Our config/services.yaml is 34 lines. The file it replaced, app/config/services.yml, was 512 lines and defined 61 services. That is the number I keep repeating to people who ask whether Symfony 4 is worth the move, because it is the only part of this upgrade that felt like a gift rather than a chore.
Symfony 4.0.0 shipped on 30 November, and 3.4 shipped the same day. We are on 4.0.4 as of the end of January. One client app, a small internal tool, was started on the skeleton in December and has never known anything else. The interesting work was the other one: a 2.8 app with a single AppBundle, about 40,000 lines, which I spent two days converting in January and which is now running 4.0 on staging. This is what those two days consisted of.
The requirement that decides it for you
Symfony 4.0 requires PHP 7.1.3 or higher. There is no negotiating with that one. If a client is on 5.6 or 7.0 shared hosting, the conversation about Symfony 4 is really a conversation about the hosting, and you should have that one first. We moved this app to 7.1 in September, which is the only reason the January work was two days instead of two months.
The other date worth having in your head: 3.4 is the long term support release, with bug fixes until November 2020 and security fixes until November 2021, and it still runs on PHP 5.5.9. So the question is not “4.0 or stay behind”. It is “4.0 or 3.4”, and 3.4 is a perfectly respectable answer for another three years.
Moving the files
The directory layout changed more than anything else, and the UPGRADE-4.0.md file opens with the whole mapping as a table. The moves that mattered for us:
| Symfony 3.x | Symfony 4.x |
|---|---|
app/config/ | config/ and config/packages/ |
app/config/parameters.yml | config/services.yaml and .env |
app/Resources/views/ | templates/ |
src/AppBundle/ | src/ |
web/ | public/ |
var/logs/ | var/log/ |
The Flex upgrade guide is honest that there is no automatic tool for this and you do it by hand. Practically, it went: require symfony/flex, remove symfony/symfony and add it to the conflict section so Composer never pulls the monolithic package back in, then require the individual components you actually use. Flex then installs recipes, which is the part that does the work: each package drops its own file into config/packages/ and registers itself in config/bundles.php. Deleting AppKernel.php with its hand-maintained array of bundles was satisfying in a way I did not expect.
Then the namespace. Every file under src/AppBundle/ moves to src/ and its namespace goes from AppBundle to App, and composer.json autoload changes to match. PhpStorm did most of it. What it did not do was the string references: service ids in YAML, AppBundle:Booking:index.html.twig template names in controllers, and the AppBundle:Shipment shorthand in Doctrine repository calls. Grep found 190 of those. Two of them were in a Twig template built by string concatenation, and those I found on staging rather than in the editor.
Bundle inheritance is gone in 4.0, which UPGRADE-4.0.md lists under HttpKernel without ceremony. We were not using it. If you are, that is the item that turns your two days into two weeks.
parameters.yml is gone
The old pattern was parameters.yml.dist in the repository, parameters.yml ignored by git, and a Composer script that prompted you for values on install. Symfony 4 replaces it with environment variables and a .env file for local development, with .env.dist committed as the template.
Two related removals bit us. SYMFONY_ENV and SYMFONY_DEBUG are now APP_ENV and APP_DEBUG, which is a rename you find immediately. The other is quieter: UPGRADE-4.0.md notes that the SYMFONY__ prefixed variables are no longer processed automatically into container parameters, and you use the %env()% syntax in configuration instead. Our deploy script had been setting SYMFONY__DATABASE__PASSWORD since 2015. It kept setting it. Nothing read it. The failure mode was a connection refused on staging with an empty password in the exception, which took me longer to understand than it should have because the variable was obviously present in the environment when I checked.
The good version of that config now reads a real environment variable, and the .env file only exists so that a developer with a fresh clone gets something that boots.
Private by default, and what it costs
This is the change that produces the error messages. The service container documentation puts it in one line: from Symfony 4.0, every service defined is private by default. UPGRADE-4.0.md adds the consequences: requesting a private service from Container::get() is no longer supported, and Container::has() returns false for one.
The message you get is written by someone who has answered this question a lot. It is in Container.php:
The "app.rate_calculator" service or alias has been removed or inlined when the
container was compiled. You should either make it public, or stop using the
container directly and use dependency injection instead.
We hit it in three places, all of them in code that predates constructor injection being easy: a console command pulling services out of $this->getContainer(), an event listener doing the same, and one genuinely awkward case in a legacy bridge where a static factory reached for the container. The first two became constructor arguments in about an hour. The third is still marked public: true and I am not proud of it.
The second autowiring change is the one that needs explicit aliases. In 4.0, autowiring only looks for a service whose id matches the type hint exactly, and autowiring by “some service in the container implements this interface” is gone, along with the old autowiring-types mechanism. If you type hint an interface, there must be an alias from the interface name to a concrete service id. In 3.4 you can turn this behaviour on early with the container.autowiring.strict_mode parameter, which is what I did for a week before the actual move, and the deprecation notices told me exactly which four interfaces needed aliases.
What the file looks like now
Before, in Symfony 2.8, a representative slice:
# app/config/services.yml
services:
app.rate_calculator:
class: AppBundleRateCalculator
arguments: ['@doctrine.orm.entity_manager', '%rate_table_path%']
app.booking_handler:
class: AppBundleBookingHandler
arguments: ['@app.rate_calculator', '@logger']
app.listener.booking:
class: AppBundleEventListenerBookingListener
arguments: ['@app.booking_handler']
tags:
- { name: kernel.event_listener, event: booking.created, method: onBookingCreated }
After, the whole of it:
# config/services.yaml
parameters:
app.rate_table_path: '%kernel.project_dir%/config/rates.csv'
services:
_defaults:
autowire: true
autoconfigure: true
public: false
App:
resource: '../src/*'
exclude: '../src/{Entity,Migrations,Tests,Kernel.php}'
AppController:
resource: '../src/Controller'
tags: ['controller.service_arguments']
AppRateCalculator:
arguments:
$rateTablePath: '%app.rate_table_path%'
AppPaymentGatewayInterface: '@AppPaymentHttpGateway'
The _defaults block and the PSR-4 resource loader come straight from the framework-bundle recipe. autoconfigure is what killed the listener definition: the class implements the subscriber interface, so it gets tagged without me saying so. The two entries left are the two facts the container genuinely cannot work out, a scalar path and a choice of implementation behind an interface.
Controllers are services now, and the constructor works the way it does everywhere else:
<?php
// src/Controller/BookingController.php
namespace AppController;
use AppBookingHandler;
use SymfonyBundleFrameworkBundleControllerAbstractController;
use SymfonyComponentHttpFoundationRequest;
use SymfonyComponentHttpFoundationResponse;
use SymfonyComponentRoutingAnnotationRoute;
class BookingController extends AbstractController
{
private $handler;
public function __construct(Handler $handler)
{
$this->handler = $handler;
}
/**
* @Route("/bookings", name="booking_create", methods={"POST"})
*/
public function create(Request $request): Response
{
$booking = $this->handler->handle($request->request->all());
return $this->json(['reference' => $booking->reference()], 201);
}
}
One warning from our own tests: the functional test base class no longer guesses your kernel. UPGRADE-4.0.md removes the KERNEL_DIR variable and the guessing based on where phpunit.xml sits, so you set KERNEL_CLASS to AppKernel in phpunit.xml.dist and move on. Ten minutes, but ten confusing minutes.
Two more removals cost real time, and both are the kind that only show up when a specific page is opened. The form types registered as services, form.type.date and its 28 siblings, are gone and you pass the fully qualified class name instead. And calling isValid() on a form before it has been submitted now raises an exception rather than quietly returning false, so every if ($form->isValid()) in the codebase becomes if ($form->isSubmitted() && $form->isValid()). We had nine of those. Eight were on GET requests where the old behaviour happened to be harmless, which is why nobody had touched them in three years.
Console commands need one line of attention too. Convention based discovery of everything in a Command/ folder is not supported anymore, so commands are registered as services and tagged console.command, which autoconfigure does for you as long as the class lives under the App resource glob. Our two cron commands moved without any configuration at all. The one that lived outside src/ for historical reasons did not, and vanished from bin/console list until I moved it.
Why the other client app is staying on 3.4
The larger app in our shop is not moving this year, and the reasons are boring and correct. It has two bundles that are genuinely reusable across two client deployments, so the flat src/ layout is work rather than relief. Its hosting is a managed box we do not control and the PHP version there is a ticket, not a command. And 3.4 already has autowiring, autoconfiguration, and the PSR-4 service loader, because those all landed in 3.3. The parts of Symfony 4 that changed how I write code are available one minor version back.
What 3.4 does not give you is the small skeleton, and after two weeks of working in the converted app I think that is worth more than it sounds. Not because a short file is prettier. Because when the config directory has 9 files in it and each one is named after a package, a new developer on the project can find the thing they need without asking me.
Still open on my side: the legacy bridge that forced a public service, and the fact that our Jenkins deploy still writes a parameters.yml that nothing reads. I have not deleted it because I want one more full release cycle of staging before I touch the deploy script.
Sources
- Symfony 4.0.0 released: the 30 November 2017 release date.
- Symfony 4.0 release page: the PHP 7.1.3 minimum requirement and the maintenance window.
- Symfony 3.4 release page: 3.4 as the LTS, PHP 5.5.9 minimum, bug fixes to November 2020 and security fixes to November 2021.
- UPGRADE-4.0.md: the directory mapping table, private services and aliases, the end of autowiring by implemented type, bundle inheritance removal, the
SYMFONY__variables, andKERNEL_CLASS. - Upgrading existing applications to Symfony Flex: the manual conversion steps, the
symfony/symfonyconflict entry, and theAPP_ENVandAPP_DEBUGrename. - Service Container documentation: services private by default, autowire and autoconfigure.
- Container.php in the 4.0 branch: the exact wording of the removed or inlined service exception.
- The framework-bundle recipe services.yaml: the default
_defaultsblock and PSR-4 service loading that Flex installs.