- Published on
Starting a new Laravel app
- Author
-
-
- Name
- owls
- Mastodon
- @owls@yshi.org
-
I’ve started working on a new Laravel app. That isn’t uncommon, but it is a good opportunity for some blogging!
I wanted to do more restaurant reviews — but the world has other ideas — so here’s some thoughts on how I’m setting the new app up instead.
Oracle
The database is unusual for this app: I am replacing a web app using a legacy Oracle database. There’s still tons of business logic external to the app (yay, batch jobs) written against this DB, so moving it to postgres isn’t in the cards right now.
This usually means it’s time for another oci8 or pdo_oci adventure. I’ve been building these extensions on-and-off for the last 15 years or so, and it’s never fun.
- Oracle [used to] make it difficult to download the instant client (but credit where it’s due, [this no longer needs an account / EULA acceptance](http://I just noticed this today ... https://blogs.oracle.com/opal/oracle-instant-client-downloadable-without-login-breaking-news), so scripting setup is possible now)
- The PHP extension build scripts usually fail to detect where the instant client is installe to, so I have to go source-diving to figure out what it’s expecting
- If you’re using a product that comes with PHP in a non-standard location (lookin’ at you, Zend Server), the build scripts may fail to find some of the PHP devel stuff, like the
php-config
command - You have to rebuild whenever you do a minor PHP version upgrade, since no vendor is shipping pre-built Oracle extensions for PHP (thanks, Oracle licensing)
Even when you do have oci8 or pdo_oci set up, you run into random bugs that nobody has bothered fixing, because who the hell is even using this nonsense?
But deploying to Vapor offers me a blessing in disguise: getting the Oracle driver built as an additional Lambda layer sounds like a HUGE pain in the ass, so the team came up with something totally different instead:
This neatly solves a second problem I would have had to deal with — the Oracle DB is on-premises, so the code needs to run in our VPC with a VPN connection back to the datacenter. Vapor uses its own VPC with no VPN set up. The express app will live in my VPC, and Laravel will make API calls to it over the internets.
Installing the node-oracledb package is MUCH easier then building a PHP extension. I’m even able to bundle the Linux instant client with the repo, so there’s zero extra setup — yarn install && yarn start
and you’re in business.
“But why do you even need Laravel“, you ask yourself, “if you have a perfectly good Node app…”
Well, a couple reasons. First of all, because we’re Laravel devs. But we want to use Livewire and eventually build some async jobs in. Eventually, we want to migrate the Oracle stuff to postgres and tie it directly into the Laravel app, so this Node piece is a transitory shim that will be going away in a year or two.
Eloquent for Oracle
Before we decided on using Express, we had considered Laravel -> Oracle directly. Once you clear the driver hurdle, there’s still a problem: Eloquent doesn’t have an Oracle grammar.
Yajra has a package that fixes this: laravel-oci8. Even though we did not go this route, I’m confident that package would have worked out well — I use another of Yajra’s packages, laravel-datatables.
Yajra is a great developer 💓
JSON:API
If you attended Laracon Online 2020, you saw Matt Stauffer’s talk on the JSON:API spec. It’s a very reasonable standard for making REST APIs, so I decided to run with it.
That does introduce some complexity — my models need to be decorated by a bunch more Stuff before the API sends them to the client.
I was writing an OpenAPI spec by hand for a couple of the API endpoints, so the first thing I needed were some OpenAPI models for the JSON:API “stuff”. I found exactly what I needed in a gist, and that got me started.
On the Express side, I went with Yayson to make things easier. It’s not a complete solution though — the first time I wanted to emit an error response, the library left me hanging.
I would have preferred something more complete like ethanresnick/json-api, but it REALLY wants you to be using a supported ORM, and there’s not much love for Oracle in developing-ORMs-for-node community.
Writing my own adapter looked tedious, so Yayson was the better choice: all I needed to do was write an error model & presenter.
On the Laravel side, I’m working with swisnl/json-api-client. It’s still early days, but this seems like a very thorough client implementation. I was going to write repositories for all my models; this has a base class for a repository that Just Works.
Laravel Authentication
Authenticating users for this project is very different from my usual approach: plug in to our SSO system and generate user models for people I don’t have in the database yet.
All of the auth is instead outsourced to the Express API, and I needed to plug authentication into the JSON:API client’s repository base class.
My API has two authentication options: forwarding a cookie for our SSO system, or providing an API token header (for a few select admin users). Once you authenticate, the API exposes your user info & permissions, so Laravel can grab those.
I did this with a custom guard & user provider. The default session guard almost did what I wanted, but it had a bunch of stuff for a ‘remember me’ token and other types of auth that I didn’t want to deal with.
I rolled my own simple guard. The GuardHelpers trait made it painless — if you’re writing a guard, you probably want to mix that in.
The user provider is just a shim to hit the JSON:API repository. You need to implement five methods; four of mine just throw a NotImplemented
exception. It’s only being called from the guard I wrote, so I know those extra methods won’t ever be called.
Wiring up the JSON:API client so it forwards the SSO cookie took me awhile to figure out because what I actually wanted was more complex: forward the cookie for web requests, but use an admin API token (from my env vars) for tinker, console commands, and async jobs.
Tinker is important to our teams’ development workflow, and having to go copy-paste a cookie to make it work would have made for a poor developer experience.
I ended up with a two-piece solution: bind the JSON:API ClientInterface to a class that extends the package’s client, but adds an extra API token header as a default. Admin API token auth becomes the “default” for the Laravel app.
Then, to get the cookie forwarding working, I added a middleware to the web route group, right before the authentication middleware. This becomes the default for web requests. It felt like a very clean approach, since middleware receives the request and can grab the cookie value off that.
Here’s the cookie one, as an example:
<?php
namespace App\Repositories\ApiAuth;
use Psr\Http\Client\ClientInterface as HttpClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Swis\JsonApi\Client\Client;
/**
* SSO cookie authenticated JSON:API client, for general use by the UI.
*/
class SsoClient extends Client
{
public function __construct(string $api_url, string $sso_token, string $cookie_name, HttpClientInterface $client = null, RequestFactoryInterface $requestFactory = null, StreamFactoryInterface $streamFactory = null) {
parent::__construct($client, $requestFactory, $streamFactory);
$this->setBaseUri($api_url);
$this->setDefaultHeaders($this->mergeHeaders([
'Cookie' => sprintf('%s=%s', $cookie_name, $sso_token),
]));
}
}
And the binding, which I’ve placed in a dedicated middleware and applied to the web group:
<?php
namespace App\Http\Middleware;
use Closure;
use App\Repositories\ApiAuth\SsoClient;
use Swis\JsonApi\Client\Interfaces\ClientInterface;
class BindSsoCookieToApiClient
{
public function handle($request, Closure $next)
{
// The admin API token client is the default (bound in AppServiceProvider),
// but for HTTP requests we want to use the SSO client so it forwards the cookie.
$cookie_name = config('my-app.cookie-name');
$apiKeyClient = app()->makeWith(SsoClient::class, [
'api_url' => config('jsonapi.base_uri'),
'cookie_name' => $cookie_name,
'sso_token' => $request->cookie($cookie_name),
]);
app()->instance(ClientInterface::class, $apiKeyClient);
return $next($request);
}
}
Since my SSO cookie isn’t set by Laravel, there is one last step: add it to the EncryptCookies exception list, so Laravel doesn’t blank the value out. I somehow had failed to notice this default middleware for the last two years, and I had been grabbing it from the $_COOKIE
superglobal instead … doh!
Cosmetics
I have a standard Bootstrap theme & site layout that I’ve been using in our Laravel apps. Until now, I’ve just been copy-pasting it from app to app.
That seemed like a maintenance time-bomb. With Bootstrap 5 drawing closer, I took steps to defuse the bomb and packaged my UI up as a UI preset. User-definable presets became a thing in Laravel 6, but the laravel/ui
package has been on my mind a lot more since 7 launched.
My initial package ejected everything into the app. This didn’t feel like a good fix: devs would customize things and upgrading would still be impossible.
I looked at a few other packages to see if they handled that problem. Turns out, Laravel has a solution and I’d missed it: packages can register a view namespace. I was ejecting a couple views for the layout & some standard UI components, so I pulled them back into the package.
Unfortunately, the same functionality doesn’t exist for the SASS/JS stuff, so that’s still being ejected. If anybody has a good solution for that, ping me @owlmanatt … some of it should be ejected, but I don’t want stuff like my social icon styles to be changed, since they’re done per our organization’s branding guide.
Developer Experience
My role increasingly involves creating positive developer experiences: writing Terraform modules for our different infrastructure patterns, doing the early bits of development projects to set the scene for other teams to flesh out, and writing loads of documentation.
Just like we give user experience a lot of thought, I’m constantly evaluating the developer experience (DX) that my decisions result in.
Auto-wiring the authentication stuff to the JSON:API repositories is one less thing future developers have to think about. The new UI package, plus my tools package, means our dev teams can go from generating a new Laravel app skeleton to the meat of their projects immediately.
When I was setting up the Express project, I tried to make it “feel” like a Laravel app. Express is a barebones framework, so there’s not much structure. Setting it up with controllers, putting the routes file where you’d see it in Laravel, and adding dotenv will make our developers more comfortable crossing over.
Both the Laravel app & API include setup steps in the README that have been tested on Windows, OS X, and our usual Linux VM. This is a critical step — nothing kills momentum like “I spent all week figuring out how to set up my dev environment”.
Laravel’s easy thanks to Homestead/Valet, but Express offers a pretty shite DX out-of-the-box. It doesn’t detect code changes, so you have to stop/start the app constantly. I went through and set up Nodemon so nobody has to think about this when they’re working.
It’s all minor stuff, but the lifespan of our apps is usually around 10 – 15 years. Every minor thing that I can do to make developing & maintaining the app easier adds up to a lot of saved time over such a long lifespan!