When I joined my current team in July 2023, our Symfony platform was running on PHP 7.4. PHP 7.4 had stopped receiving security fixes eight months earlier.
That meant no security patches, a growing list of packages that had dropped support, and none of the language features that make modern PHP pleasant to write.
In June 2025, we finished moving it to PHP 8.4. The commit that did it touched eight files.
That’s the part of this post I care most about. But the eight-file commit was the end of a two-year process, and the two years were where the work was.
Why it mattered
The platform handles patient data. Running it on a version of PHP that no longer gets security fixes isn’t a comfortable place to be, and “we’re planning to upgrade” isn’t a great answer when someone asks about it.
It was also starting to cost us in smaller ways. We were on Symfony 4.4. Our OAuth2 server bundle had been abandoned. Our Faker package had been abandoned. Our JWT library was two major versions behind. Every time we wanted something new, the answer was an old version of it, or nothing.
We never ran PHP 8.2
The ticket for the first stage was called “upgrade to PHP 8.2”. Production never ran PHP 8.2.
What that first stage actually did was everything except change PHP:
- Symfony 4.4 to 5.4, including moving security over to the new authenticator system
- Doctrine ORM 2.7 to 2.15
- Removing the abandoned OAuth2 bundle entirely
- Replacing PHP_CodeSniffer with PHP-CS-Fixer
- Adding Rector, and PHPStan’s deprecation rules
It changed 2,482 files. And it shipped in May 2024 to containers that were still running PHP 7.4, with Composer still pinned to 7.4.33.
That sounds backwards, but it’s the reason the rest worked. Code that runs on both 7.4 and 8.x can go out on its own, through the normal release process, with the normal tests. If something broke, it broke because of Symfony, not because of Symfony and PHP at the same time.
Jumping straight to 8.3
Two months later, in July 2024, we changed one line in the Dockerfile:
1FROM php:8.3-fpm-alpine AS base
Plus removing the Composer platform pin, and a few dependencies that had been waiting on it. The JWT library went from 3 to 4. The abandoned fzaninotto/faker became fakerphp/faker.
The rest of that commit was Rector. We ran its PHP sets across src, config and tests, and let it do the dull parts: constructor property promotion, null coalescing assignment, #[Override] on every method that overrides a parent.
1// Before/** * @var EntityManagerInterface */private $entityManager;public function __construct(EntityManagerInterface $entityManager, LoggerInterface $logger){ parent::__construct($logger); $this->entityManager = $entityManager;}// Afterpublic function __construct(private EntityManagerInterface $entityManager, LoggerInterface $logger){ parent::__construct($logger);}
That’s 1,814 files of diff, and almost none of it needed a human to think about it. Which is exactly what you want from 1,814 files.
We went from 7.4 to 8.3 in a single production deploy. That’s only safe because the hard part, the framework and the libraries, had already shipped two months earlier and had two months of real traffic on it.
The boring step
We could then upgrade to Symfony 6.4 with no bother at all.
By the time 8.4 came along, the final step really was small. The commit touched eight files:
- the
Dockerfile composer.json.php-version- the README
- the four GitHub Actions workflows that set the PHP version for linting, static analysis and tests
No application code at all.
That’s the part I’d recommend most: make the last step boring. If the final upgrade is a version bump in a handful of files, all the risky work has already happened, in smaller pieces, with tests running on each one.
What broke
Not much, on the day. Which is not the same as nothing.
OPcache. Five weeks after the 8.4 upgrade, containers started logging “Cannot load Zend OPcache – it was already loaded”. The Dockerfile was still installing Alpine’s php83-opcache package, which belongs to Alpine’s own PHP rather than the one in the official image, and our php.ini was loading the extension by hand on top of that. The fix was to delete both and let docker-php-ext-install opcache do it properly.
PHP 8.4.8, for a few months. Our base image is php:8.4-fpm-alpine, a floating tag. I assumed that meant every deploy picked up the latest 8.4 patch release.
It didn’t. The deploy build was reusing a cached base layer, so production sat on 8.4.8, the version we’d upgraded to months earlier. We only found out because two PHP CVEs came out and I went to check we’d picked up the patch. We hadn’t. One line, pull: true, in the deploy workflow fixed it.
And then the patch broke something. One of our endpoints started returning 500.
Our code built new DateTime(' '). On 8.4.8, that quietly meant “now”. On 8.4.27, it throws a DateMalformedStringException.
That’s a patch release changing behaviour you were relying on without knowing it. It’s also a good argument for not letting your patch version drift for sixteen months, because the longer you wait, the more of those you swallow in one go.
The fix was a one-line fallback to 'now', and a test for the case that broke.
The results
What I can say:
- PHP went from 20 months past end of life to the current release
- Symfony went from 4.4 to 5.4, to 6.4 along the way, and Doctrine from 2.7 to 2.15
- Two abandoned dependencies were removed or replaced, and the JWT library moved up a major version
- The final upgrade commit changed 8 files and no application code
What I’d do again
Upgrade the framework before the language. Get the code running on both versions, ship that, and let it bake. Then the PHP change is just a Dockerfile change.
Let Rector do the typing. Nobody should hand-promote constructor properties across 1,800 files.
Skip versions you don’t need. We never ran 8.0, 8.1 or 8.2 in production. Each intermediate version is another deploy, another round of testing, another chance for something to go wrong, and none of them were the destination.
Check what you’re actually running. php -v in a production container, not the Dockerfile. opcache_get_status(), not php.ini. Twice now, what we thought was running and what was actually running were different.
Upgrade the base image on purpose. We’ve since stopped Dependabot opening PRs to bump the PHP image. A PHP upgrade also needs the CI version, the Composer lock and any deprecations to move with it, so it should be a piece of work, not a lone dependency bump. But that means you have to remember to do it, and to make sure your build actually pulls the image you think it does.
The upgrade to 8.4 took eight files. Getting to the point where it could took two years.