We merged the Symfony 8 branch on a Saturday morning in late January. Of the 186 files in that pull request, 119 were configuration. Not controllers, not entities, not Twig. Config files. I had read the upgrade guide twice before starting and still got the proportions wrong in my head.
We had been sitting on Symfony 7.4 since December, which is exactly the position the release process wants you in: 7.4 and 8.0 shipped simultaneously at the end of November 2025 with the same features, and 8.0 is 7.4 minus everything deprecated. So the job was never “learn the new version”. The job was “stop using the old one”, and most of what we were still using lived under config/.
PHP 8.4 is the gate, not a suggestion
The 8.0 release page is blunt about it: requires PHP 8.4.0 or higher. I have seen a couple of write-ups claiming 8.2, presumably by copying the 7.x requirement, and if you plan a quarter around that number you will lose the quarter. We had been on PHP 8.3 across the board since 2025, so 8.4 was one minor version away, and it still ate more calendar time than the framework upgrade did: three weeks of staged rollout, one pinned extension we had to wait on, and a fortnight of running 8.4 in production on 7.4 before anyone touched the composer constraint.
Doing it in that order matters, because Symfony 8 assumes PHP 8.4 in ways that show up as deleted code rather than new features. PHP 8.4 shipped property hooks, asymmetric visibility, the spec-compliant DomHTMLDocument API and native lazy objects, and 8.0 leans on the last two hard. VarExporter’s LazyGhostTrait and LazyProxyTrait are gone, replaced by native lazy objects. AbstractBrowser::useHtml5Parser() is gone because the native HTML5 parser is now unconditional, and HtmlSanitizer’s MastermindsParser is gone in favour of NativeParser. Three dependencies we had carried since Symfony 6 stopped being needed.
The config rewrite is the whole job
Here is the timeline nobody put in front of me in one piece, so I will. Config builder classes, the fluent PHP interface with SymfonyConfigSecurityConfig and friends, arrived in Symfony 5.3 back in May 2021. Symfony 7.4 deprecated them, deprecated the generation of fluent methods, and deprecated the XML configuration format in one go. Symfony 8.0 removed all of it. The DependencyInjection changelog says it in two lines under 8.0: remove support for the XML configuration format, remove the fluent PHP format for semantic configuration.
The replacement is a PHP array format with generated array shapes. This was our config/packages/security.php before:
// config/packages/security.php
use SymfonyConfigSecurityConfig;
return static function (SecurityConfig $security) {
$security->firewall('dev')
->pattern('^/(_profiler|_wdt|assets)/')
->security(false);
$security->firewall('main')
->pattern('^/')
->lazy(true)
->provider('app_user_provider');
$security->accessControl(['path' => '^/ops', 'roles' => 'ROLE_DISPATCH']);
};
And after:
// config/packages/security.php
namespace SymfonyComponentDependencyInjectionLoaderConfigurator;
return App::config([
'security' => [
'firewalls' => [
'dev' => [
'pattern' => '^/(_profiler|_wdt|assets)/',
'security' => false,
],
'main' => [
'pattern' => '^/',
'lazy' => true,
'provider' => 'app_user_provider',
],
],
'access_control' => [
['path' => '^/ops', 'roles' => 'ROLE_DISPATCH'],
],
],
]);
Two things about that. The namespace line is not decoration, it is how App::config() and the helper functions (param(), env(), service()) resolve. And the array is the shape of the YAML, key for key, which is the real gift: if you can read security.yaml you can read this, and you never again have to guess whether the fluent method for access_control is singular or plural.
Our config/ directory held 63 files. Thirty-one were fluent PHP, written between 2021 and 2023 when we thought the fluent builders were the future. Eight were XML left over from the Symfony 2.8 era, including messenger transports nobody had touched since 2022. The rest were YAML and needed nothing. Two of us spent 26 hours over four days, and only about six of those were mechanical: strip the closure, unindent, turn ->method('x') into 'method' => 'x'. The other twenty went to the framework and doctrine files, where the fluent version had let us be sloppy about which nested node we were in and the array version would not.
Routing is a smaller version of the same tax. XML routing is gone, and FrameworkBundle dropped its own errors.xml and webhook.xml for the PHP equivalents. Our config/routes/ became Routes::config([...]) in about an hour.
reference.php, and my one real complaint
The array shapes are not magic. Symfony generates config/reference.php from the bundles you have installed, declaring a final class App and a final class Routes that carry psalm type definitions for every config key your app can use. That is where the autocompletion comes from. The docs say to commit it and to optionally add "classmap": ["config/"] to the autoload section of composer.json.
Committing it is correct and also annoying. The file is 4,800 lines in our app and it regenerates whenever a bundle version changes, so it turns up in pull requests that have nothing to do with configuration. We put it on a review-skip list, which is the kind of workaround that means the tooling is not finished.
Worse, and still unresolved for us: the generator writes your config values into docblock comments and does not escape a comment-closing sequence. One of our service exclude patterns contains one. The generated reference.php became a PHP parse error, in a file the container warms up, so the app died on cache:clear complaining about an unexpected token. It took me forty minutes to believe the problem was in a generated file and not my own diff. We renamed the pattern. I do not love that fix.
Commands: the part I enjoyed, with the versions straightened out
Everyone talks about invokable commands as an 8.0 thing. They are not. Per the Console changelog, invokable commands with #[Argument] and #[Option] landed in 7.3. Symfony 7.4 then added the good parts: BackedEnum support for arguments and options, usages in #[AsCommand], #[MapInput] for binding a whole DTO, #[Interact] and #[Ask] for interactive commands, Cursor support inside invokable commands, and passing invokable commands to CommandTester. What 8.0 actually did was take the old way away: Command::getDefaultName() and getDefaultDescription() are removed, Application::add() is removed in favour of addCommand(), #[AsCommand] is final, and closures passed to setCode() must be typed and must return an int.
So the rewrite is optional on 7.4 and forced on 8.0 only if you were still using the removed methods. We had 61 console commands and rewrote 44 of them. This is one, before:
#[AsCommand(name: 'app:manifest:export')]
class ExportManifestCommand extends Command
{
protected function configure(): void
{
$this->addArgument('courier', InputArgument::REQUIRED)
->addOption('format', null, InputOption::VALUE_REQUIRED, 'csv');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$courier = $input->getArgument('courier');
$format = $input->getOption('format');
// ...
return Command::SUCCESS;
}
}
After, with a backed enum doing the option validation that used to be an in_array() check and an InvalidArgumentException:
#[AsCommand(
name: 'app:manifest:export',
description: 'Exports a courier manifest',
usages: ['jrs-express', 'ninja-van --format=json'],
)]
class ExportManifestCommand
{
public function __invoke(
SymfonyStyle $io,
#[Argument(description: 'Courier slug')]
string $courier,
#[Option]
ManifestFormat $format = ManifestFormat::Csv,
): int {
$io->writeln(sprintf('%s as %s', $courier, $format->value));
// ...
return Command::SUCCESS;
}
}
Fifteen lines shorter on average across the 44, and the help output improved for free because usages exists now. #[Interact] and #[Ask] replaced a hand-rolled interact() override in our three destructive commands.
What actually broke
The one that cost us a Wednesday afternoon on staging was Doctrine. The DoctrineBridge deprecated auto-mapping of entities to controller arguments in 7.1 and removed it in 8.0. Auto-mapping is how our oldest tracking controllers had always worked: name the route placeholder after a property, type-hint the entity, and the resolver figured it out. On 8.0 you get this:
Controller "AppControllerOpsShipmentController::show" requires the "$shipment"
argument that could not be resolved. Cannot find mapping for "AppEntityShipment":
declare one using either the #[MapEntity] attribute or mapped route parameters.
Which is, to be fair, an error message that tells you the fix. Ours was 31 controller actions across four files:
public function show(
#[MapEntity(mapping: ['tracking_number' => 'trackingNumber'])]
Shipment $shipment,
): Response
Two smaller ones. Request::get() is removed, so read from ->attributes, ->query or ->request explicitly; we had 87 call sites and caught them all from 7.4 deprecation notices, which is the argument for stopping at 7.4 rather than jumping. And UrlType‘s default_protocol now defaults to null instead of 'http', so a seller typing example.com into their storefront field is stored verbatim rather than silently prefixed. No error, no deprecation notice. It reached our database before it reached our attention.
For scale: UPGRADE-8.0.md runs to 155 bullet points across 41 component sections, and 94 of those begin with the word “Remove”. Nine of them touched us. If you have kept up with deprecations, that ratio is about what to expect.
Three components stopped being experimental
Quietly, in the same cycle, JsonPath, JsonStreamer and ObjectMapper all lost their @experimental marker. All three changelogs record it under 7.4, which means the code in 8.0 is covered by the normal backward compatibility promise. JsonPath queries JSON with the RFC 9535 syntax. ObjectMapper maps one object onto another with attributes, which is the DTO-to-entity boilerplate we have been writing by hand since 2019. JsonStreamer encodes and decodes JSON incrementally, for classes with no constructor and only public properties.
That last one replaced a memory problem for us. Our bulk tracking export serialises up to 40,000 shipment rows for a courier partner, and the Serializer version of it needed a limit and a cursor loop to stay under the memory ceiling:
use SymfonyComponentJsonStreamerStreamWriterInterface;
use SymfonyComponentTypeInfoType;
public function export(
StreamWriterInterface $jsonStreamWriter,
ShipmentRepository $shipments,
): StreamedResponse {
$type = Type::list(Type::object(ShipmentExport::class));
return new StreamedResponse($jsonStreamWriter->write($shipments->toExport(), $type));
}
Also from 7.4 and therefore in 8.0: FormFlow, for multistep forms. It is a Form component addition, not an 8.0 one, and I mention it because two people on the team went looking in the wrong changelog for it. We have not adopted it. Our booking wizard is four steps of hand-rolled session state that I would like to delete, though starting that in the same sprint as a major upgrade is how you end up with two half-migrations.
Which one you should actually be on
Read the support windows before you decide. Symfony 8.0 is a standard release: bug fixes end in July 2026, five months from today. 7.4 is the LTS, with bug fixes until November 2028 and security fixes until November 2029. 8.1 is due in May 2026, so taking 8.0 means committing to a version bump roughly twice a year, forever.
Which gives a clean rule. If getting your fleet onto PHP 8.4 will take you more than a quarter, go to 7.4 and stop there. You get every feature above, the array config format and the command attributes and the three newly stable components, because the two releases have identical features. You keep PHP 8.2 or 8.3, and you get three more years of patches. All you give up is the smaller API surface.
Take 8.0 if you are already on 8.4, already deprecation-clean, and you want the removals now rather than in 2028. That was us, and with this many people committing to one codebase I would rather the framework refuse the old patterns than trust a linter to catch them. Ask me again in July.
Still open on our side: the reference.php comment escaping, and the fact that Flex recipes still assume YAML, so every new bundle writes a .yaml file into a directory where the other 63 files are now PHP. We have a script that converts it. We should not need a script.
Sources
- Symfony 8.0 release page: PHP 8.4.0 minimum, November 2025, end of support July 2026, not an LTS.
- Symfony 7.4 release page: LTS status, bug fixes to November 2028 and security fixes to November 2029.
- UPGRADE-8.0.md: the removal list, including the fluent PHP format, XML semantic configuration,
Request::get()and VarExporter’s lazy traits. Also the 155 bullets and 41 sections count. - New in Symfony 7.4: Better PHP Configuration: config builders arrived in 5.3, why they and XML were deprecated in 7.4, the
App::config()format, and thereference.phpguidance. - ObjectMapper changelog: experimental in 7.3, not experimental as of 7.4. The JsonPath and JsonStreamer changelogs carry the same entry.
- Console changelog: invokable commands in 7.3;
BackedEnum,usages,#[MapInput],#[Interact],#[Ask]andCursorin 7.4; the removals in 8.0. - DoctrineBridge changelog: entity auto-mapping deprecated in 7.1, removed in 8.0.
- Form changelog:
FormFlowis a 7.4 addition; theUrlTypedefault_protocolchange is 8.0. - PHP 8.4 release announcement: property hooks, asymmetric visibility, the new DOM API with HTML5 parsing, native lazy objects.
- Streaming JSON documentation: the JsonStreamer constraints (no constructor, public properties only) and the
write()call.