From symfony-plugin
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 contract, and Messenger. Apply when writing or reviewing Symfony backend code. Activated automatically by symfony-plugin/stack.md as a convention skill for the development phase. Apply when: writing or reviewing Symfony controllers, services, forms, validators, voters, and serialization. Do NOT use this skill for: - PHP language idioms (strict_types, enums, readonly) — see php-foundation:php-conventions. - Doctrine queries / entities — see symfony-plugin:doctrine-patterns. - Testing — injected by the QA phase (WebTestCase/KernelTestCase) + php-foundation:php-testing.
How this skill is triggered — by the user, by Claude, or both
Slash command
/symfony-plugin:symfony-conventionsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
This skill encodes the conventions used across modern Symfony projects. Apply alongside `php-foundation:php-conventions` (language idioms) when implementing features.
This skill encodes the conventions used across modern Symfony projects. Apply alongside php-foundation:php-conventions (language idioms) when implementing features.
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.
AbstractController (gives render(), json(), denyAccessUnlessGranted(), getUser()).#[Route] is automatically registered as a service with autowired action arguments.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.
@required setters for new code.config/services.yaml _defaults: autowire: true, autoconfigure: true).#[Autowire]:public function __construct(
private readonly HttpClientInterface $http,
#[Autowire('%env(STRIPE_SECRET)%')] private readonly string $stripeSecret,
) {
}
services.yaml only when autowiring can't resolve it. Avoid manual service definitions otherwise.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.
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.
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.
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.
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.
config/services.yaml; package config in config/packages/*.yaml.%env(...)% (typed: %env(int:...)%, %env(bool:...)%). Secrets via the Secrets Vault (bin/console secrets:set). Never hardcode.parameters: and inject with #[Autowire('%param%')].| 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 |
#[Route] with explicit methods:AbstractController and are thin; logic in servicesaccess_control (no inline role checks)%env(...)% onlyphp bin/console lint:container passesvendor/bin/php-cs-fixer fix cleannpx claudepluginhub aratkruglik/claude-sdlc --plugin symfony-pluginProvides Symfony framework reference with architecture patterns, DDD integration, clean architecture checklists, common violations, and antipatterns for auditing PHP projects.
Reference for all 38 Symfony components covering APIs, configuration, best practices, and common pitfalls for PHP 8.3+ and Symfony 7.x.
Creates or refactors Symfony console commands using modern attribute-based patterns (method-based on 8.1+, invokable class on 7.x) instead of extending Command.