| name | symfony-code-contribution |
|---|---|
| description | PHP coding standards, naming conventions, deprecations and the backward-compatibility promise for contributing code to Symfony (core, bundles, UX, AI). Use when writing or reviewing PHP for a Symfony pull request. |
Use when writing, modifying, or reviewing PHP code for a contribution to Symfony core or any Symfony-maintained PHP package (bundles, UX PHP side, AI, Mailer, Messenger, Recipes' PHP, etc.).
Do not apply these rules to JavaScript/TypeScript projects such as Webpack Encore or the Stimulus/UX frontend assets — follow that project's own CONTRIBUTING and standards instead. For documentation (.rst) changes, use the symfony-docs-contribution skill.
- Run PHP CS Fixer before every commit. Most style below is auto-enforced by the project's
.php-cs-fixer.dist.php— never hand-format against it:php ./vendor/bin/php-cs-fixer fix -v. - Never break the backward-compatibility promise on a maintenance/minor branch. Public and protected APIs are frozen: deprecate, never remove or change signatures. Breaking changes land only on the next major branch.
- Target the right branch. Bug fixes -> oldest maintained branch that still has the bug (it merges up automatically). New features and deprecations -> current development branch. Never add a feature on a patch branch.
- Ship every deprecation complete, in the same PR: a
trigger_deprecation()call, an@deprecatedPHPDoc tag, aCHANGELOG.mdentry, and theUPGRADE-*.mdentries. - Add the MIT license header at the top of every new PHP file, before the
namespace. - One class per file. Add tests. Do not write PHPDoc that only restates the signature.
The rules below are what PHP CS Fixer enforces and what reviewers check.
Formatting
- One space after each comma.
- One space around binary operators (
==,&&,||, ...) except concatenation (.). - Unary operators (
!,--,++) stick to their variable, no space. - No spaces around
[/]in array access:$a[0], not$a [0]. - Multi-line arrays: trailing comma after every item, including the last.
Control flow & returns
- Always brace control-structure bodies, even single statements.
- Blank line before
return, unless thereturnis alone inside a statement group (e.g. anif). - No
else/elseif/breakafter anif/casebranch that already returns or throws. return null;when returning null; barereturn;for void. Do not add avoidreturn type in tests.
Comparisons
- Identical comparisons (
===/!==) unless you explicitly need type juggling. - Yoda conditions when comparing a variable to an expression:
if (null === $value).
Class layout
- Declare inheritance and all implemented interfaces on the same line as the class name.
- Properties before methods. Methods ordered public -> protected -> private, except constructors,
setUp()andtearDown()which come first. - Use parentheses when instantiating, regardless of argument count:
new Foo(). - All method/function arguments on the same line as the name — except constructor property promotion, where each parameter goes on its own line with a trailing comma.
Types
- Use
bool,int,float(neverboolean/integer/double/real). - In PHPDoc
@param/@returntype lists, putnulllast:string|null.
PHPDoc
- Add a block only when it adds information the name/native types/context don't already give.
- No one-line PHPDoc blocks, even for a single tag.
- Omit
@returnwhen the method returns nothing. - Group annotations: same type together, a single blank line between different types.
Exception & error messages
- Build with
sprintf(), not raw concatenation. - Capital first letter, trailing period.
get_debug_type($x)for class names in messages, not$x::class.- Double quotes around technical elements —
The "foo" option ...— not backticks.
| Element | Case |
|---|---|
| variables, functions, methods | camelCase |
| classes, interfaces, traits, enums | UpperCamelCase |
| enum cases | UpperCamelCase |
| constants | SCREAMING_SNAKE_CASE |
| config params, route names, Twig vars | snake_case |
| PHP files | UpperCamelCase.php |
| templates & web assets | snake_case |
- Prefix abstract classes with
Abstract(except PHPUnit*TestCase). Suffix*Interface,*Trait,*Exception. - Service-config attributes prefixed
As(#[AsCommand],#[AsEventListener]); controller-argument attributes prefixedMap(#[MapEntity],#[MapCurrentUser]). - Primary service id = the fully-qualified class name; add public aliases; parameter names lowercase.
- Command and option names use the English imperative:
run,list(notruns,lists).
Method naming for collections. When a class has one clear "main" relation, use: get set has all replace remove clear isEmpty add register count keys. For secondary relations, suffix with the thing: getXxx setXxx hasXxx getXxxs removeXxx addXxx countXxx ... Note setXxx() may add or replace; replaceXxx() must not add and throws on an unknown key.
- PHPDoc tag:
@deprecated since Symfony X.Y, use Bar instead.— state the version and the replacement (FQCN if in another namespace). - Runtime trigger (needs
symfony/deprecation-contracts):For a deprecated class, place the call after thetrigger_deprecation('symfony/package', '7.3', 'The "%s" class is deprecated, use "%s" instead.', Foo::class, Bar::class);
useblock, before the class definition. - Document in the same PR:
CHANGELOG.md(component root),UPGRADE-X.Y.md(this minor),UPGRADE-X.0.md(next major, removal/replacement consequences). - Only deprecate on the next minor; never introduce something already deprecated; removals happen only on the next major.
Minor releases keep BC; only majors may break it. When unsure, keep the old API and deprecate.
Never, on public/protected API: remove or rename a method/property/constant; add or remove an argument; change a signature, type hint or return type; reduce visibility; flip static/non-static; add a mandatory constructor argument.
Allowed: add new methods/properties/constants; add a constructor argument with a default, at the end; add a default to an existing argument; rename an argument if behavior is unchanged; anything on private members; anything on @internal, @experimental, or *\Tests\ code.
Interfaces are strictest: you may add a parent interface that introduces no method, and add constants — nothing else. Evolve an interface method by shipping a new interface, or via the recipe below.
Adding a new argument to a public method (2-step, BC-safe):
// Minor N — argument commented, read defensively, deprecate when absent
public function say(string $text /* , bool $trim = true */): void
{
$trim = 2 <= \func_num_args() ? func_get_arg(1) : false;
if (\func_num_args() < 2) {
trigger_deprecation('symfony/pkg', '7.3', 'Not passing the "bool $trim" argument is deprecated; its default will be true in 8.0.');
}
// ...
}
// Major N+1 — uncomment the real parameter, drop the func_get_arg/deprecation codeMark future-final classes/methods with the @final PHPDoc tag one release before enforcing final.
Patch releases of a maintained minor ship monthly and accept only tightly-scoped changes. When targeting a maintained branch, fix the bug and nothing else.
Accepted in a patch: bug fixes that keep existing tests green and add a covering test; support for newer PHP/OS versions (never new PHP features); translation updates (always to the oldest maintained branch); external-data refreshes (e.g. ICU); raising a dependency's minimum version; tests that raise coverage.
Not accepted — do it on the next minor/major: new features; new classes or public/protected methods; new config options; new deprecations (none after a version is stable); performance work (unless local to one class and backed by real-world numbers); coding-standard/refactor churn; adding/updating annotations (fixing wrong ones may pass); changing exception messages (automated tools rely on them); new Composer deps or support for their new majors; security hardening; BC breaks (except when unavoidable to fix a security issue); web-design changes to built-in pages (profiler, toolbar, error pages).
When docs or PHPDoc disagree with the code, the code is authoritative.
- Fork the canonical repo (
symfony/symfonyor the target package) to your account; clone;git remote add upstream <canonical>. - Branch from the correct base (Core Rule 3):
git checkout -b my_change upstream/<branch>. - Write the change and tests; keep one topic per PR.
- If deprecating: add
trigger_deprecation+@deprecated+CHANGELOG.md+UPGRADE-*.md. php ./vendor/bin/php-cs-fixer fix -vand run the component tests (./phpunit src/Symfony/Component/Xxx).- Commit; push to your fork; open the PR against the base branch and fill the PR table (branch, bug fix?, new feature?, deprecations?, BC breaks?, tickets, license).
- Address CI and review; push follow-ups to the same branch.
Canonical sources live in symfony/symfony-docs and stay in sync — read the .rst when a case is unclear:
contributing/code/standards.rstcontributing/code/conventions.rstcontributing/code/bc.rstcontributing/code/maintenance.rst
| Bad | Good |
|---|---|
if ($value == null) |
if (null === $value) |
throw new \RuntimeException('Invalid '.$type.' given') |
throw new \RuntimeException(sprintf('Invalid "%s" given.', $type)) |
throw new \LogicException('The `debug` option ...') |
throw new \LogicException('The "debug" option ...') |
$class = $object::class; (in a message) |
$class = get_debug_type($object); |
| removing a public method in 7.x | @deprecated it in 7.x, remove in 8.0 |
adding bool $strict as a real param in a minor |
comment it, read via func_get_arg(), deprecate when absent |
public function all() { ... } renamed to getAll() |
keep all(); it is the standard main-relation name |