Symfony 4.0: Flex and the end of the Standard Edition
Symfony 4.0 released November 30, 2017, same day as 3.4. The shared release date is pretty much the only thing they have in common.
4.0 is a different philosophy. The Symfony Standard Edition, the monolithic starting point that bundled everything and left you to remove what you didn’t need, is gone. In its place: a microframework that grows.
Flex
Symfony Flex is a Composer plugin that changes how you install Symfony packages. Before Flex, adding a bundle meant: install via Composer, register in AppKernel.php, add config to config/, update routing if needed. Four steps, all manual.
With Flex, installing a package runs a “recipe”: a set of automated steps that registers the bundle, generates a config skeleton, and wires routing. Installing Doctrine:
composer require symfony/orm-pack
That command installs the packages, creates config/packages/doctrine.yaml, adds the env variable stubs to .env, and registers everything. One command, zero manual steps.
Recipes are community-contributed and hosted on a central server. Quality varies, but for major packages they’re maintained alongside the packages themselves.
The new project structure
The Standard Edition layout (app/, src/, web/) is replaced by a leaner structure. Config lives in config/ split by environment. The public directory is now public/, not web/. The kernel is smaller. Controllers are plain classes, no extends Controller required.
More importantly, the default services.yaml uses the 3.3 autowiring defaults that make explicit service configuration mostly unnecessary. New projects start minimal and grow by adding what they actually need.
Services private by default
4.0’s biggest BC break for existing apps: all services are private by default. You can’t fetch a service from the container directly anymore, it has to be injected. This is the right call from a DI perspective, but it breaks anything that used $this->get('service_id') in controllers.
The migration path is AbstractController, which provides the same convenience methods through lazy service locators rather than raw container access.
What was removed
4.0 is clean because it removes everything deprecated in 3.4:
- The old form events, the old security interfaces, the old configuration formats
- Support for PHP < 7.1.3
- The ClassLoader component
- ACL support from SecurityBundle
The removals are aggressive. Apps that skipped fixing their 3.4 deprecations will have a rough time. Apps that did the cleanup beforehand have a smooth path.
Symfony 4.0 is the reset the framework needed. The Standard Edition had accumulated years of “this is how it’s done” that Flex sweeps away in one shot.
Environment variables that actually know their type
Before 3.4 and 4.0, environment variables were strings. Always. Trying to inject DATABASE_PORT into an int parameter would silently break or blow up with a type error. The fix was ugly: cast in PHP or avoid typed parameters entirely.
4.0 ships with env var processors that handle the conversion at the container level:
parameters:
app.connection.port: '%env(int:DATABASE_PORT)%'
app.debug_mode: '%env(bool:APP_DEBUG)%'
Beyond casting, processors can decode base64, load from files, parse JSON, or resolve container parameters within a value. The json:file: combination turned into a clean pattern for loading secrets from mounted files in containerized deployments:
parameters:
env(SECRETS_FILE): '/run/secrets/app.json'
app.secrets: '%env(json:file:SECRETS_FILE)%'
You can also write custom processors by implementing EnvVarProcessorInterface and tagging the service. Looks like overkill until the day you need it.
Tagged services without the boilerplate
Before 4.0, collecting all services with a given tag into one service meant writing a compiler pass. Forty lines of PHP to say “give me everything tagged app.handler.”
3.4 introduced the !tagged YAML shorthand, and 4.0 carries it forward:
services:
App\HandlerCollection:
arguments: [!tagged app.handler]
The collection is lazy by default when type-hinted as iterable, so services aren’t instantiated until you actually iterate. This replaced a whole category of compiler passes that existed for the sole purpose of building lists.
PHP as a configuration format
YAML has been the default for so long it feels required. It isn’t. 4.0 ships with PHP-based configuration using a fluent interface:
// config/services.php
return function (ContainerConfigurator $container) {
$services = $container->services()
->defaults()
->autowire()
->autoconfigure();
$services->load('App\\', '../src/')
->exclude('../src/{Entity,Repository}');
};
Same approach works for routes. The practical benefit: IDE autocompletion, type checking, and actual PHP logic in configuration without the % parameter interpolation syntax. YAML isn’t going anywhere, but now you have a choice.
Argon2i, because bcrypt was already aging
Symfony 3.4/4.0 added Argon2i support, winner of the 2015 Password Hashing Competition. Configuration is one line:
security:
encoders:
App\Entity\User:
algorithm: argon2i
Argon2i is built into PHP 7.2+ and available via the sodium extension on earlier versions. Like bcrypt, it’s self-salting, no need to manage salt columns. Unlike bcrypt, it’s designed to resist GPU-based attacks with configurable memory usage. If you’re starting a new project on 4.0, there’s really no reason to reach for bcrypt.
The form layer gets a Bootstrap 4 theme
The existing Bootstrap 3 form theme has been around since Symfony 2.x. Bootstrap 4 ships as a first-class option in 4.0:
twig:
form_themes: ['bootstrap_4_layout.html.twig']
More useful in practice: the tel and color HTML5 input types are now available as TelType and ColorType form types. Before, you had to write custom types or override raw widgets for those.
Local service binding
Global _defaults bindings apply to all services. Sometimes you need a binding scoped to a specific class or namespace, like different logger instances for different subsystems.
4.0 supports per-service bind for exactly that:
services:
App\Service\OrderService:
bind:
Psr\Log\LoggerInterface: '@monolog.logger.orders'
App\Service\PaymentService:
bind:
Psr\Log\LoggerInterface: '@monolog.logger.payments'
Same interface, two different implementations, no factory, no extra configuration. Small feature, but it kills a whole category of awkward workarounds.