DevTools

Cheatsheet Symfony

Framework PHP robusto e maduro para aplicações web enterprise

Back to languages
Symfony
92 cards found
Categories:
Versions:

Installation and Setup


10 cards
Create Project
composer create-project symfony/skeleton my-project
cd my-project
symfony serve

symfony/skeleton creates a minimal project. symfony serve starts the development server with automatic HTTPS. For a full project use symfony/website-skeleton.

Maker Bundle
php bin/console make:controller HomeController
php bin/console make:entity Post
php bin/console make:form PostType
php bin/console make:crud Post

MakerBundle generates code automatically. make:crud creates controller, templates and form at once. All commands are interactive and generate ready-to-use files.

Install Bundles
composer require symfony/mailer
composer require symfony/security-bundle
composer require doctrine/doctrine-bundle
composer require symfony/maker-bundle --dev

composer require installs and configures bundles via Symfony Flex. Recipes create config files automatically. --dev installs only in development.

Symfony CLI
symfony new app --webapp
symfony check:requirements
symfony open:local
symfony server:log

symfony new creates a project with the CLI. --webapp includes all web packages. check:requirements validates the environment. open:local opens it in the browser.

Project Structure
src/
  Controller/
  Entity/
  Repository/
  Service/
templates/
config/
  packages/
  routes.yaml
  services.yaml
public/

src/ contains all PHP code. templates/ holds the Twig files. config/packages/ has per-bundle configuration. public/ is the web root.

Symfony Flex
composer recipes
composer sync-recipes
composer unpack api

Flex is the plugin that automates package installation. recipes lists applied recipes. unpack expands a pack into its individual components for more control.

Development Server
symfony serve -d
symfony server:stop
symfony server:status
symfony serve --port=8080

-d runs the a daemon (background). server:stop stops the server. The server supports automatic HTTPS and multiple projects on different ports.

.env Configuration
# .env
APP_ENV=dev
APP_SECRET=a1b2c3d4
DATABASE_URL="mysql://root:@127.0.0.1:3306/app"
MAILER_DSN=smtp://localhost

.env defines environment variables. APP_ENV controls the environment (dev/prod/test). DATABASE_URL configures the DB connection. Never commit .env.local.

Bin Console
php bin/console list
php bin/console debug:router
php bin/console debug:container
php bin/console about

bin/console is the command console. debug:router lists all routes. debug:container shows the registered services. about displays project info.

YAML Configuration
# config/packages/framework.yaml
framework:
    secret: '%env(APP_SECRET)%'
    session:
        handler_id: null
    csrf_protection: true

Each bundle has its file in config/packages/. The %env()% syntax reads environment variables. Configurations are merged automatically per environment.

Routes and Controllers


11 cards
Basic Controller
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;

class HomeController extends AbstractController
{
    public function index(): Response
    {
        return new Response('Hello Symfony!');
    }
}

AbstractController provides helper methods like render(), json() and redirectToRoute(). Every controller must return a Response.

Response Types
return new Response('HTML text');
return $this->json(['ok' => true]);
return $this->redirectToRoute('app_home');
return $this->render('page.html.twig', $data);
return new JsonResponse($data, 201);

Response is the base response. json() returns JSON with the correct headers. redirectToRoute() does a 302 redirect. render() renders a Twig template.

Route with Prefix
#[Route('/admin', name: 'admin_')]
class AdminController extends AbstractController
{
    #[Route('/dashboard', name: 'dashboard')]
    public function dashboard(): Response { }
    // final URL: /admin/dashboard
}

The #[Route] on the class defines a prefix for all the controller routes. The class name is prefixed to each method name. Avoids path repetition.

Route with Attribute
use Symfony\Component\Routing\Attribute\Route;

#[Route('/hello/{name}', name: 'app_hello')]
public function hello(string $name): Response
{
    return $this->render('hello.html.twig', [
        'name' => $name,
    ]);
}

#[Route] defines the route with PHP 8 attributes. The name allows generating URLs and redirecting. The {name} parameter is injected automatically into the method.

Request Object
use Symfony\Component\HttpFoundation\Request;

public function store(Request $request): Response
{
    $name = $request->request->get('name');
    $query = $request->query->get('page', 1);
    $all = $request->request->all();
    $ip = $request->getClientIp();
}

Request is injected automatically. request accesses POST data. query accesses GET parameters. The second argument of get() is the default value.

Route Naming and Organization
// config/routes.yaml
controllers:
    resource: ../src/Controller/
    type: attribute
    prefix: /{_locale}
    requirements:
        _locale: 'pt|en|fr'

