symfony-conventions
Consolidated Symfony conventions (Symfony 6.4 / 7.x): attribute routing, controllers as services, autowiring/DI, Form types, Validation constraints, Voters for authorization, the Serializer/DTO contra
Files1
View on GitHubSKILL.mdplugins/symfony-plugin/skills/symfony-conventions/SKILL.md
GitHubSymfony Conventions (6.4 / 7.x)
This skill encodes the conventions used across modern Symfony projects. Apply alongside php-foundation:php-conventions (language idioms) when implementing features.
1. Attribute routing
Routes are declared with #[Route] attributes on controller actions — not YAML/XML for new code.
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/subscriptions', name: 'subscription_')]
final class SubscriptionController extends AbstractController
{
#[Route('/{id}', name: 'show', methods: ['GET'], requirements: ['id' => '\d+'])]
public function show(int $id): Response
{
// ...
}
}
A class-level #[Route] sets a path prefix and name prefix. Always set methods: explicitly. Use requirements: to constrain route params.
2. Controllers as services + thin actions
- Extend
AbstractController(givesrender(),json(),denyAccessUnlessGranted(),getUser()). - A controller with
#[Route]is automatically registered as a service with autowired action arguments. - Keep actions thin: authorize → map/validate input → call a service → return a response. Business logic lives in services.
public function __construct(
private readonly SubscriptionManager $subscriptions,
) {
}
#[Route('', name: 'store', methods: ['POST'])]
public function store(#[MapRequestPayload] CreateSubscriptionRequest $request): Response
{
$this->denyAccessUnlessGranted('SUBSCRIPTION_CREATE');
$subscription = $this->subscriptions->create($this->getUser(), $request);
return $this->redirectToRoute('subscription_show', ['id' => $subscription->getId()]);
}
#[MapRequestPayload] deserializes + validates the request body into a DTO automatically.
3. Dependency injection / autowiring
- Constructor injection only. Never property injection or
@requiredsetters for new code. - Services are autowired + autoconfigured by default (
config/services.yaml_defaults: autowire: true, autoconfigure: true). - Inject scalars/parameters with
#[Autowire]:
public function __construct(
private readonly HttpClientInterface $http,
#[Autowire('%env(STRIPE_SECRET)%')] private readonly string $stripeSecret,
) {
}
- Bind/alias an interface to an implementation in
services.yamlonly when autowiring can't resolve it. Avoid manual service definitions otherwise.
4. Form types
Use Form types for HTML form workflows; constructor-inject collaborators.
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class SubscriptionType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('plan', ChoiceType::class, ['choices' => Plan::cases()])
->add('startsAt', DateType::class, ['required' => false]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(['data_class' => CreateSubscriptionRequest::class]);
}
}
For JSON APIs prefer #[MapRequestPayload] DTOs over Form types.
5. Validation — constraints on DTOs/entities
Always declare validation with Constraint attributes; never validate inline in the controller.
namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert;
final class CreateSubscriptionRequest
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Choice(['basic', 'pro', 'enterprise'])]
public readonly string $plan,
#[Assert\GreaterThanOrEqual('today')]
public readonly ?\DateTimeImmutable $startsAt = null,
) {
}
}
#[MapRequestPayload] runs validation automatically and returns 422 on failure. Otherwise call the ValidatorInterface in the service and use #[Valid] for nested objects.
6. Authorization — Voters
Centralize authorization in Voters; never inline role checks.
namespace App\Security\Voter;
use App\Entity\Subscription;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
final class SubscriptionVoter extends Voter
{
public const VIEW = 'SUBSCRIPTION_VIEW';
public const EDIT = 'SUBSCRIPTION_EDIT';
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::VIEW, self::EDIT], true)
&& $subject instanceof Subscription;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
return $subject->getOwner() === $user || $user->isAdmin();
}
}
Use via #[IsGranted('SUBSCRIPTION_EDIT', subject: 'subscription')] on the action, or $this->denyAccessUnlessGranted(...). Coarse, path-based rules go in security.yaml access_control.
Never inline if (in_array('ROLE_ADMIN', $user->getRoles())) — that bypasses the authorization layer and is untestable.
7. Serializer / DTO contract
Never serialize a raw entity with all fields. Use serialization groups or dedicated DTOs.
use Symfony\Component\Serializer\Attribute\Groups;
class Subscription
{
#[Groups(['subscription:read'])]
private int $id;
#[Groups(['subscription:read'])]
private string $plan;
// no #[Groups] on stripeCustomerId → never serialized
}
// in the controller:
return $this->json($subscription, context: ['groups' => 'subscription:read']);
Document the exposed shape (DTO/group) so a SPA frontend agent can consume it.
8. Messenger — async work
Offload slow/side-effectful work to the message bus.
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
final readonly class SendSubscriptionReceipt
{
public function __construct(public int $subscriptionId) {}
}
#[AsMessageHandler]
final class SendSubscriptionReceiptHandler
{
public function __construct(private readonly Mailer $mailer) {}
public function __invoke(SendSubscriptionReceipt $message): void
{
// ...
}
}
Dispatch with $this->bus->dispatch(new SendSubscriptionReceipt($id)). Configure transports (async) in config/packages/messenger.yaml.
9. Configuration
- Service config in
config/services.yaml; package config inconfig/packages/*.yaml. - Env values via
%env(...)%(typed:%env(int:...)%,%env(bool:...)%). Secrets via the Secrets Vault (bin/console secrets:set). Never hardcode. - Bind app parameters under
parameters:and inject with#[Autowire('%param%')].
10. Anti-patterns to avoid
| Don't | Do |
|---|---|
| YAML/XML routing for new controllers | #[Route] attributes |
| Property/setter injection | Constructor injection |
Inline $validator->validate() in controllers |
Constraint attributes + #[MapRequestPayload] / #[Valid] |
in_array('ROLE_X', $user->getRoles()) |
Voter + #[IsGranted] |
return $this->json($entity) (all fields) |
Serialization groups or a DTO |
| Business logic in the controller | Service class |
| Hardcoded secrets in config | %env(...)% / Secrets Vault |
11. Checklist before completing development phase
- All new routes use
#[Route]with explicitmethods: - Controllers extend
AbstractControllerand are thin; logic in services - All collaborators injected via constructor
- All input validated via Constraint attributes (no inline validation)
- Authorization via Voters /
access_control(no inline role checks) - Serialized output uses groups/DTOs (no raw-entity exposure)
- No hardcoded secrets —
%env(...)%only -
php bin/console lint:containerpasses -
vendor/bin/php-cs-fixer fixclean