Best Practices
This page is a capstone checklist for the whole Symfony track — the habits worth carrying into every real project you build with it. Each item links back to the page that covers it in full, so treat this as a quick-reference summary rather than a first read.
1) Prefer PHP 8 Attributes Over YAML/XML
Symfony still supports YAML and XML configuration, but attributes keep the rule and the code it affects in the same file — a route stays next to the action it maps to, a validation constraint stays next to the property it protects. See Routing and Validation.
#[Route('/articles/{id}', name: 'article_show')]
public function show(int $id) { /* ... */ }
- Default to attributes for routing and validation on new code.
- Reach for YAML only when a value needs to change per-environment without touching PHP.
2) Keep Real Credentials Out of Version Control
Database usernames and passwords belong in .env.local, which is git-ignored by
default, never in the committed .env file. Anyone who clones the repository gets
working placeholder values from .env and supplies their own real credentials
locally. See Database Configuration.
# .env.local — real credentials, never committed
DATABASE_URL="mysql://real_user:real_password@127.0.0.1:3306/app_db"
- Confirm
.env.localis listed in.gitignoreon every project. - Never paste a real password directly into
.env, even temporarily.
3) Keep Controllers Thin
A controller action's job is to receive the request, ask a service to do the real work, and return a response — not to contain business logic itself. Push data rules and processing into focused service classes, where they're easier to test and reuse from more than one action. See Services & Dependency Injection.
// Thin: the controller just delegates
public function publish(Article $article)
{
$this->notifier->notifyPublished($article);
}
- If an action is doing calculations or complex conditionals, move that logic into a service.
- A controller action that's mostly delegation is usually a sign you're on the right track.
4) Let Form + Validation Handle Input
Hand-rolled $_POST checks mean hand-rolled CSRF handling and hand-rolled error
messages — both easy to get wrong. The Form component paired with Assert
constraints gives you both for free, consistently, on every form. See
Forms and Validation.
if ($form->isSubmitted() && $form->isValid()) {
// $article already satisfies its Assert constraints
}
- Default to the Form component for anything that writes to an Entity.
- Never trust
$_POSTor$request->requestdata as already valid.
5) Use IsGranted Consistently, Not Ad-Hoc Role Checks
Scattering manual if ($user->getRoles()) checks through controllers is easy to
get inconsistent and easy to forget on a new action. Standardizing on
#[IsGranted] or denyAccessUnlessGranted() makes every protected action
enforce its role the same way, in the same place. See Security.
#[IsGranted('ROLE_ADMIN')]
public function delete(Article $article) { /* ... */ }
- Guard every sensitive action with
#[IsGranted]ordenyAccessUnlessGranted(). - Avoid writing your own role-comparison logic when the Security component already does it.
6) Favor Constructor Injection and Small Services
A handful of focused service classes, each type-hinted into the constructors that need them, is
easier to test and reason about than one large "God" class or a pile of static helper methods.
Let the container do the wiring instead of reaching for new or static calls.
public function __construct(private ArticleNotifier $notifier)
{
}
- Split a growing service into smaller ones once it starts doing more than one job.
- Type-hint dependencies in the constructor; avoid static factories and global state.
7) Use bin/console for Anything Outside an HTTP Request
Cron jobs, batch imports, and scheduled cleanup tasks don't have a browser waiting for a
response, so they don't belong in a controller. A custom bin/console command is the
natural home for that kind of work, and it can inject the exact same services a controller
would. See Console Commands.
bin/console app:send-newsletter
- Write a custom command for any job meant to run on a schedule rather than per-request.
- Reuse existing services inside commands instead of duplicating their logic.
| Practice | Covered in |
|---|---|
| Prefer attributes over YAML/XML | Routing & Validation |
Keep credentials in .env.local | Database Configuration |
| Keep controllers thin | Services & Dependency Injection |
| Let Form + Validation handle input | Forms |
| Use IsGranted consistently | Security |
| Favor constructor injection | This page |
| Use console commands for non-HTTP work | Console Commands |