The team had skipped 5.7 and 5.8 because the previous minor upgrade had broken three things and taken two days, and the version after that was going to be a major. Then 6.0 shipped in September and the upgrade took an afternoon — because the number is bigger and the change is smaller, which requires explaining.
The symptom
# what a MINOR upgrade used to mean
$ git log --oneline v5.7.0..v5.8.0 -- src/Illuminate | grep -ci 'breaking|remove'
14
$ cat UPGRADE.md | sed -n '/5.7 to 5.8/,/5.6 to 5.7/p' | grep -c '^###'
22 # twenty-two sections in a MINOR release guide
# and the result, on a team that had been burned
$ composer show laravel/framework | head -2
name laravel/framework
versions * v5.6.39 ← eighteen months behindTwenty-two upgrade sections in a minor release is not semantic versioning by any definition, and a team that reads that once quite reasonably stops upgrading. Eighteen months behind is where they end up, and by then the upgrade genuinely is a project.
Why it happens
Laravel’s old scheme used the second number for what other projects use the first number for: 5.7 and 5.8 could differ arbitrarily, and the leading 5 meant nothing except an era. That was internally consistent and it meant Composer’s ^5.7 constraint — which promises compatibility — was allowing changes that were not compatible.
The consequence is a team that cannot distinguish a safe upgrade from a risky one, so it treats them all as risky and does none. That is the failure semver exists to prevent, and the version numbers being unusual is a smaller cost than the upgrades not happening.
The fix
What the numbers mean now
6.0 → 7.0 a major. breaking changes, an upgrade guide, ~6 months apart.
6.0 → 6.1 a minor. additive only. safe.
6.1 → 6.1.4 a patch. fixes only.
LTS: 6.x gets bug fixes for 2 years and security fixes for 3.
non-LTS: bug fixes for 6 months, security fixes for 1 year.
which makes "^6.0" in composer.json mean what it says
for the first time.The practical effect is that composer update laravel/framework within a major is now a thing that can be done on a Tuesday without reading anything. That is the whole benefit and it is larger than it sounds — an upgrade that is boring gets done, and a codebase that stays current never faces the eighteen-month version.
The LTS promise is worth reading precisely: two years of bug fixes, three of security. It does not promise that packages in the ecosystem will support 6.x for three years, and in practice the community moves faster than the framework — so an LTS project on a two-year-old version will find its dependencies dropping support before Laravel does.
The only real break: the helpers moved
$ grep -rn 'str_slug|array_get|str_random|array_pluck' app/ | wc -l
214
# the five-minute answer
$ composer require laravel/helpers
# the right answer
# str_slug($title) → Str::slug($title)
# array_get($a, 'k.j') → Arr::get($a, 'k.j')
# str_random(32) → Str::random(32)
$ vendor/bin/rector process app --set laravel60The compatibility package defers the work indefinitely and is a legitimate choice under deadline. The reason to do the replacement instead is that global functions with those prefixes collide with PHP’s own namespace — str_contains became a core function in 8.0, and a project still shipping a global of that name will find out the hard way.
Rector handles almost all of it mechanically. The residue is the calls that were aliased or wrapped, which is a handful in a codebase of that size and is worth doing by hand in the same commit.
Ignition, and the error page that suggests the fix
$ composer require --dev facade/ignition
# what it recognises, among others:
# an undefined variable in a Blade view — and which one
# a missing .env key that a config file reads
# a route referenced by a name that does not exist
# an unknown Eloquent column, WITH the table's real columns listed
#
# and for two of those it offers to apply the fix.
Keeping it in require-dev matters more than it looks. A debug error page in production leaks environment variables, the file layout and occasionally credentials — APP_DEBUG is supposed to prevent that, and relying on one flag being correct on every host is thinner protection than not installing the package at all.
The solution suggestions are genuinely useful for the first month with a codebase and stop mattering afterwards, which is the right shape for a development tool. The column-listing one earns its place permanently, because a typo in a column name is otherwise a database error with no context.
The additions worth adopting immediately
// subquery selects — one column, no hydration
$orders = Order::addSelect(['last_shipped_at' => OrderLine::select('shipped_at')
->whereColumn('order_id', 'orders.id')
->latest('shipped_at')
->limit(1)
])->get();
// lazy collections — constant memory over any result set
foreach (Order::cursor()->filter($isExportable) as $order) { /* ... */ }
// job middleware — the rate limit is not in the handler
public function middleware()
{
return [new RateLimited('exports'), new PreventOverlapping($this->id)];
}
The subquery select is the one with the most immediate effect on a real application: eager-loading an entire relation to read one field from its most recent row is a common shape, and this replaces it with a column in the existing query. It needs an index supporting the correlated lookup, and without one it is worse than what it replaced.
Job middleware is the same idea as HTTP middleware applied to queued work, and it moves throttling and overlap prevention out of the handler — which makes the handler testable without Redis and puts the operational concern where it can be reused.
Verifying it worked
$ composer require laravel/framework:^6.0
$ php artisan --version
Laravel Framework 6.0.4
$ vendor/bin/phpunit
OK (1284 tests, 3891 assertions)
$ git diff --stat | tail -1
218 files changed, 231 insertions(+), 231 deletions(-)
# and the check that the upgrade actually removed the fear
$ composer update laravel/framework --dry-run
Upgrading laravel/framework (v6.0.4 => v6.4.1)
$ composer update laravel/framework && vendor/bin/phpunit
OK (1284 tests, 3891 assertions)Equal insertion and deletion counts is the cheap proof that the change was mechanical — the helper renames and nothing else. The second half is the point of the whole exercise: taking four minor versions in one command with a green suite is what “boring” means, and it is worth demonstrating once so that the team believes the version scheme changed.
What this costs
A faster major cadence, which is a trade rather than a win. Six months between majors means an application on a non-LTS version is out of support within a year, and the ecosystem now has more versions to support — a package author maintaining compatibility with 6.x and 7.x is doing more work than one supporting 5.x. Some of them will not, and the cost lands on applications that depend on them.
The other cost is that LTS is a tempting place to stop, and stopping is what produced the eighteen-month problem in the first place. An LTS release is a floor for security patches rather than a licence to skip three majors, and a team that treats it as the latter arrives at exactly the same upgrade cliff two years later with a longer distance to travel. The discipline the new scheme enables — upgrade minors on a schedule, majors within a couple of months — is worth adopting deliberately rather than assuming it follows from the version numbers.