symfony
SKILL.md
Symfony Framework Guide
Applies to: Symfony 7+, PHP 8.2+, Doctrine ORM, Twig, Messenger
Core Principles
- Convention Over Configuration: Follow Symfony defaults unless there is a strong reason not to
- Dependency Injection: Constructor injection for all services; avoid service locators
- Thin Controllers: Controllers delegate to services; no business logic in controllers
- DTOs at Boundaries: Never expose Doctrine entities directly to API consumers
- Events for Decoupling: Use EventDispatcher and Messenger for side effects
- Explicit Configuration: Use PHP 8 attributes for routing, ORM mapping, and validation
Guardrails
Version and Dependencies
- Target Symfony 7.x with PHP 8.2+
- Use Composer with
composer.lockcommitted - Use Symfony Flex for bundle management
- Pin major versions in
composer.json; runcomposer auditbefore adding dependencies
Code Style
- All files start with
declare(strict_types=1); - Use PSR-12 coding standard; run
php-cs-fixer fixbefore committing - Run PHPStan at level 8+ (
vendor/bin/phpstan analyse src) - Use PHP 8 attributes (not annotations) for routing, ORM, validation
- Follow Symfony naming:
PascalCaseclasses,camelCasemethods,snake_caseconfig keys
Controller Rules
- Extend
AbstractControlleronly when you need its shortcuts - One action per method; keep under 20 lines
- Use
#[Route]attribute on both class and method - Use
#[MapRequestPayload]for automatic DTO deserialization and validation - Return
JsonResponsefor APIs; neverechoordie() - Use serialization groups to control response shape
Entity Rules
- Use PHP 8 attributes for all Doctrine mapping (
#[ORM\Entity],#[ORM\Column], etc.) - Always set
repositoryClasson entities - Use
#[ORM\HasLifecycleCallbacks]sparingly; prefer Doctrine event listeners - Initialize collections in constructor:
$this->items = new ArrayCollection() - Use
DateTimeImmutablefor all date columns - Add database indexes for frequently queried columns with
#[ORM\Index] - Fluent setters return
staticfor chaining
Service Rules
- One responsibility per service class
- All dependencies via constructor injection (use
readonlypromoted properties) - Never inject
EntityManagerInterfaceinto controllers; inject repositories or services - Use
#[AsMessageHandler]for async operations - Tag services only when autowiring cannot resolve
Project Structure
myapp/
├── bin/
│ └── console # Symfony CLI
├── config/
│ ├── packages/ # Per-package YAML config
│ │ ├── doctrine.yaml
│ │ ├── security.yaml
│ │ ├── messenger.yaml
│ │ └── ...
│ ├── routes/ # Route imports
│ ├── routes.yaml
│ ├── services.yaml # Service definitions and autowiring
│ └── bundles.php # Registered bundles
├── migrations/ # Doctrine migrations (never edit after deploy)
├── public/
│ └── index.php # Single entry point
├── src/
│ ├── Controller/ # HTTP controllers (thin)
│ ├── Dto/ # Request/response DTOs
│ ├── Entity/ # Doctrine entities
│ ├── Repository/ # Doctrine repositories
│ ├── Service/ # Business logic
│ ├── EventSubscriber/ # Event subscribers
│ ├── Message/ # Messenger messages
│ ├── MessageHandler/ # Messenger handlers
│ ├── Command/ # Console commands
│ ├── Form/ # Form types (web apps)
│ ├── Security/ # Voters, authenticators
│ └── Kernel.php
├── templates/ # Twig templates
├── tests/
│ ├── Controller/ # Functional tests
│ ├── Service/ # Unit tests
│ └── bootstrap.php
├── translations/ # i18n files
├── var/ # Cache and logs (gitignored)
├── .env # Default env vars (committed)
├── .env.local # Local overrides (gitignored)
├── composer.json
├── phpunit.xml.dist
└── symfony.lock
src/Dto/keeps request/response data separate from entitiessrc/Message/andsrc/MessageHandler/follow Messenger conventionsvar/is ephemeral; never store persistent data thereconfig/packages/files are loaded by environment (config/packages/test/)
Controllers and Routing
API Controller
#[Route('/api/v1/users')]
class UserController extends AbstractController
{
public function __construct(
private readonly UserService $userService,
) {}
#[Route('', methods: ['GET'])]
public function index(Request $request): JsonResponse
{
$page = $request->query->getInt('page', 1);
$limit = $request->query->getInt('limit', 15);
return $this->json(
$this->userService->getPaginated($page, $limit),
Response::HTTP_OK,
[],
['groups' => 'user:read'],
);
}
#[Route('', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function create(#[MapRequestPayload] CreateUserDto $dto): JsonResponse
{
return $this->json(
$this->userService->create($dto),
Response::HTTP_CREATED,
[],
['groups' => 'user:read'],
);
}
}
Routing Tips
- Prefer attribute routing over YAML for controller routes
- Use YAML routes only for third-party bundle prefixes
- Version API routes:
/api/v1/... - Type-hint entities in action signatures for automatic
ParamConverter
Doctrine ORM Basics
Entity with Validation
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: 'users')]
#[ORM\HasLifecycleCallbacks]
class User
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 180, unique: true)]
#[Assert\NotBlank]
#[Assert\Email]
private string $email;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
// Getters and fluent setters (return static)
}
Repository
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<User> */
class UserRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
public function findOneByEmail(string $email): ?User
{
return $this->createQueryBuilder('u')
->andWhere('u.email = :email')
->setParameter('email', strtolower($email))
->getQuery()
->getOneOrNullResult();
}
}
Migration Workflow
php bin/console make:migration # Generate from entity diff
php bin/console doctrine:migrations:migrate # Apply migrations
php bin/console doctrine:schema:validate # Check mapping vs DB
- Always review generated migrations before running
- Every migration must have a working
down()method - Never edit migrations that have been applied to production
Twig Templates
Layout Pattern
{# templates/base.html.twig #}
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}App{% endblock %}</title>
{% block stylesheets %}{% endblock %}
</head>
<body>
{% block body %}{% endblock %}
{% block javascripts %}{% endblock %}
</body>
</html>
{# templates/user/index.html.twig #}
{% extends 'base.html.twig' %}
{% block title %}Users{% endblock %}
{% block body %}
<h1>Users</h1>
{% for user in users %}
<p>{{ user.name|e }}</p>
{% else %}
<p>No users found.</p>
{% endfor %}
{% endblock %}
- Always escape output (Twig auto-escapes by default; never use
|rawon user data) - Use
{% include %}for partials,{% embed %}for overridable partials - Keep logic minimal in templates; compute values in controller or service
Services and Dependency Injection
Service Definition
class UserService
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly EventDispatcherInterface $eventDispatcher,
) {}
public function create(CreateUserDto $dto): User
{
$user = new User();
$user->setEmail($dto->email);
$user->setPassword($this->passwordHasher->hashPassword($user, $dto->password));
$this->entityManager->persist($user);
$this->entityManager->flush();
$this->eventDispatcher->dispatch(new UserCreatedEvent($user));
return $user;
}
}
services.yaml Essentials
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
- Rely on autowiring; only add explicit definitions when needed
- Use
#[Autowire]attribute for non-standard parameters - Use
#[TaggedIterator]to inject all services with a specific tag
Form Handling
class UserType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class)
->add('email', EmailType::class);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(['data_class' => User::class]);
}
}
- Always bind forms to a DTO or entity via
data_class - Validate via constraints on the DTO/entity, not in the form type
- Use CSRF protection (enabled by default for web forms)
- For APIs, prefer
#[MapRequestPayload]over Symfony forms
Security Basics
Voter Pattern
class PostVoter extends Voter
{
public const EDIT = 'POST_EDIT';
public const DELETE = 'POST_DELETE';
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::EDIT, self::DELETE], true)
&& $subject instanceof Post;
}
protected function voteOnAttribute(
string $attribute,
mixed $subject,
TokenInterface $token,
): bool {
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
return $subject->getAuthor() === $user || $user->getRole() === 'admin';
}
}
Security Configuration Checklist
- Use
password_hashers: auto(Symfony picks bcrypt/argon2 automatically) - Stateless firewalls for APIs (
stateless: true) - JWT authentication via
lexik/jwt-authentication-bundlefor APIs - CSRF protection enabled for all web forms
- Use
#[IsGranted]attribute on controller actions - Use Voters for object-level authorization (not
is_granted('ROLE_...')for fine-grained checks) - Never store plain-text passwords; always use
UserPasswordHasherInterface
Console Commands
#[AsCommand(name: 'app:import-users', description: 'Import users from CSV')]
class ImportUsersCommand extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
// Implementation here
$io->success('Import complete.');
return Command::SUCCESS;
}
}
- Use
#[AsCommand]attribute (notconfigure()for name/description) - Return
Command::SUCCESS,Command::FAILURE, orCommand::INVALID - Use
SymfonyStylefor consistent output formatting - Inject services via constructor (commands are services)
Essential CLI Commands
# Development
symfony serve # Local dev server
php bin/console cache:clear # Clear cache
php bin/console debug:router # List all routes
php bin/console debug:container # List all services
# Code Generation
php bin/console make:entity User
php bin/console make:controller UserController
php bin/console make:migration
php bin/console make:form UserType
php bin/console make:voter PostVoter
php bin/console make:command App:ImportUsers
php bin/console make:subscriber UserEventSubscriber
php bin/console make:message SendWelcomeEmail
# Database
php bin/console doctrine:database:create
php bin/console doctrine:migrations:migrate
php bin/console doctrine:schema:validate
php bin/console doctrine:fixtures:load
# Messenger
php bin/console messenger:consume async
php bin/console messenger:failed:show
php bin/console messenger:failed:retry
# Testing and Quality
php bin/phpunit
php bin/phpunit --coverage-html coverage
vendor/bin/phpstan analyse src
vendor/bin/php-cs-fixer fix
# Production
composer install --no-dev --optimize-autoloader
php bin/console cache:clear --env=prod
php bin/console cache:warmup --env=prod
Do and Don't
Do
- Use constructor injection with
readonlypromoted properties - Use DTOs for all API request/response payloads
- Use Messenger for async work (emails, notifications, heavy processing)
- Use Voters for authorization logic
- Use Events for decoupling side effects from core logic
- Use serialization groups to control JSON output shape
- Use
DateTimeImmutablefor all temporal data - Run
php bin/console doctrine:schema:validatein CI
Don't
- Don't put business logic in controllers (delegate to services)
- Don't flush
EntityManagerinside loops (batch with$em->flush()once) - Don't use
$_GET,$_POST,$_SERVER(useRequestobject) - Don't hardcode config values (use
%env()%syntax or#[Autowire]) - Don't bypass the security component (no manual session/cookie auth)
- Don't create "god services" that handle everything (single responsibility)
- Don't ignore Symfony deprecation warnings (they become errors on upgrade)
Advanced Topics
For detailed patterns and production guidance, see:
- references/patterns.md -- Doctrine advanced patterns, event system, Messenger, API Platform, testing, deployment
External References
Weekly Installs
9
Repository
ar4mirez/samuelGitHub Stars
3
First Seen
Mar 1, 2026
Security Audits
Installed on
opencode9
gemini-cli9
github-copilot9
codex9
amp9
cline9