config/routes.yaml imports the controller routes. prefix adds a global prefix. requirements restricts _locale to valid values. Convention: app_ for names.

Route Parameters
#[Route('/user/{id}', requirements: ['id' => '\d+'])]
public function show(int $id): Response { }

#[Route('/blog/{slug}', defaults: ['page' => 1])]
public function post(string $slug, int $page): Response { }

requirements validates the parameter with regex. \d+ accepts only numbers. defaults sets optional values. The int type-hint casts automatically.

Generate URLs
$url = $this->generateUrl('app_hello', ['name' => 'Anna']);

// with RouterInterface injected:
use Symfony\Component\Routing\RouterInterface;

$url = $router->generate('app_hello', ['name' => 'Anna']);

generateUrl() creates URLs from the route name. Extra parameters become the query string. Always use route names instead of hardcoded URLs for easier maintenance.

Custom Response
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;

return new BinaryFileResponse('/path/file.pdf');

$response = new StreamedResponse(function () {
    echo 'streaming...';
});

BinaryFileResponse sends files with the correct headers. StreamedResponse generates content progressively (useful for large CSVs). Both extend Response.

HTTP Methods
#[Route('/posts', methods: ['GET'])]
public function list(): Response { }

#[Route('/posts', methods: ['POST'])]
public function create(Request $request): Response { }

#[Route('/posts/{id}', methods: ['PUT', 'PATCH'])]
public function update(int $id): Response { }

methods restricts the accepted HTTP verbs. Routes with the same path but different methods are resolved correctly. Useful for RESTful APIs.

Redirects
return $this->redirectToRoute('app_home');
return $this->redirect('https://external.com');
return new RedirectResponse($url, 301);

redirectToRoute() redirects to an internal route. redirect() accepts an absolute URL. RedirectResponse lets you set the HTTP code (301 permanent, 302 temporary).

Utilities and DI


10 cards
Create Service
namespace App\Service;

class EmailService
{
    public function send(string $to, string $msg): void
    {
        // sending logic
    }
}

A service is a plain PHP class in src/Service/. Symfony registers it automatically in the container. No manual configuration needed with autowire enabled.

Interface Binding
# config/services.yaml
services:
    App\Service\PaymentInterface:
        alias: App\Service\StripePayment

// in the code:
public function __construct(
    private PaymentInterface $payment,
) {}

alias binds an interface to the concrete implementation. Allows swapping implementations without changing the consumer code. Essential for Dependency Inversion and testability.

Service Decorator
#[AsDecorator(serviceName: 'app.notifier', priority: 10)]
class CachedNotifier implements NotifierInterface
{
    public function __construct(
        private NotifierInterface $inner,
    ) {}

    public function notify(string $msg): void
    {
        // extra logic + $this->inner->notify($msg);
    }
}

#[AsDecorator] wraps an existing service without modifying it. The original service is available the $inner. priority controls the order when there are multiple decorators.

Automatic Injection
class PostController extends AbstractController
{
    public function __construct(
        private EmailService $email,
        private LoggerInterface $logger,
    ) {}

    public function index(): Response
    {
        $this->logger->info('Accessed the index');
    }
}

Constructor injection with PHP 8 promoted properties. The container resolves dependencies automatically via type-hint. LoggerInterface is injected without extra configuration.

Tagged Services
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

#[AsEventListener(event: 'kernel.request')]
class MyListener
{
    public function __invoke(RequestEvent $event): void
    {
        // executed on every request
    }
}

#[AsEventListener] registers the class the a listener without YAML config. The __invoke method is called when the event fires. autoconfigure detects the attribute automatically.

Compiler Passes
// src/DependencyInjection/AppExtension.php
class AppExtension extends Extension
{
    public function load(array $configs, ContainerBuilder $c): void
    {
        $loader = new YamlFileLoader($c, new FileLocator(__DIR__));
        $loader->load('services.yaml');
    }
}

Extension allows a bundle to register services programmatically. CompilerPass modifies the container before compiling. Used in reusable bundles to register services conditionally.

Autowiring Config
# config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'
        exclude:
            - '../src/Entity/'
            - '../src/Kernel.php'

autowire: true injects dependencies by type-hint. autoconfigure: true registers tags automatically. exclude prevents registering entities and the Kernel.

Public Service
# config/services.yaml
services:
    App\Service\LegacyService:
        public: true

// access manually:
$service = $container->get(LegacyService::class);

