Laravel 9 shipped on the eighth of February, five months after 8, and it is the first release on a fixed annual cadence. The feature list is short and the underlying change is not: the Symfony components moved from 5 to 6, which raises the minimum PHP version to 8.0 and pulls a dozen transitive dependencies with it.
The symptom
$ composer require laravel/framework:^9.0 --dry-run
Your requirements could not be resolved to an installable
set of packages.
- laravel/framework[v9.0.0] requires symfony/console ^6.0
- some/package[2.4.1] requires symfony/console ^5.0
$ composer why-not laravel/framework 9.0
some/package 2.4.1 requires symfony/console (^5.0)
another/package 1.8.0 requires php (^7.4)
# two packages. that is the entire estimate.composer why-not answers “when can we do this” in one command and it should be the first thing run rather than the last. The answer is usually a date somebody else controls, and knowing that before planning is the difference between a sprint and a surprise.
Why it happens
Laravel does not pin its Symfony components loosely — a major of the framework requires a specific major of each component, and any package in the tree requiring the previous one is an unsatisfiable constraint. The dependency resolver reports this accurately and the message is long enough that people stop reading before the useful line.
The fix
Clearing the blockers, which is most of the work
# for each blocker, in order of preference:
# 1. a release that supports Symfony 6 exists → bump it
# 2. a pull request exists → wait, or contribute
# 3. neither → fork, replace, or remove the feature
$ composer show some/package --all | grep -A2 versions
versions : * 2.4.1, 2.4.0, 2.3.0
# no 3.x. no open PR. abandoned in 2021.
# it did one thing: a CSV writer. replaced with 40 lines.The abandoned package doing one small thing is the common case and the cheapest to resolve, because the forty lines that replace it are forty lines nobody has to keep up to date. The expensive case is a package that is central, maintained, and slow — at which point the options are waiting, contributing, or a fork with a date attached to it.
The Symfony 6 behaviour changes underneath
// symfony/console 6: a command signature is typed
#[AsCommand(name: 'app:reconcile', description: 'Reconcile orders')]
final class ReconcileCommand extends Command
{
protected function execute(InputInterface $i, OutputInterface $o): int
{
return Command::SUCCESS; // an int return is now required
}
}
// symfony/mailer 6: the transport DSN parser is stricter,
// and a password containing an unencoded @ now fails
// with a message about an invalid DSN rather than an
// authentication error.
The DSN parsing change is the one that reached production, because a mail password with a special character in it had been working by accident and the new parser is correct to reject it. The error message names the DSN rather than the password, which sent the investigation in the wrong direction for an hour.
What is genuinely new
// accessors and mutators: one method rather than two
protected function fullName(): Attribute
{
return Attribute::make(
get: fn () => $this->first_name . ' ' . $this->last_name,
)->shouldCache(); // ← this is the new capability
}
// full-text indexes in the schema builder
$table->fullText(['name', 'description']);
Product::whereFullText(['name', 'description'], 'desk lamp')->get();
// forced scoping, which fixes an authorisation bug
Route::scopeBindings()->group(function () { /* ... */ });
shouldCache is the only one of these with no equivalent in 8: an accessor doing real work was recomputed on every read, including inside a loop over a collection, and there was no supported way to memoise it. The old accessor syntax still works and is not deprecated, so the new form is an option rather than a migration.
Scoped bindings are the change worth applying immediately, because the unscoped behaviour is an authorisation bug that looks like routing — a request for one customer’s order under another customer’s URL resolved the order, and only a policy checking ownership caught it.
Flysystem 3, and the failures that became exceptions
// config/filesystems.php
'disks' => [
's3' => [
'driver' => 's3',
'throw' => true, // opt in; the default is false
],
],
// with throw => false, a failed write returns false and
// the reason is gone.
// with throw => true, an exception with a message.
if (! Storage::disk('s3')->put($path, $contents)) {
// this branch has been silently taken for two years
}
Defaulting to the old behaviour is a kindness that also means most applications upgraded without gaining anything. Turning it on one disk at a time, starting with the one written to least often, is how it gets adopted rather than reverted — because it will surface something, and the something is usually a permission problem that has been failing quietly.
Verifying it worked
$ php artisan --version
Laravel Framework 9.5.1
$ composer why-not laravel/framework 9.0
# (no output)
$ vendor/bin/phpunit
Tests: 1,412 passed
$ vendor/bin/phpstan analyse
[OK] No errors
# and the check that the deprecation surface is clean
$ php artisan test 2>&1 | grep -ci deprecat
0A clean static analysis run after a framework major is a stronger signal than a passing suite, because it covers the paths the tests do not — a removed method on a class nobody instantiates in a test is a runtime error waiting for the right request.
What this costs
An annual major and a twelve-month support window for anything other than the latest release, which is a considerably faster cadence than the roughly six-month one that preceded it. That is a commitment to upgrading yearly, and a team that upgrades every second year is now permanently on an unsupported version rather than occasionally behind.
The package ecosystem is the recurring constraint rather than the framework itself. Every future major puts a ceiling on the upgrade until the slowest dependency moves, and the only real answers are to replace it, to vendor it, or to contribute the compatibility work — the third being the one nobody budgets for and the one that would help most.