The first thing anyone notices about a Symfony 4 project is what is missing. There is no AppBundle, no app/ directory, and the vendor directory after the first install is about a fifth of what the standard edition produced. The second thing, which takes longer to notice and matters more, is that installing a package now writes configuration files.
The symptom
$ composer create-project symfony/framework-standard-edition old 3.4
$ du -sh old/vendor
41M old/vendor
$ find old/vendor -name '*.php' | wc -l
8214
$ symfony new app
$ du -sh app/vendor
7.4M app/vendor
$ find app/vendor -name '*.php' | wc -l
1102The old project could send email, generate PDFs through a bridge, talk to Doctrine and render Twig before anybody had written a line. Most projects used two of those. The rest was code to be kept up to date, audited for security advisories and deployed, in exchange for nothing.
Why it happens
The standard edition was assembled when Composer was young and adding a dependency mid-project was unpleasant enough that shipping everything up front was the kinder default. That calculation stopped being true years ago, and the distribution took a while to catch up.
The other half is that a bundle was the unit of configuration. A package could not tell an application how to configure it, so every README ended with a block of YAML to paste into config.yml and a line to add to AppKernel. Flex is the mechanism that removes both steps, and everything else about the release follows from it.
The fix
A skeleton, and recipes that write configuration
$ composer require symfony/twig-bundle
Symfony operations: 1 recipe (d7d4c9)
- Configuring symfony/twig-bundle (>=4.0): From github.com/symfony/recipes
$ git status --short
M composer.json
M composer.lock
M config/bundles.php
M symfony.lock
A config/packages/twig.yaml
A templates/base.html.twigSix files, none of which were edited by hand. config/bundles.php is the replacement for AppKernel and is a plain array mapping a bundle class to the environments it is enabled in — which is both more readable and easier to modify programmatically than a method that instantiated objects.
// config/bundles.php
return [
SymfonyBundleFrameworkBundleFrameworkBundle::class => ['all' => true],
SymfonyBundleTwigBundleTwigBundle::class => ['all' => true],
SymfonyBundleWebProfilerBundleWebProfilerBundle::class => [
'dev' => true,
'test' => true,
],
];
A recipe is a directory in a public repository containing the files to copy and a manifest describing where they go. There are two of them — a curated set maintained by the core team, and a contrib set that prompts before running unless you have accepted it globally. Reading one before accepting it takes thirty seconds and is worth doing the first few times, because the mechanism is only trustworthy if you know what it does.
config/packages, and why it beats a bundle
The old arrangement had every package’s configuration in one config.yml, with environment overrides in config_dev.yml and config_prod.yml. One file, edited by everyone, merged badly.
config/
bundles.php
services.yaml
routes.yaml
packages/
framework.yaml
twig.yaml
doctrine.yaml
dev/
web_profiler.yaml
monolog.yaml
prod/
monolog.yaml
doctrine.yamlOne file per package, with per-environment directories that are merged over the base. The practical effect is that a merge conflict in configuration is now rare, because two people changing unrelated packages touch different files. It also means a package’s configuration can be deleted along with the package, which was previously a manual and frequently skipped step.
The bundle that is now a directory
Application code lives in src/ with a App namespace and no bundle at all. This sounds cosmetic and changes how a project is organised, because the bundle was carrying an implicit structure — controllers here, entities there — that nothing now enforces.
# config/services.yaml — the whole of it, on a new project
services:
_defaults:
autowire: true
autoconfigure: true
public: false
App:
resource: '../src/*'
exclude: '../src/{DependencyInjection,Entity,Migrations,Tests}'
AppController:
resource: '../src/Controller'
tags: ['controller.service_arguments']
That is twelve lines replacing what was frequently four hundred. Every class under src/ is a service, wired by type hint, private unless something needs it by name. The exclusions are the classes that are data rather than services — entities, migrations — and forgetting to exclude a directory produces a container trying to autowire something with a scalar constructor.
The absence of an enforced structure is worth deciding about deliberately rather than drifting into. Organising by feature — src/Billing/, src/Ordering/, each with its own controllers and services — is now possible where a bundle made it awkward, and it is a considerably better arrangement for anything that might one day be split apart.
The .env file, and the one production does not read
Symfony 4 puts a .env file in the repository root and reads it through Dotenv, which is convenient and is not how the framework expects production to work.
# .env — COMMITTED. defaults, no secrets.
APP_ENV=dev
DATABASE_URL=mysql://[email protected]:3306/app
# .env.local — gitignored, overrides, never deployed
DATABASE_URL=mysql://dev:[email protected]:3306/app
# production: real environment variables. and to skip the parsing:
$ composer dump-env prod # compiles to .env.local.phpThe convention that .env is committed catches everyone arriving from Laravel, where it is the opposite. The distinction is that Symfony’s file holds defaults and Laravel’s holds configuration — and a real environment variable always wins over anything in a file, so a container platform that injects them needs no dotenv at all.
Verifying it worked
$ bin/console debug:container --parameters | wc -l
88
$ bin/console debug:router
Name Method Path
app_order_index GET /orders
app_order_show GET /orders/{id}
$ bin/console cache:clear --env=prod --no-debug
$ bin/console lint:container
[OK] The container was linted successfully: all services are injectable.
$ composer install --no-dev && du -sh vendor
7.4M vendorlint:container is the check worth wiring into CI. Autowiring resolves at compile time, so a type hint that cannot be satisfied is a build failure rather than a request failure — but only if something compiles the container in the production configuration before deploy. Without that step, the error arrives on the first request after release.
What this costs
A recipe is code from a repository you did not audit, executing during composer require, writing files into your project. The curated repository is maintained by the core team and the contrib one asks first, so this is a manageable risk rather than an alarming one — but it is a genuine change in what installing a package means, and a team with a strict supply-chain posture should decide about it explicitly rather than by default.
The second cost is that upgrading an existing 3.4 application is not a version bump. The directory layout, the configuration format, the kernel and the service definitions all change, and doing it incrementally is possible but involves a period where the project has both structures. On a large application this is a project rather than an afternoon, and 3.4 being an LTS supported until 2021 means there is a legitimate case for waiting until the next rewrite of something adjacent makes it cheap.