By default services are private (injection only). public: true allows direct access through the container. Use only for legacy services or tests.

Config Parameters
# config/services.yaml
parameters:
    app.name: 'My Site'
    app.max_items: 50

# in the service:
use Symfony\Component\DependencyInjection\Attribute\Autowire;

public function __construct(
    #[Autowire('%app.name%')]
    private string $name,
) {}

parameters defines reusable values. #[Autowire] injects parameters or specific values. The %param% syntax references container parameters.

Lazy Services
# config/services.yaml
services:
    App\Service\HeavyService:
        lazy: true

// or with attribute:
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;

#[AsDecorator('app.mailer')]
class LoggingMailer { }

lazy: true creates a proxy that only instantiates the service when it is actually used. Reduces memory and boot time. Useful for heavy, rarely used services.

Doctrine ORM


11 cards
Create Entity
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: PostRepository::class)]
class Post
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private string $title;

    #[ORM\Column(type: 'text')]
    private string $content;
}

#[ORM\Entity] marks the class the an entity. #[ORM\Id] and #[ORM\GeneratedValue] define the auto-increment primary key. repositoryClass links to the custom repository.

QueryBuilder
$qb = $this->createQueryBuilder('p')
    ->where('p.active = :active')
    ->andWhere('p.created > :date')
    ->setParameter('active', true)
    ->setParameter('date', new \DateTime('-30 days'))
    ->orderBy('p.created', 'DESC')
    ->setMaxResults(10);

$posts = $qb->getQuery()->getResult();

createQueryBuilder() builds dynamic queries. setParameter() prevents SQL injection. setMaxResults() limits results. Always preferable to concatenated DQL.

DQL
$dql = 'SELECT p, c FROM App\Entity\Post p
        JOIN p.comments c
        WHERE p.active = :active
        ORDER BY p.created DESC';

$query = $em->createQuery($dql)
    ->setParameter('active', true)
    ->setMaxResults(20);

$posts = $query->getResult();

DQL is Doctrine's object-oriented query language. JOIN loads relations eagerly. getResult() returns an array of entities. Use getOneOrNullResult() for a single one.

Column Types
#[ORM\Column(type: 'string', length: 100)]
private string $name;

#[ORM\Column(type: 'integer')]
private int $age;

#[ORM\Column(type: 'boolean', options: ['default' => false])]
private bool $active;

#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $created;

#[ORM\Column(type: 'json')]
private array $metadata = [];

type defines the mapped SQL type. options allows defaults and nullable. json serializes arrays automatically. datetime_immutable is preferable to datetime.

CRUD Operations
$em = $this->entityManager;

// Create / Update
$em->persist($post);
$em->flush();

// Delete
$em->remove($post);
$em->flush();

// Read
$post = $em->find(Post::class, $id);

EntityManager manages the entity lifecycle. persist() marks for insert/update. flush() executes the SQL queries. find() looks up by primary key.

Entity Events
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\HasLifecycleCallbacks]
class Post
{
    #[ORM\PrePersist]
    public function onPrePersist(): void
    {
        $this->created = new \DateTimeImmutable();
    }

    #[ORM\PreUpdate]
    public function onPreUpdate(): void
    {
        $this->updated = new \DateTimeImmutable();
    }
}

#[ORM\HasLifecycleCallbacks] enables callbacks on the entity. #[ORM\PrePersist] runs before inserting. #[ORM\PreUpdate] runs before updating. Alternative: use Doctrine Events.

Migrations
php bin/console doctrine:database:create
php bin/console make:migration
php bin/console doctrine:migrations:migrate
php bin/console doctrine:migrations:status
php bin/console doctrine:schema:validate

make:migration generates the migration by comparing entities with the DB. migrate applies pending migrations. schema:validate checks consistency between mapping and DB.

ManyToOne Relations
#[ORM\Entity]
class Comment
{
    #[ORM\ManyToOne(inversedBy: 'comments')]
    #[ORM\JoinColumn(nullable: false)]
    private Post $post;
}

// in Post:
#[ORM\OneToMany(mappedBy: 'post', targetEntity: Comment::class)]
private Collection $comments;

#[ORM\ManyToOne] defines the owning side of the relation. inversedBy points to the property on the opposite side. mappedBy on the OneToMany indicates it is the inverse side.

Fixtures
use Doctrine\Bundle\FixturesBundle\Fixture;

