Symfony (5.4+) — API conventions
Vanilla Symfony + Doctrine ORM + PHP 8.1+. Focused on the mistakes that survive lint because Symfony tolerates both the modern and the legacy style — you have to enforce the modern one.
Rules
- DI is constructor-only. Bind config values in
services.yamlunderbind:; never read$_ENV/getenv()from a service, never injectContainerInterface. - PHP 8 attributes everywhere.
#[Route],#[ORM\Entity],#[AsCommand],#[AsSchedule]. Doctrine annotations (@ORM\Entity) are dead weight — a file mixing the two is a bug. - Thin controllers and thin commands. Controllers and
Command::execute()handle I/O only; business logic lives in an injected service. - Queries live in repositories. DQL,
createQueryBuilder, and custom finders belong inServiceEntityRepositorysubclasses — never in controllers or services. - Migrations are append-only. Once a migration has run in any environment, fix mistakes with a new migration — never edit the old file.
GOOD vs BAD — dependency injection
// BAD — service locator + raw env access
class ReportService {
public function __construct(private ContainerInterface $container) {}
public function run(): void {
$url = $_ENV['REPORT_URL'];
$http = $this->container->get(HttpClientInterface::class);
}
}
// GOOD — bound parameter + typed constructor injection
class ReportService {
public function __construct(
private readonly string $reportUrl, // matches services.yaml bind:
private readonly HttpClientInterface $http,
) {}
}
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
bind:
$reportUrl: '%env(REPORT_URL)%'
GOOD vs BAD — controllers
// BAD — annotation routing, logic in the controller, manual JSON
/** @Route("/api/items", methods={"GET"}) */
public function list(): JsonResponse {
$items = $this->getDoctrine()->getRepository(Item::class)->findAll();
return new JsonResponse(array_map(fn($i) => ['id' => $i->getId()], $items));
}
// GOOD — attribute routing, delegation, framework serializer
#[Route('/api/items', methods: ['GET'])]
public function list(ItemService $items): JsonResponse {
return $this->json($items->findAll());
}
Tooling
- Migrations:
php bin/console make:migration, review the generated SQL (Doctrine can emit unexpectedDROP/ALTER), run against a disposable DB before production. - Static analysis: PHPStan level 6 is the minimum bar for new code. Suppressions with
@phpstan-ignore-*require a comment explaining why. - Automated refactors:
vendor/bin/rector process --dry-runto preview,rector processto apply. - Scheduled work:
#[AsSchedule]+ Symfony Scheduler — don't hand-roll cron bootstrapping.
Anti-patterns
- ❌
$_ENV['KEY']/getenv('KEY')inside a service — usebind:parameters - ❌
ContainerInterfaceinjection in new services or controllers - ❌
@ORM\Entityannotations — use#[ORM\Entity]attributes - ❌ DQL or
createQueryBuildercalls outside a repository class - ❌ Business logic inside
Controller::action()orCommand::execute() - ❌ Editing an already-applied migration in place
- ❌
shell_exec/exec/passthru— useSymfony\Component\Process\Process - ❌
verify_peer => falseon HTTP clients — fix the certificate, don't disable TLS
References
- Symfony docs: https://symfony.com/doc/current/index.html
- Doctrine ORM: https://www.doctrine-project.org/projects/orm.html
- PHPStan levels: https://phpstan.org/user-guide/rule-levels