/

Laravel 9: Symfony 6 underneath, and a smaller upgrade than expected

1,983 words, about 9 min read

A finance user uploaded a scanned remittance slip on the Monday after we shipped the Laravel 9 branch, and the screen told her it had saved. It had not. The volume on that box was full, Storage::put() returned false, and our controller did what it had done since 2020: ignore the return value, because under Laravel 8 a failed write raised an exception and the handler logged it for us. Six uploads went nowhere over about forty minutes before support noticed the attachment column was empty.

That was the only real casualty of the upgrade, and it is a Flysystem 3 behaviour change that is documented in plain words in the guide I had read the week before. Everything else went in over a Saturday. The internal ops and finance app is now on 9.40.1, which is the release from 15 November. The customer-facing platform stays on Symfony 6.1 and none of this touched it.

The write that stopped throwing

Laravel 9 moves from Flysystem 1.x straight to 3.x, and the upgrade guide lists five behaviour changes under that heading. Two of them changed how our code has to be written, and I only respected one of them before deploying.

The first: put, write and writeStream no longer throw when a write fails, they return false. The second: reading a file that does not exist returns null instead of throwing IlluminateContractsFilesystemFileNotFoundException. The third, which we depended on without knowing it: writes now overwrite existing files by default, so if you were relying on a collision to stop a duplicate upload, that guard is gone and you have to check existence yourself. Deleting a file that does not exist now returns true. And cached adapters are gone from Flysystem entirely, so the cache key inside a disk config is dead configuration.

The fix for the silent write is one line of config per disk, and I wish I had put it in the same commit as the dependency bump rather than four days later:

// config/filesystems.php
'scans' => [
    'driver' => 'local',
    'root' => storage_path('app/scans'),
    'throw' => true,
],

With throw set, the old behaviour is back and our existing exception handling works unchanged. I turned it on for all four disks. I do not think returning false is the wrong default for a library, but for an application that has been written against the throwing version for two years, the default is a trap, and it is a trap that passes every test you already have because nobody writes a test for a full disk.

Two more Flysystem items cost us time in the pull request rather than in production. The S3 adapter is no longer pulled in by the framework, so it needs an explicit composer require -W league/flysystem-aws-s3-v3 "^3.0", and the -W matters because Composer otherwise refuses to move the dependencies it needs to move. And Storage::extend now has to return an IlluminateFilesystemFilesystemAdapter rather than a bare LeagueFlysystemFilesystem. We have one custom driver, a read-only wrapper over a partner’s SFTP drop, and it broke loudly at boot rather than quietly at runtime, which I will take.

Dependency bumps, and where the PHP floor actually sits

The dependency half of the upgrade is genuinely short. Laravel 9 requires PHP 8.0.2, not 8.0, which is the kind of detail that only hurts if you are pinned to a distribution package. The framework’s own composer.json spells out the rest: php: ^8.0.2, league/flysystem: ^3.0, and ^6.0 against ten Symfony components, console, error-handler, finder, http-foundation, http-kernel, mailer, mime, process, routing and var-dumper.

-        "laravel/framework": "^8.75",
-        "facade/ignition": "^2.5",
-        "fideloper/proxy": "^4.4",
+        "laravel/framework": "^9.0",
+        "spatie/laravel-ignition": "^1.0",
-        "nunomaduro/collision": "^5.10",
+        "nunomaduro/collision": "^6.1",

facade/ignition is replaced by spatie/laravel-ignition at ^1.0, and fideloper/proxy is dropped entirely because the trusted proxy middleware now lives in the framework. If you keep the old TrustProxies file, swap the import to IlluminateHttpMiddlewareTrustProxies and replace Request::HEADER_X_FORWARDED_ALL with the explicit bitmask the guide gives, because the combined constant is gone in Symfony 6. We are behind an ALB, so the HEADER_X_FORWARDED_AWS_ELB bit is the one that matters to us.

We were on PHP 8.0 on that box and went to 8.1.12 in the same maintenance window. Not because Laravel 9 asks for it, it does not, but because the two features I actually wanted are 8.1 only: enum attribute casting and implicit route bindings on enums. The casting docs carry a warning to that effect at the top of the section. Upgrading the runtime and the framework together is against my own advice and I did it anyway, because the box is small, the app has 287 test cases, and the alternative was two maintenance windows in one month.

Accessors: the new syntax, and the ones I left alone

The accessor rewrite is the change I expected to like least and use most. Old style, a pair of prefixed methods that exist by naming convention:

// Laravel 8
public function getConsigneeNameAttribute($value)
{
    return Str::title($value);
}

public function setConsigneeNameAttribute($value)
{
    $this->attributes['consignee_name'] = Str::upper($value);
}

New style, one method with a return type the framework can see:

use IlluminateDatabaseEloquentCastsAttribute;
use IlluminateSupportStr;

protected function consigneeName(): Attribute
{
    return Attribute::make(
        get: fn ($value) => Str::title($value),
        set: fn ($value) => Str::upper($value),
    );
}

The Attribute return type is what makes it work, and the practical gain is not the typing, it is that the accessor and the mutator for one column sit next to each other instead of thirty lines apart in a file nobody has reorganised since 2019. Object values returned from a get closure are retained and synced back to the model before save, which is the same caching behaviour custom cast classes have. For primitives you have to ask for it with ->shouldCache().