class AppFixtures extends Fixture
{
    public function load(ObjectManager $manager): void
    {
        $post = new Post();
        $post->setTitle('First post');
        $manager->persist($post);
        $manager->flush();
    }
}
// php bin/console doctrine:fixtures:load

Fixtures populate the DB with test data. ObjectManager works like the EntityManager. doctrine:fixtures:load purges and reloads everything. Use --append to avoid purging.

Repository
class PostRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Post::class);
    }

    public function findActive(): array
    {
        return $this->findBy(['active' => true], ['created' => 'DESC']);
    }
}

ServiceEntityRepository is the base for repositories. findBy() and findOneBy() are inherited magic methods. Add custom methods for reusable queries.

ManyToMany Relations
#[ORM\Entity]
class Post
{
    #[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'posts')]
    #[ORM\JoinTable(name: 'post_tag')]
    private Collection $tags;

    public function __construct()
    {
        $this->tags = new ArrayCollection();
    }
}

#[ORM\ManyToMany] creates an intermediate table. #[ORM\JoinTable] sets the pivot table name. Initialize collections in the constructor with ArrayCollection.

Forms and Validation


10 cards
Create Form Type
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;

class PostType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('title')
            ->add('content', TextareaType::class)
            ->add('active', CheckboxType::class, ['required' => false]);
    }
}

AbstractType is the base of all forms. buildForm() defines the fields. The type is inferred from the property name if omitted. required => false removes the HTML required attribute.

Field Types
TextType::class
TextareaType::class
EmailType::class
IntegerType::class
ChoiceType::class
EntityType::class
DateTimeType::class
CheckboxType::class
FileType::class
PasswordType::class

Each type maps to a specific HTML input. ChoiceType creates select/radio/checkbox. EntityType loads options from a Doctrine entity. FileType handles uploads.

Nested Forms
class PostType extends AbstractType
{
    public function buildForm(FormBuilderInterface $b, array $opts): void
    {
        $b->add('title')
          ->add('author', AuthorType::class)
          ->add('tags', CollectionType::class, [
              'entry_type' => TagType::class,
              'allow_add' => true,
              'by_reference' => false,
          ]);
    }
}

CollectionType manages lists of sub-forms. allow_add allows adding items via JS. by_reference => false forces the use of setters. entry_type defines each item type.

Use in Controller
$form = $this->createForm(PostType::class, $post);
$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
    $em->persist($post);
    $em->flush();
    return $this->redirectToRoute('app_post_list');
}

return $this->render('post/form.html.twig', [
    'form' => $form,
]);

createForm() instantiates the form bound to an entity. handleRequest() processes the submission. isSubmitted() and isValid() check the state before persisting.

Form without Class
$form = $this->createFormBuilder()
    ->add('name', TextType::class)
    ->add('email', EmailType::class)
    ->add('submit', SubmitType::class)
    ->getForm();

$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
    $data = $form->getData();
}

createFormBuilder() creates inline forms without a FormType. Ideal for simple search or contact forms. getData() returns an associative array with the values.

CSRF in Forms
# config/packages/framework.yaml
framework:
    csrf_protection: true

// manual validation:
if (!$this->isCsrfTokenValid('delete-item', $request->get('_token'))) {
    throw new AccessDeniedException('Invalid token');
}

csrf_protection enables tokens on all forms. The token is rendered automatically by form_end(). For actions outside forms use isCsrfTokenValid() manually.

Validation with Constraints
use Symfony\Component\Validator\Constraints as Assert;

#[Assert\NotBlank(message: 'The title is required')]
#[Assert\Length(min: 5, max: 255)]
private string $title;

#[Assert\Email]
#[Assert\NotBlank]
private string $email;

#[Assert\Range(min: 0, max: 100)]
private int $age;

Constraints validate entity data. #[Assert\NotBlank] rejects empty. #[Assert\Length] limits characters. Constraints are checked automatically by form->isValid().

Form Events
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormEvent;

$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
    $post = $event->getData();
    $form = $event->getForm();

    if ($post && $post->getId()) {
        $form->add('created', DateTimeType::class, ['disabled' => true]);
    }
});

FormEvents allows modifying fields dynamically. PRE_SET_DATA runs before populating data. PRE_SUBMIT and POST_SUBMIT control the submission. Useful for conditional fields.

Render in Twig
{{ form_start(form) }}
    {{ form_row(form.title) }}
    {{ form_row(form.content) }}
    {{ form_widget(form.active) }}
    <button type="submit">Save</button>
{{ form_end(form) }}

