The Laravel 8 upgrade guide puts Estimated Upgrade Time: 15 Minutes at the top of the page. Renaming a directory, rewriting factories and re-reading two queue classes took me most of a Tuesday afternoon. The only real argument, though, was about something the guide never mentions: whether the client dashboard should now get Jetstream, and with it Livewire.
It did not. The upgrade first, then the reasoning, because the reasoning is the part I expect to be wrong about in a year.
The parts that cost time
Laravel 8.0 shipped on 8 September. We went from 7.30 to 8.15.0 on 17 November, the day it was tagged, per the framework changelog. The dashboard runs PHP 7.4 and the new floor is 7.3.0, so the language was free. Dependencies were one commit: framework to ^8.0, guzzlehttp/guzzle to ^7.0.1, facade/ignition to ^2.3.6, nunomaduro/collision to ^5.0, phpunit/phpunit to ^9.0.
Then the high-impact list. Seeders and factories are namespaced now: database/seeds becomes database/seeders, classes get DatabaseSeeders and DatabaseFactories, and the classmap block in composer.json is replaced by PSR-4 entries. Mechanical, and it touched 41 files.
Queued job properties were renamed: retryAfter is backoff, timeoutAt is retryUntil. Two of our jobs used the old names, and nothing fails loudly when you leave them. The paginator defaults to Tailwind markup now, so Paginator::useBootstrap() went into AppServiceProvider::boot() and our pagination links stopped looking like a ransom note.
We also moved 34 models into app/Models. Not required, since the framework falls back to app, but the generators assume it once the directory exists. Routes stayed put: new 8.x applications ship a null $namespace in RouteServiceProvider and the [UserController::class, 'index'] callable syntax, while upgraded applications keep the property, so ours still resolves plain strings.
What we picked up
Class-based model factories are the real headline. Our old factory file was 600 lines of closures with states passed as strings. Now each factory is a class with a definition() method and states are methods, so a suspended account is User::factory()->suspended()->create() and the editor can follow it. I leaned on laravel/legacy-factories for two days, converted 18 factories by hand, then removed it.
Migration squashing killed a real irritation. php artisan schema:dump --prune writes the schema to database/schema and deletes the migrations it covers. Our 173 became one SQL file plus the four written since, and the test database builds in about 4 seconds instead of 31.
Job batching replaced a homemade progress counter. The dashboard imports courier tariff spreadsheets, roughly 4,000 rows a time, chunked into jobs; completion used to be inferred from a Redis key we incremented ourselves and got wrong whenever a worker died. The batching API wants a job_batches table (php artisan queue:batches-table) and a uuid column on failed_jobs, then it is this:
use AppJobsImportTariffRows;
use IlluminateBusBatch;
use IlluminateSupportFacadesBus;
use Throwable;
$batch = Bus::batch($chunks->map(function (array $rows) use ($upload) {
return new ImportTariffRows($upload->id, $rows);
})->all())->then(function (Batch $batch) use ($upload) {
$upload->markImported();
})->catch(function (Batch $batch, Throwable $e) use ($upload) {
$upload->markFailed($e->getMessage());
})->dispatch();
return $batch->id;
Two smaller ones landed the same week. Named rate limiters, registered as closures in configureRateLimiting() and applied as throttle:uploads middleware, gave the import endpoint Limit::perMinute(20)->by($request->user()->id) with no custom middleware; the routing docs have the syntax. And php artisan down --secret="...", which replaces the old IP allow list: visit the URL containing the token, collect a cookie, browse normally while everyone else gets the 503. Add --render and the two extra lines in public/index.php and that page renders before the vendor directory finishes swapping, which is exactly when our deploys used to leak a stack trace. Both are in the configuration docs.
Why Blade stayed
Jetstream is the flagship here, and it is good: login, registration, email verification, two-factor authentication, session management, Sanctum API tokens, optional teams, Tailwind throughout, and a choice of Livewire or Inertia. It also replaces the authentication scaffolding, which this dashboard has not had since someone hand-wrote its login in 2018. So the question was never Jetstream. It was Livewire.
The interactive surface is small: two screens with live filtering, one uploader, and the rest are forms that post, redirect and flash a message. Livewire’s value scales with how much state lives on a page, and most of ours have none.
Debugging stays cheap with server-rendered pages. A Blade view that breaks does it in one request, in the access log, with a stack trace in Ignition. A component that breaks mid-interaction breaks inside an XHR nobody opens a console for, and the evidence is a 500 on a message endpoint with a payload that means little without the component’s state.
Then handover. Two of us touch this codebase, plus a part-time developer on the client’s side whose JavaScript is jQuery. Blade with 200 lines of hand-written JavaScript is something he can change. A component protocol is not.
And Blade improved anyway. Components with the x- syntax arrived in 7.x, and 8.x adds <x-dynamic-component :component="$name" /> for when you only know the component at runtime. That plus anonymous components removed most of the duplication that would have pushed me toward a component framework.
Where Livewire would have won
The shipment filter screen. Eleven inputs, debounced, kept in sync with the query string so the page stays linkable, with empty states and a spinner. That is 180 lines of JavaScript I wrote and now own, where wire:model and a render method would have been about 30 lines of PHP. No good argument against that, only a preference for the stack I can debug at 11pm. The other honest gap is two-factor authentication and session management: we have neither, we have talked about building them twice, and Jetstream ships both.
So the rule went into the README. A new screen that would need more than roughly 150 lines of JavaScript gets Livewire on that route alone, and the rest stays Blade. The tariff editor is next, in January, and I suspect it is the one that breaks the rule.
Sources
- Laravel 8 release notes: the 8 September 2020 release date, Jetstream, factory classes, migration squashing, batching, rate limiting, maintenance mode and the routing namespace change.
- Laravel 8 upgrade guide: the 15 minute estimate, dependency constraints, seeder and factory namespaces, the
backoffandretryUntilrenames, pagination defaults, theCollection::offsetExistschange and thefailed_jobsuuid column. - Queue documentation: the
queue:batches-tablecommand and thethen,catchandfinallycallbacks used by the tariff import. - Routing documentation: defining rate limiters,
Limit::perMinute,by()and thethrottlemiddleware. - Configuration documentation: maintenance mode, the
--secretbypass token and pre-rendering the 503 view. - Database testing documentation: class-based factories,
definition(), state methods and theHasFactorytrait. - laravel/framework changelog: 8.15.0 tagged on 17 November 2020, the version this was written against.
- Livewire 2 quickstart: what adopting Livewire involves, from
make:livewiretowire:clickand the scripts in the layout.