PHP Modernization

Laravel Upgrades Stall on Packages, PHP, and Untested Money Paths

Laravel majors stall on packages, PHP, Horizon and untested checkout. Shift rewrites files. It does not own your Composer graph or your money path.

A Laravel upgrade blocked in the real world is rarely blocked on the framework’s upgrade guide. The guide is fine. Laravel Shift is fine for mechanical diffs. What stalls the major is the rest of the estate: a package that last tagged for Laravel 8, a PHP pin, Horizon workers that boot the whole application, Scout with a custom engine, macros that reached into internals, and a checkout path whose only test is a human in staging.

Laravel upgrades stall on packages, PHP, and untested money paths. If you treat Shift as the project, you will get a large PR that does not install. If you treat “rewrite in Go” as the project because Laravel 11 felt far away, you will skip the upgrade versus extract versus rewrite choice and pay for a second product. Laravel to Go migration is for a named workload after the app is patchable. It is not an escape hatch from Composer.

This article is what is usually stuck, and the sequence that unsticks it.

Shift is not the inventory

Laravel Shift (and similar automated diffs) rewrites files for namespace changes, config keys, removed helpers, PHPUnit attributes. That work is real and it is the smallest part. Shift does not:

  • Negotiate with an abandoned package that requires laravel/framework: ^8.0.
  • Rebuild ext-* on the production image.
  • Add characterisation around billing.
  • Restart Horizon without dropping jobs.
  • Tell you that a macro on Illuminate\Database\Query\Builder calls a method removed in the next major.

Use Shift or a Rector set as a mechanical pass after the constraint graph allows the target. Using it as the plan is how you open a 400-file PR on Monday and revert the conversation on Thursday.

The inventory is a table: package, Laravel constraint, PHP constraint, last release, whether the app boots on a scratch image of the next major, owner, fate (delete, replace, wrap, fork). Add rows for custom service providers, macros, and AppServiceProvider bindings that alias internals. Add Horizon, Scout, Socialite providers, Cashier, Nova if you use it, and every package:discover surprise.

composer why-not laravel/framework 11.0 is the start. Then boot the kernel. Then run the money path. Those are three different failures.

Abandoned packages and PHP coupling

Laravel majors move roughly yearly. The package ecosystem lags on the long tail. The pins I see repeatedly:

  • Old admin or CRUD packages that wrapped Laravel 5/6 era Form/HTML helpers.
  • Payment, tax, and ERP SDKs that certify one Laravel major and one PHP version.
  • PDF/Excel wrappers around unmaintained binaries.
  • Authentication starters that forked Breeze/Jetstream and then froze.
  • Internal company packages on a private Satis that nobody has tagged since the last lead left.

PHP version coupling is the silent twin. Laravel 10 wanted PHP 8.1+. Laravel 11 wanted 8.2+. If production is still on 8.0 or 7.4, you do not have a Laravel 11 project. You have a PHP upgrade that must happen first or in a strict sequence. Mixing “PHP 8.3 + Laravel 11 + replace Cashier + new Horizon” in one branch is how the branch becomes the team’s personality.

config('app.php') providers and the dont-discover list hide packages that still run. composer.json extra.laravel.dont-discover is not a deletion. Grep vendor for the namespace after you think you removed it.

Nova, Spark, and other paid first-party tools have their own major lines. Budget them as packages with owners, not as “Laravel.” A Nova pin can freeze the framework as hard as a random PDF bundle.

Replacement means behaviour. Cashier’s invoice PDF is a product surface. A new billing package that rounds tax differently is not an upgrade. Characterise first.

Forks: if you vendor a package to “just bump the constraint,” you now own security patches. Put CI on it against the target Laravel and PHP. Otherwise you copied vendor/ into packages/ and bought a delay.

Queue workers, Horizon, and Scout

Queues are where Laravel upgrades go to production to die.

Horizon runs queue:work processes with the full container. A deploy that updates FPM but leaves Horizon on the old release is mixed versions. Job payloads from the new code hit old workers, or the reverse. Serialised models (SerializesModels) are especially brittle across majors: a class moved, a $hidden attribute changed, a cast changed, and the worker fatals in a loop. Horizon will restart it. The queue will not drain.

Before the major:

  • Make job payloads explicit for anything you care about: arrays of IDs, not Eloquent instances, with a version field.
  • Deploy workers in the same release unit as the app, or drain, upgrade, refill.
  • Confirm failed_jobs / the Horizon failed list has a replay you have actually run.
  • Memory limits: if workers are already near the limit, a new Laravel major’s container graph can push them over. That looks like “the upgrade caused OOMs.” It is the same leak plus a larger baseline. Measure RSS before.

