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
code
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 entities - •
src/Message/andsrc/MessageHandler/follow Messenger conventions - •
var/is ephemeral; never store persistent data there - •
config/packages/files are loaded by environment (config/packages/test/)
Controllers and Routing
API Controller
php
#[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
<?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
<?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
bash
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
twig
{# 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
php
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
yaml
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
php
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
php
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
php
#[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
bash
# 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