{# render everything at once: #}
{{ form(form) }}

form_start() opens the form tag with CSRF. form_row() renders label + widget + errors. form_end() closes and renders hidden fields. form(form) is the full shortcut.

File Upload
$builder->add('file', FileType::class, [
    'constraints' => [
        new File([
            'maxSize' => '5M',
            'mimeTypes' => ['application/pdf', 'image/png'],
        ]),
    ],
]);

// in the controller:
$file = $form->get('file')->getData();
$newName = uniqid() . '.' . $file->guessExtension();
$file->move($this->getParameter('uploads_dir'), $newName);

FileType handles uploads. The File constraint validates size and MIME type. move() saves the file to the destination. Use getParameter() for the configured directory.

Twig Templates


10 cards
Variables and Output
{{ name }}
{{ post.title }}
{{ user.email|default('N/A') }}
{{ "Hello #{name}" }}
{{ items|length }}

{{ }} prints variables. Property access with a dot. |default provides a fallback for nulls. |length counts elements. Interpolation with #{} inside strings.

Functions
{{ path('app_hello', {name: 'Anna'}) }}
{{ url('app_hello', {name: 'Anna'}) }}
{{ asset('css/app.css') }}
{{ csrf_token('my_form') }}
{{ include('partials/menu.html.twig') }}
{{ dump(variable) }}

path() generates a relative URL. url() generates an absolute URL. asset() references static files with versioning. dump() shows debug (dev only).

Assets
<link rel="stylesheet" href="{{ asset('build/app.css') }}">
<script src="{{ asset('build/app.js') }}"></script>

{# with Webpack Encore: #}
{{ encore_entry_link_tags('app') }}
{{ encore_entry_script_tags('app') }}

asset() generates URLs with cache-busting. Encore integrates Webpack to compile assets. encore_entry_link_tags() and encore_entry_script_tags() insert tags automatically.

Flow Control
{% if user and user.isAdmin %}
    <p>Admin</p>
{% elseif user %}
    <p>User</p>
{% else %}
    <p>Anonymous</p>
{% endif %}

{% for item in items %}
    <li>{{ loop.index }}. {{ item }}</li>
{% endfor %}

{% if %} supports conditions with logical operators. {% for %} iterates arrays. loop.index gives the current position (1-based). loop.first and loop.last are useful booleans.

Include and Macros
{% include 'partials/header.html.twig' %}
{% include 'partials/card.html.twig' with {title: 'X'} %}

{% macro input(name, value, type) %}
    <input type="{{ type|default('text') }}"
           name="{{ name }}"
           value="{{ value }}">
{% endmacro %}

{% import _self as forms %}
{{ forms.input('email', '', 'email') }}

{% include %} inserts partials with optional variables. {% macro %} defines reusable functions. {% import %} imports macros from another file. _self references macros from the template itself.

Debug and Dump
{{ dump(variable) }}
{{ dump() }}  {# all variables #}

{% debug %}

{# in the CLI: #}
php bin/console debug:twig
php bin/console lint:twig templates/

dump() inspects variables (dev only). debug:twig lists available functions and filters. lint:twig validates the syntax of all templates. Remove dumps before production.

Template Inheritance
{# templates/base.html.twig #}
<!DOCTYPE html>
<html>
<body>
    {% block content %}{% endblock %}
    {% block scripts %}{% endblock %}
</body>
</html>

{# templates/page.html.twig #}
{% extends 'base.html.twig' %}
{% block content %}
    <h1>Page</h1>
{% endblock %}

{% extends %} inherits from a base layout. {% block %} defines overridable sections. parent() includes the parent block content. Each template can only have one extends.

Twig Components
{# templates/components/alert.html.twig #}
<div class="alert alert-{{ type|default('info') }}">
    {% block content %}{% endblock %}
</div>

{# usage: #}
{% component 'alert' with {type: 'danger'} %}
    {% block content %}Serious error!{% endblock %}
{% endcomponent %}

Twig Components (extra bundle) create reusable components with props. Syntax similar to Web Components. Requires symfony/ux-twig-component. Modern alternative to macros.

Filters
{{ name|upper }}
{{ text|lower|truncate(50) }}
{{ date|date('d/m/Y') }}
{{ price|number_format(2, ',', '.') }}
{{ html|raw }}
{{ list|join(', ') }}

|upper and |lower transform case. |date formats dates. |raw disables HTML escaping (beware of XSS). |number_format formats numbers. Filters chain with the pipe.

Translation (i18n)
{# in the template: #}
{{ 'welcome.message'|trans({'%name%': user.name}) }}

{# translations/messages.en.yaml: #}
welcome.message: 'Welcome, %name%!'

{# in the controller: #}
$translator->trans('welcome.message', ['%name%' => 'Anna']);

|trans translates strings with placeholders. Files in translations/ per locale. %placeholder% is replaced by the parameters. trans() also works in PHP.

Security


10 cards
Configure Security
# config/packages/security.yaml
security:
    password_hashers:
        App\Entity\User: 'auto'

    providers:
        app_user_provider:
            entity:
                class: App\Entity\User
                property: email

    firewalls:
        main:
            form_login:
                login_path: app_login
                check_path: app_login

password_hashers defines the hash somethingrithm. providers indicates how to load users. firewalls protects application areas. form_login enables form-based authentication.

CSRF Protection
<input type="hidden" name="_token"
       value="{{ csrf_token('delete-item') }}">

// validate in the controller:
if (!$this->isCsrfTokenValid('delete-item', $request->get('_token'))) {
    throw new AccessDeniedException('Invalid CSRF token');
}

csrf_token() generates the token in the template. isCsrfTokenValid() validates in the controller. The first argument is the token ID (must be unique per action). Protection against cross-site request forgery.

Logout
# config/packages/security.yaml
security:
    firewalls:
        main:
            logout:
                path: app_logout
                target: app_home

// controller (empty - Symfony handles it):
#[Route('/logout', name: 'app_logout')]
public function logout(): void
{
    throw new \LogicException('Intercepted by the firewall');
}

logout.path defines the logout route. target is the redirect after logout. The controller is never executed — the firewall intercepts it. The session is destroyed automatically.

User Entity
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;

#[ORM\Entity]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
    private string $email;
    private string $password;
    private array $roles = [];

    public function getUserIdentifier(): string
    {
        return $this->email;
    }
}

UserInterface is required for authentication. PasswordAuthenticatedUserInterface adds hash support. getUserIdentifier() returns the unique identifier (email). getRoles() returns roles.

Custom Voters
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

class PostVoter extends Voter
{
    protected function supports(string $attribute, mixed $subject): bool
    {
        return $attribute === 'EDIT' && $subject instanceof Post;
    }

    protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
    {
        $user = $token->getUser();
        return $subject->getAuthor() === $user;
    }
}

Voter implements custom authorization logic. supports() decides whether the voter handles the attribute. voteOnAttribute() returns true/false. Use with IsGranted passing the attribute and the subject.

API Tokens
# config/packages/security.yaml
security:
    firewalls:
        api:
            pattern: ^/api
            stateless: true
            custom_authenticators:
                - App\Security\ApiTokenAuthenticator

// or with LexikJWTAuthenticationBundle:
composer require lexik/jwt-authentication-bundle

stateless: true disables sessions for APIs. custom_authenticators registers your own authenticators. LexikJWT is the standard solution for JWT. Ideal for SPAs and mobile apps.

Protect Routes
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[IsGranted('ROLE_ADMIN')]
public function admin(): Response { }

#[IsGranted('ROLE_USER', statusCode: 403)]
public function profile(): Response { }

# or in security.yaml:
access_control:
    - { path: ^/admin, roles: ROLE_ADMIN }

#[IsGranted] protects individual methods. ROLE_ADMIN requires the admin role. statusCode customizes the error. access_control protects by URL pattern in YAML.

Password Hashing
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

public function register(
    Request $request,
    UserPasswordHasherInterface $hasher,
): Response {
    $user->setPassword(
        $hasher->hashPassword($user, $plainPassword)
    );

    // verify:
    $valid = $hasher->isPasswordValid($user, $input);
}

UserPasswordHasherInterface handles secure hashing. hashPassword() creates a hash with automatic salt. isPasswordValid() compares input with the stored hash. Never store passwords in plain text.

Get the User
// in the controller:
$user = $this->getUser();
$email = $user->getEmail();

// inject Security:
use Symfony\Bundle\SecurityBundle\Security;

public function __construct(private Security $security) {}
$user = $this->security->getUser();

// in Twig:
{{ app.user.email }}
{% if is_granted('ROLE_ADMIN') %}

getUser() returns the authenticated user or null. Injected Security allows checking in services. app.user accesses it in Twig. is_granted() checks permissions.

Remember Me
# config/packages/security.yaml
security:
    firewalls:
        main:
            remember_me:
                secret: '%kernel.secret%'
                lifetime: 604800  # 1 week
                path: /
                secure: true

remember_me keeps the session with a persistent cookie. lifetime sets the duration in seconds. secure: true sends the cookie only over HTTPS. The user needs a checkbox on the login form.

Advanced and Good Practices


10 cards
Cache
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;

public function __construct(private CacheInterface $cache) {}

$data = $this->cache->get('unique_key', function (ItemInterface $item) {
    $item->expiresAfter(3600);
    return $this->expensiveData();
});

CacheInterface is injected automatically. get() returns from the cache or runs the callback. expiresAfter() sets the TTL in seconds. The cache is invalidated automatically upon expiry.

Filesystem
use Symfony\Component\Filesystem\Filesystem;

$fs = new Filesystem();
$fs->mkdir('/tmp/uploads');
$fs->copy('source.txt', 'dest.txt', true);
$fs->remove('/tmp/old');
$fs->dumpFile('config.json', $jsonContent);
$fs->exists('file.txt');

Filesystem abstracts file operations. mkdir() creates directories recursively. dumpFile() writes atomically. remove() deletes files and folders. Cross-platform and safe.

Error Handling
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

throw $this->createNotFoundException('Post not found');
throw new AccessDeniedHttpException('No permission');

// custom error controller:
# config/packages/framework.yaml
framework:
    error_controller: App\Controller\ErrorController::show

createNotFoundException() throws 404. AccessDeniedHttpException throws 403. Symfony converts exceptions into HTTP responses. error_controller customizes error pages.

Serializer
use Symfony\Component\Serializer\SerializerInterface;

$json = $serializer->serialize($post, 'json', [
    'groups' => ['post:read'],
]);

$post = $serializer->deserialize($json, Post::class, 'json');

// with Normalizer:
$data = $serializer->normalize($post, 'json');

SerializerInterface converts objects to JSON/XML and back. groups controls which fields are serialized. normalize() returns an array. Essential for REST APIs.

Logging
use Psr\Log\LoggerInterface;

public function __construct(private LoggerInterface $logger) {}

$this->logger->info('User {id} authenticated', ['id' => $user->getId()]);
$this->logger->warning('Failed attempt from {email}', ['email' => $email]);
$this->logger->error('Payment failure', ['order' => $order->getId()]);

LoggerInterface (PSR-3) is injected automatically. {key} placeholders are replaced by the context. Levels: debug, info, warning, error, critical. Logs in var/log/.

Best Practices
// 1. Thin controllers - logic in Services
// 2. Type-hints everywhere
// 3. DTOs for data input/output
// 4. Events to decouple modules
// 5. Config in YAML, not hardcoded

Keep controllers thin by delegating to services. Use DTOs instead of arrays. Prefer events for cross-cutting logic. Configure everything in YAML with parameters. Test with PHPUnit and WebTestCase.

Mailer
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;

public function __construct(private MailerInterface $mailer) {}

$email = (new Email())
    ->from('noreply@site.com')
    ->to('user@site.com')
    ->subject('Welcome!')
    ->html('<h1>Hello!</h1>')
    ->attachFromPath('/path/file.pdf');

$this->mailer->send($email);

MailerInterface sends emails. Email builds the message with a builder. html() sets the HTML body. attachFromPath() attaches files. Configure the DSN in MAILER_DSN.

Profiler and Debug
// debug bar (dev): /_profiler
php bin/console debug:router
php bin/console debug:container --show-arguments
php bin/console debug:autowiring
php bin/console profiler:info

// in the code:
use Symfony\Component\Stopwatch\Stopwatch;
$stopwatch->start('operation');
// ...
$event = $stopwatch->stop('operation');

The Profiler shows queries, time and memory per request. debug:autowiring lists injectable services. Stopwatch measures operation time. Indispensable for optimization.

HTTP Client
use Symfony\Contracts\HttpClient\HttpClientInterface;

public function __construct(private HttpClientInterface $client) {}

$response = $this->client->request('GET', 'https://api.example.com/data', [
    'headers' => ['Authorization' => 'Bearer token'],
    'timeout' => 10,
]);

$statusCode = $response->getStatusCode();
$data = $response->toArray();

HttpClientInterface makes HTTP requests without cURL. request() accepts method, URL and options. toArray() parses JSON automatically. Supports async, retries and native rate limiting.

Rate Limiting
# config/packages/rate_limiter.yaml
framework:
    rate_limiter:
        api:
            policy: sliding_window
            limit: 100
            interval: '60 minutes'

// in the controller:
use Symfony\Component\RateLimiter\RateLimiterFactory;

$limiter = $factory->create($request->getClientIp());
if (!$limiter->consume()->isAccepted()) {
    return new Response('Too Many Requests', 429);
}

rate_limiter limits requests per IP/user. sliding_window is the fairest policy. consume() checks and records the request. Returns 429 when the limit is exceeded. Essential for APIs.

Events and Console


10 cards
Event Listeners
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;

#[AsEventListener(event: 'kernel.request', priority: 10)]
class LocaleListener
{
    public function __invoke(RequestEvent $event): void
    {
        $request = $event->getRequest();
        $request->setLocale($request->getPreferredLanguage(['pt', 'en']));
    }
}

#[AsEventListener] registers without YAML config. priority controls the execution order (higher = first). The __invoke method receives the event. kernel.request fires before the controller.

Input and Output
protected function configure(): void
{
    $this->addArgument('file', InputArgument::REQUIRED)
         ->addOption('force', 'f', InputOption::VALUE_NONE);
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
    $file = $input->getArgument('file');
    $force = $input->getOption('force');
    $output->writeln("<info>Done!</info>");
}

addArgument() defines positional arguments. addOption() defines optional flags. getArgument() and getOption() read values. Tags like <info> color the output.

Failed Messages
php bin/console messenger:failed:show
php bin/console messenger:failed:retry 1
php bin/console messenger:failed:remove 1

# config/packages/messenger.yaml
framework:
    messenger:
        failure_transport: failed
        transports:
            failed: 'doctrine://default?queue_name=failed'

failure_transport stores messages that fail after retries. failed:show lists failures. failed:retry reprocesses. failed:remove discards. Essential for monitoring.

Event Subscribers
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class LogSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [
            'kernel.request' => 'onRequest',
            'kernel.response' => ['onResponse', -10],
        ];
    }

    public function onRequest(RequestEvent $e): void { }
    public function onResponse(ResponseEvent $e): void { }
}

EventSubscriberInterface allows listening to multiple events in one class. getSubscribedEvents() maps events to methods. The [method, priority] array sets the order. More organized than separate listeners.

Messenger (async)
// src/Message/SendEmailMessage.php
class SendEmailMessage
{
    public function __construct(
        public string $to,
        public string $subject,
    ) {}
}

// in the controller:
$this->messageBus->dispatch(new SendEmailMessage('ana@site.com', 'Hello'));

Messenger sends messages for asynchronous processing. The message is a simple DTO. dispatch() puts it in the queue. Requires transport configuration (Doctrine, RabbitMQ, Redis).

Dispatch Custom Events
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

class OrderService
{
    public function __construct(
        private EventDispatcherInterface $dispatcher,
    ) {}

    public function complete(Order $order): void
    {
        // logic...
        $this->dispatcher->dispatch(new OrderCompletedEvent($order));
    }
}

EventDispatcherInterface emits custom events. Create an event class extending Event. Listeners react to the event without coupling logic. Observer pattern to decouple modules.

Kernel Events
kernel.request      // before the controller
kernel.controller   // controller resolved
kernel.view         // controller returned non-Response
kernel.response     // before sending the response
kernel.terminate    // after sending the response
kernel.exception    // when an exception occurs

Symfony's HTTP lifecycle emits events at each phase. kernel.request for pre-processing. kernel.exception for error handling. kernel.terminate for post-response tasks (emails, logs).

Message Handlers
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
class SendEmailHandler
{
    public function __invoke(SendEmailMessage $message): void
    {
        // send email asynchronously
        $this->mailer->send($message->to, $message->subject);
    }
}
// php bin/console messenger:consume async -vv

#[AsMessageHandler] registers the handler. The __invoke type-hint determines which message it handles. messenger:consume processes the queue. -vv shows detailed logs.

Console Commands
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;

#[AsCommand(name: 'app:clear-cache', description: 'Clears old data')]
class ClearCommand extends Command
{
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $output->writeln('Cache cleared!');
        return Command::SUCCESS;
    }
}

#[AsCommand] registers the command with name and description. execute() contains the logic. Command::SUCCESS returns exit code 0. writeln() prints to the terminal.

Configure Transport
# config/packages/messenger.yaml
framework:
    messenger:
        transports:
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                retry_strategy:
                    max_retries: 3
                    delay: 1000
        routing:
            App\Message\SendEmailMessage: async

transports defines where messages go. routing maps classes to transports. retry_strategy reprocesses failures with delay. The DSN supports Doctrine, AMQP and Redis.