When a Symfony Upgrade Is Blocked by Packages Nobody Owns
A blocked Symfony upgrade is usually a package-ownership problem, not a lack of knowledge about the next minor. Abandoned bundles pin PHP, and tests are what make replacement safe.
A Symfony upgrade blocked by abandoned packages is a common state, not a rare disaster. The framework itself is upgradeable. The lockfile is not. Somewhere between Symfony 3 and 6, or 4 and 7, a bundle that wired PDF generation, an old admin generator, a payment client, or a “helpful” Doctrine behaviour stopped tagging. Flex recipes went stale. composer update prints a constraint conflict and the team treats that output as destiny.
It is not destiny. It is an ownership map you have not drawn. The next Symfony minor will not appear until someone owns each pin: replace, wrap, vendor, or delete. Jumping to Go because a bundle is stale is how you skip the map and buy a second stack. Selective Symfony to Go migration is a later tool for a named workload. It does not install symfony/flex for you.
This article is the blocked upgrade: Flex versus bundles, unmaintained code, replace versus wrap, PHP version coupling, and the tests that make any of those reversible.
Flex, bundles, and the ownership vacuum
Symfony Flex changed how packages land: recipes, symfony.lock, conventional config paths. Bundles still exist. Older apps are a mix. That mix is where ownership disappears.
A bundle in src/ that you wrote is yours. A bundle from Packagist with a recipe you never read is yours too, in production, because you ship it. The author archiving the GitHub repo does not transfer a ticket to them. It transfers the ticket to you, whether you accept it or not.
Symptoms that the upgrade is blocked on ownership rather than on Symfony:
composer why-notpoints at a package with no tag in three years and an issue tracker full of “will you support Symfony 6?”- Config still lives in
app/configYAML from the 2.x layout, plusconfig/packagesfrom a half-finished Flex move. Two layouts, two places to miss a setting. - A bundle registers compiler passes that reach into Doctrine internals. The pass breaks on a new ORM major. Nobody knows what the pass was for.
- Recipes were applied once by hand and then edited.
symfony/flexwill not re-apply them cleanly. Diffingsymfony.lockagainst reality is archaeology.
Flex is not the villain. Half-adopting Flex is. Finish the recipe layout or stop pretending Flex will save the upgrade. Pick one config tree. Document which packages are “we own the fork” versus “we will replace this before the next major.”
Bundle categories that actually stall majors:
- Admin generators and CRUDs tied to an old Sonata or EasyAdmin major, with custom field types that call removed APIs.
- Payment and fiscal SDKs that trail PHP and Symfony by years because the vendor tests against one ancient runtime.
- Doctrine extensions that hook lifecycle events. These fail loudly on ORM upgrades and silently on behaviour if you skip them.
- Templating bridges for an old Twig extra or a PDF layer that embeds an unmaintained binary.
The framework code you wrote in src/ is rarely the pin. Stop rewriting it as a warm-up. Read the lockfile.
Unmaintained packages that pin PHP and Symfony together
Composer constraints chain. A bundle requires symfony/framework-bundle: ^4.4 and php: ^7.4|^8.0. You cannot take Symfony 6 without taking PHP 8.1+ on a modern plan, and you cannot take PHP 8.3 while that bundle still calls utf8_decode in a way that fatals, or still uses a removed EventDispatcher signature.
Treat PHP and Symfony as two planes, but inventory them together. An estate still on unsupported PHP is often a Symfony 4 estate with the same abandoned row, which is exactly the sort of coupling a PHP modernization audit should put in a blocker table. Upgrading PHP first can be the right slice if the bundle allows it. Upgrading Symfony first on 7.4 can be impossible. The order is a function of the constraint graph, not a preference.
Practical commands, then distrust them:
composer why-not symfony/framework-bundle 6.4composer why-not php 8.3- A scratch image that actually autoloads the app on the target versions. Constraints pass while constructors fail.
Record last Git tag, last commit, open CVE, whether the package is a thin wrapper, and whether its Symfony dependency is symfony/framework-bundle (heavy) or a single component (maybe replaceable with a few classes).
PECL and system extensions sit beside Composer. ext-http, old ext-apcu builds, Oracle clients, ionCube. Symfony Flex will not see them. Your Debian image will. Include them in the same blocker table.
Do not wait for the original author. Dead packages do not resurrect on your release train. Time-box a search for a maintained fork with a real CI matrix. If the fork is one person with no tests, it is not maintained. It is a different name on the same risk.
Replace, wrap, or vendor: choosing per package
Every abandoned dependency gets one of four fates. Mixing fates in one PR without naming them is how the branch dies.
Delete. The feature is unused. Prove it with access logs, a grep, and a week behind a flag. Then delete. This is the highest-leverage move and the least popular, because it feels like not-work.
Replace. There is a maintained equivalent with a supported Symfony constraint. Budget for behaviour differences. A new PDF library will not match pixel output. If a government portal is pixel-picky, replacement is a product project. Say so.
Wrap. You keep the dead code behind an interface you own. Tests speak the interface. The upgrade can replace the inner implementation later. Wrapping is correct when the protocol is stable and the library is a brick. It is incorrect when the brick leaks types through the whole domain.
Vendor / fork. You copy the package into the monorepo or a private VCS, namespace it, and run your PHPUnit on your target PHP. This is an admission of maintenance. Put a calendar reminder to delete the fork. Forks without tests are how Symfony upgrades stay blocked in 2028.
A fifth fake fate is “we’ll rewrite the app in Go and not need the bundle.” If the bundle is PDF invoices, Go still needs a PDF library. If the bundle is a Doctrine behaviour, Go still needs the invariant. Upgrade, refactor, or rewrite is the decision framework. “Abandoned package” is not a rewrite trigger. It is a package-level decision.
For bundles that register a lot of DIC services, replacement cost is the config surface, not the PHP files. Count the service IDs, the event subscribers, the console commands. That count is the estimate. Lines of code in vendor/ are not.
Tests that make an upgrade reversible
A Symfony upgrade without characterisation is a staged incident. The container compiling on Symfony 6 is necessary. It is not sufficient. Compiler passes pass. Invoices do not.
What to have before the major:
- Application smoke on the money path. Kernel browser or API tests that hit checkout, billing, the partner webhook, the admin action that refunds. Real database. Not a unit test of a service with a mocked entity manager.
- Messenger consumers. If you use Messenger, the transport payload and the handler’s side effects. An upgrade that changes serialisation will bite here, not in a controller.
- Golden exports. The XML/CSV a ministry or an ERP consumes.
- Container compile in CI on the current and target Symfony versions. Fail the build when a service is missing. This catches recipe drift.
What not to require: 80% unit coverage of domain services you are not changing. That is a refactor tax on the upgrade. Pay it later, on the module you are actually isolating.
Doctrine schema diffs belong in the pipeline. doctrine:schema:update --dump-sql on CI against a copy of production schema should be empty, or a reviewed list. ORM upgrades change types. You want that as a diff, not as a production surprise.
When you replace a bundle, add the characterisation first, on the old bundle, then switch the implementation. If you switch first, you cannot tell whether the failure is the upgrade or the replacement.
Sequencing PHP, Symfony, and Composer
A working order for a stuck estate:
- Inventory blockers with a table that has owners.
- Delete dead code and dead packages.
- Characterise money paths and Messenger.
- Land a PHP version the remaining graph allows, if you are also on an EOL interpreter.
- Move Flex/config to one layout if you are split-brained.
- Replace or wrap the packages that pin the next Symfony major.
- Take the Symfony LTS you can actually run. Bake in production.
- Only then discuss extracting a workload. Extraction into Go does not retire
knplabs/knp-snappyfor you.
Do not open a Symfony 7 branch that also “cleans up” the bounded contexts. That branch will last a year and merge never. Keep product work on main. The upgrade branch rebases. If it cannot rebase, it is too big.
extra.symfony.require and Flex’s version constraints can hide a pin. Read them. conflict rules in your own composer.json that someone added during an outage in 2021 can hide another.
Symfony LTS is a destination, not a personality. 6.4 and 7.4 beat “latest major on day one” if your bundles lag. Security wants a supported line. They do not need you to eat every major the week it ships.
The blocked upgrade is not automatically a Go argument
Go is relevant when a workload is constrained by process model, fan-out, or long-lived connections. It is not relevant because sonata-project/admin-bundle is annoying. Moving the admin to a new language is a product rewrite of the back office. Moving a worker that happens to live in a Symfony Messenger handler can be rational, after the estate is patchable.
If you extract first and leave Symfony on an unmaintained graph, you have two on-call surfaces and still cannot patch PHP. Patch, then extract if the profiler says the handler is the cost.
A PHP modernization audit is the right artefact when the team is arguing Symfony versus rewrite versus “just composer update on staging.” The document should list packages, owners, and a sequence of weeks. If it lists microservices and no lockfile rows, it is a proposal.
Unblocking the upgrade is housework: delete, wrap, replace, test, bump. It does not make a conference talk. It makes composer update boring, which is the actual goal.
If you want that inventory done against your composer.lock and a written sequence that is allowed to say “stay on Symfony, replace three packages, do not open Go,” the Migration Readiness Audit is €1,200. PHP modernisation work afterwards is €402/day. PHP→Go implementation at €402/day stays off the table until the document names a workload with a rollback. I would rather retire a dead bundle than translate it.