Scout: drivers lag. A custom engine written against an old Engine interface will fail on the next Scout major, which may arrive with the framework bump. Typesense, Meilisearch, Elastic — each has a maintenance story. If search is the product, the engine upgrade is a project. If search is a nice-to-have, do not let it pin billing.

queue:work --once in cron is still out there. It boots Laravel per job. An upgrade that makes boot slower makes the cron pile up. Resident workers were the fix years ago. If you are still on --once, that is part of the upgrade, or you will blame PHP for a process model you chose.

Redis prefixes, Horizon prefixes, and cache keys: a major that changes a prefix can look like a total cache miss and a thundering herd. Decide whether you want a cold cache on cutover. If not, keep the prefix.

Custom macros, providers, and the undocumented kernel

Laravel’s macro system is a footgun across majors. $this inside a macro is a framework class. Methods you called on Request, Collection, Query\Builder, Str, Route get removed or change signature. Tests that never exercised the macro will not save you.

Inventory macros. Grep ::macro(. For each, write a test that uses the public behaviour you need, not the internal method you happened to call. Then the major can break the internals while the test holds the contract — or tells you to rewrite the macro.

Service providers that tap Event::listen on framework events, that replace the exception handler, that bind Illuminate\Contracts\... to a custom class copied from an old Laravel, are the same problem. Copy-pasted framework classes in app/ are a private fork of Laravel. Diff them against the target version. The diff is the estimate.

AppServiceProvider::boot that registers Blade directives, rate limiters, and Model::preventLazyLoading is fine. boot that reaches into the container and mutates a vendor singleton is not. The latter fails on a container compile change.

Middleware order and Kernel HTTP priority shift between majors. Auth, Sanctum, session, VerifyCsrfToken — a reordering is a security and session bug. Do not “clean up” middleware in the same PR as the major.

Config files: Laravel publishes new keys. Your old config/database.php may be missing a key the new framework expects. Diff against a fresh install of the target major. Missing keys are silent until a driver boots.

Untested money paths stall every major

The upgrade is done when checkout, refunds, subscriptions, webhooks, and the invoice export still agree with yesterday, not when PHPUnit is green on unit tests of a repository with a mocked query builder.

Characterisation for Laravel, specifically:

  • HTTP tests through the real kernel: middleware, sessions, CSRF, policies. $this->withoutMiddleware() is how you fake an upgrade.
  • Cashier or your payment SDK webhooks with recorded payloads. Signature verification included. Webhooks are how money moves after the user left.
  • Horizon job for the payment capture or the invoice PDF, with a golden file or a table diff.
  • artisan commands finance actually runs.

If those tests do not exist, they are the first milestone. Not the Shift PR. A PHP modernization audit that ignores the money path will recommend a timeline that dies on webhook day.

Factories and RefreshDatabase tests that never hit the schema features you use in production (JSON columns, generated columns, full-text indexes, MySQL-only SQL) will pass and then production will not. Run characterisation against a schema clone, not against SQLite if production is MySQL, unless you have proven they match for your migrations. They often do not.

A sequence that unsticks a Laravel upgrade without a rewrite

  1. Inventory packages, macros, Horizon, Scout, Nova, PHP version. Owners on every row.
  2. Characterise money paths and the hottest jobs.
  3. Delete unused packages and unused macros. Prove unused.
  4. Land the PHP version the next Laravel major needs, as its own production bake if you are far behind.
  5. Replace or wrap the packages that pin the framework. Small PRs.
  6. Mechanical Shift/Rector on a branch that rebases daily. No feature work in that branch.
  7. Deploy app and workers together. Watch Horizon, payment success, queue age.
  8. Only then consider extracting a worker to Go, if memory or fan-out is the remaining pain — as in the Laravel to Go workload split.

Do not freeze product development for a quarter. If the upgrade cannot rebase onto main, it is too big. Cut packages out first.

A rewrite of the Laravel app because the major is blocked is still a rewrite. The blockers travel: payments, jobs, search, the undocumented macros. You will rebuild them in another language without the tests you skipped. Stay on Laravel, make Composer honest, make deploys boring. That is the upgrade.

If you want the stuck graph written down — lockfile, Horizon, money path, and a sequence that is allowed to say “do not extract” — the Migration Readiness Audit is €1,200. PHP modernisation implementation is €402/day when the work is Laravel and PHP. PHP to Go from €402/day is only for a slice the document named, with a rollback that does not include rewriting Eloquent in a hurry. I would rather get you onto a supported Laravel major than translate checkout because Shift felt insufficient.