The old prefixed methods still work, so this is not a forced migration, and I converted eleven of our forty-odd accessors: the ones that had a matching mutator. A lone getStatusLabelAttribute() with no setter gets nothing out of the new shape except a diff, so it stayed. I expect to regret the mixed style in about a year.

Enum casting was the bigger practical win. Our shipment status had been a string column with a class of constants and a validator nobody trusted. Now it is a backed enum in $casts, and the four places that compared strings compare enum cases. make:migration also generates anonymous classes now, return new class extends Migration, which quietly removes the whole category of failure where two migrations end up with the same class name after a rebase. Old migration files with named classes keep running.

Full text search, and the search box we stopped over-engineering

The ops app has a search field over 1.9 million shipment rows with a free text address block on each one. It was a stack of LIKE '%...%' clauses, p95 around 2.4 seconds on the worst query, and the plan for the last year had been “put MeiliSearch on the box eventually”.

Laravel 9 makes that plan unnecessary for us. fullText() in the schema builder creates the index and whereFullText generates the right clause per database:

Schema::table('shipments', function (Blueprint $table) {
    $table->fullText(['consignee_name', 'address_block']);
});

$rows = DB::table('shipments')
    ->whereFullText(['consignee_name', 'address_block'], $term)
    ->limit(50)
    ->get();

On MySQL 8 that compiles to a MATCH ... AGAINST in natural language mode, which you can read straight out of the MySQL grammar; the third argument is an options array, so ['mode' => 'boolean'] gets you boolean mode instead:

select * from `shipments`
where match (`consignee_name`, `address_block`) against (? in natural language mode)
limit 50

Same query, p95 of 180ms. The index build on a live table took about six minutes and I ran it at 02:00 anyway.

Scout also gained a database engine in this release, for MySQL and PostgreSQL only, which runs “where like” and full text queries against your existing tables and needs no import step at all. You mark the strategy with attributes on toSearchableArray, #[SearchUsingFullText(['bio'])] and #[SearchUsingPrefix(['id', 'email'])], per the Scout docs. We did not use it, because we do not have Scout installed and the query builder covered the one screen that needed help. If we had already been running Algolia for a small dataset, I would be ripping it out this month.

route:list, and what the yearly cadence did to the calendar

The rewritten route:list is the change the rest of the team noticed first. Routes are grouped, verbs are coloured, and middleware is hidden until you ask for it with -v. The flags that earn their keep on a fifteen year old route file are --except-vendor, which drops everything registered by packages, and --path=admin for a subtree. There is a --json output too, which I now pipe into a check that fails CI if an admin route appears without our authorisation middleware.

On timing: Laravel moved to one major release a year with Laravel 8, and 9 was the release that shifted the anniversary. It was originally due in September 2021. Taylor Otwell’s note from July 2021 explains the delay plainly, that waiting for Symfony 6.0 in November meant the Symfony components could be upgraded in 9.0 instead of being stuck on 5.x until late 2022, and that yearly Laravel releases would from then on land about two months after Symfony’s. It shipped on 8 February 2022. The next major is due early next year, two months behind Symfony again.

The support policy is 18 months of bug fixes and two years of security fixes, which produces these dates:

VersionReleasedBug fixes untilSecurity fixes until
88 September 202026 July 202224 January 2023
98 February 20228 August 20236 February 2024

Laravel 8 stopped getting bug fixes in July. That is the sentence that actually moved this upgrade to the top of the list, not any feature above it. We were running an unpatched-for-bugs framework for four months and the only reason it did not matter is luck.

Thirty minutes is the estimate at the top of the upgrade guide. Ours was about seven hours of work plus the four days of the silent upload bug, and the split was roughly: one hour of Composer and config, two hours on Symfony Mailer (the withSwiftMessage to withSymfonyMessage rename, the SMTP stream options that are no longer supported, and deleting the code that inspected failed recipients, which cannot work any more because a send failure now throws a TransportExceptionInterface), three hours on accessors and enums that I chose to do, and one hour on tests. Nothing in the framework fought me.

What is still open: our $casts entries for enums are not covered by anything in CI, so a renamed enum case would pass static analysis and fail at read time on rows written before the rename. I want a command that walks every enum cast and asserts that all distinct values in the column resolve to a case. Until then there is a query I run by hand after deploys, which is a habit rather than a fix, and habits skip deploys.

Sources

  • Laravel 9 release notes: the 8 February 2022 release date, the PHP 8.0 minimum, the support policy table, and the feature list including Flysystem 3, the Scout database engine and the improved route:list.
  • Laravel 9 upgrade guide: the PHP 8.0.2 requirement, the Composer dependency changes, and every Flysystem 3 behaviour change quoted above including the throw option and the Storage::extend signature.
  • Laravel 9 release date: Taylor Otwell’s July 2021 post on delaying 9.0 to pick up Symfony 6.0 and on releasing two months after Symfony from then on.
  • laravel/framework v9.0.0 composer.json: the exact constraints, php ^8.0.2, league/flysystem ^3.0 and ^6.0 on the Symfony components.
  • Eloquent mutators and casting: the Attribute return type, Attribute::make, accessor caching and shouldCache, and the note that enum casting needs PHP 8.1.
  • Migrations: anonymous migration classes and the fullText index type.
  • Laravel Scout: the database engine, its MySQL and PostgreSQL limitation, and the SearchUsingFullText and SearchUsingPrefix attributes.
  • MySQL query grammar at v9.40.1: how whereFullText compiles to match ... against and where the boolean mode option is read.