Symfony Flex and Recipes: Managing Package Upgrades Without Breaking Your Config
Run composer require symfony/mailer in a fresh Symfony project and three things happen that Composer alone would never do: a new file appears under config/packages/, a line is added to config/bundles.php, and two commented variables land in your .env. Symfony Flex does all of this, and Symfony Flex recipes are the instructions it follows. Most teams rely on them every day without ever reading one. That works fine until composer recipes:update touches a config file someone spent an afternoon tuning, and the resulting diff raises questions nobody on the team can answer.
Understanding the mechanism pays off the moment you upgrade anything. Flex can carry configuration changes across package versions for you, but only if your project is in a state where it can do that job.
What Symfony Flex Recipes Actually Do
A recipe is a small set of instructions that lives outside the package it configures, in one of two public GitHub repositories: symfony/recipes and symfony/recipes-contrib. When you install a package, Flex checks whether a recipe exists for it and, if so, executes the actions in the recipe's manifest. The common ones:
- Copy default configuration files into your project, typically under
config/packages/ - Register the bundle in
config/bundles.php, per environment where needed - Append environment variables with commented defaults to
.env - Add entries to
.gitignoreor scripts to theauto-scriptssection ofcomposer.json
Everything a recipe does is reproducible. The same recipe version applied to the same project produces the same files, and that property is the foundation for everything else in this post. Because Flex knows exactly which recipe version generated your config/packages/mailer.yaml, it can later compute what changed between that version and a newer one, then apply only the difference.
Keeping recipes outside the packages was a deliberate design decision. Default configuration can improve without waiting for a package release, and the Symfony core team reviews every change to the main recipe repository with the same scrutiny as framework code.
symfony.lock Is State, Not Noise
Every executed recipe is recorded in symfony.lock at the project root, including the exact version of the recipe that ran. This file is the memory of the recipe system. Flex reads it to decide whether a recipe still needs to run and to reconstruct the original files when you ask for an update.
Commit it. Treat it exactly like composer.lock. When symfony.lock is missing or out of sync, Flex loses the ability to compute recipe diffs, teammates get prompted to re-run recipes that already ran, and CI behaves differently from local machines. A surprising number of upgrade problems trace back to a symfony.lock that someone deleted "to clean things up" long ago.
To see where you stand, run composer recipes. It lists every installed recipe and marks the ones with updates available. composer recipes symfony/framework-bundle shows the detail for a single package, including a link to the exact recipe version on GitHub.
What composer recipes:update Really Applies
For a long time the only way to refresh a recipe was composer recipes:install <package> --force, which overwrote your files with the new defaults and left you to restore customizations from git history. The composer recipes:update command replaced that workflow with something much closer to a git merge.
Under the hood it does what you would do manually with enough patience. It regenerates the files as the old recipe version would have written them, computes the diff between that state and the new recipe version, and applies the resulting patch to your actual files. Your edits survive whenever they do not overlap with lines the recipe changed. When they do overlap, Flex writes standard conflict markers into the file, the same <<<<<<< blocks you know from git, and leaves the resolution to you.
A few habits make this workflow uneventful. Update one package at a time, on a clean working tree, and commit after each run so every recipe change gets its own reviewable diff. Read that diff before committing, especially the .env part: recipes sometimes introduce new variables with defaults that are wrong for your infrastructure. Afterwards, run your test suite and bin/console lint:container, because a carelessly resolved recipe conflict is a config bug you would otherwise meet in production.
Protecting Customized Config From Recipe Updates
The merge mechanism protects you technically. A few practices reduce conflicts to nearly zero.
Prefer environment variables over edits to recipe-generated files. Most generated config already references env vars, %env(DATABASE_URL)% being the obvious example. If you can express your customization as a value in .env.local or in your deployment environment, the recipe file stays pristine and future updates apply cleanly.
When you do edit generated files, keep the edits small and local. Add your keys, but do not reformat the file, reorder blocks, or strip the commented examples recipes ship with. Every line you leave untouched is a line the next update can patch without your involvement.
Keep genuinely custom configuration in your own files. Anything under config/packages/ is loaded automatically, so project-specific settings can live in a file the recipe system never touches.
Finally, commit symfony.lock together with the recipe changes it describes, in a commit whose message names the package. Whoever runs git blame on config/packages/security.yaml in two years will be glad you did.
Main Recipes, Contrib Recipes, and Packages With Neither
Symfony Flex recipes come from two repositories with different rules:
| Repository | Curated by | Execution |
|---|---|---|
symfony/recipes | Symfony core team, strict review | Runs automatically |
symfony/recipes-contrib | Community, lighter review | Runs after you opt in |
By default Flex asks before executing a contrib recipe and offers to remember your answer. Setting extra.symfony.allow-contrib to true in composer.json accepts them permanently, which is the usual choice once a team trusts the mechanism.
A package with no recipe is not a defect. A recipe exists only when someone wrote one and the repository maintainers accepted it. Plenty of solid Composer packages have none, either because they need no wiring or because nobody submitted one yet. You install those the way you would in any PHP project and write the configuration yourself.
The recipe repositories also define the aliases that make composer require orm install the Doctrine pack and composer require logger install Monolog's bundle. Aliases are shortcuts only; the installed packages are identical. Packs deserve a mention here too: symfony/orm-pack and friends bundle several related packages behind one require, and composer unpack symfony/orm-pack inlines the real dependencies into your composer.json when you need to pin their versions individually.
Larger organizations can go one step further and host their own recipes. Flex reads custom endpoints from extra.symfony.endpoint, which lets a platform team ship default configuration for internal bundles across dozens of services.
Using Flex to Upgrade From Symfony 6.4 to 7.x
A major version upgrade is where the recipe system earns its keep, because configuration conventions change between major versions and recipes carry those changes for you.
First, finish the 6.4 homework. Clear every deprecation your code triggers while still on 6.4, because Symfony 7 removes what 6.4 deprecates. We covered that part in detail in our Symfony 7 upgrade guide.
Second, bring all recipes up to date while still on 6.4. Run composer recipes, then composer recipes:update for each outdated entry, one commit per package. Doing this before the version bump separates recipe drift accumulated over years from changes actually required by Symfony 7, which keeps both reviews small.
Third, raise the constraint. Flex reads extra.symfony.require in composer.json, so change it from 6.4.* to 7.4.*, the current 7.x LTS, and run composer update "symfony/*" --with-all-dependencies. If Composer reports unsolvable conflicts, a third-party bundle does not support Symfony 7 yet. Deal with that list first; the version bump comes after.
Fourth, update the recipes again. The symfony/framework-bundle recipe typically produces the largest diff: options that no longer exist disappear, renamed keys get their new names, and new defaults arrive. Resolve conflicts by reading the new recipe as intent, meaning this is what a fresh 7.x project looks like, and porting your customizations onto it. Then move to the next package.
Fifth, verify. Run composer validate, bin/console lint:container, and the full test suite. Configuration errors surface when the container compiles, so a booting kernel plus a green pipeline catches most mistakes before deployment does.
For codebases further behind than 6.4 the mechanics are the same but the road is longer. We described a staged approach in Symfony migration without a code freeze.
Recipe Hygiene Is Upgrade Insurance
Teams whose Symfony upgrades stay uneventful tend to share unglamorous habits: symfony.lock is committed, and recipe diffs get reviewed like application code instead of being waved through. Teams that struggle usually discover config drift mid-upgrade, in the form of files half-generated by an old recipe, manual edits nobody remembers making, and environment variables that exist in production but in no .env file.
If your project sounds like the second group, untangling it before the next framework release is far cheaper than during it. That assessment and cleanup work is what we do at Wolf-Tech, from code quality audits that map the current state of your configuration to staged legacy code optimization when years of drift need unwinding. Write to hello@wolf-tech.io or have a look at wolf-tech.io and we can tell you quickly whether your upgrade is a weekend or a quarter.

