Cheatsheet Symfony
Framework PHP robusto e maduro para aplicações web enterprise
Symfony
Installation and Setup
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: trueEach bundle has its file in config/packages/. The %env()% syntax reads environment variables. Configurations are merged automatically per environment.
Routes and Controllers
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
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
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:loadFixtures 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
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
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
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_loginpassword_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-bundlestateless: 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: trueremember_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
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::showcreateNotFoundException() 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
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: asynctransports defines where messages go. routing maps classes to transports. retry_strategy reprocesses failures with delay. The DSN supports Doctrine, AMQP and Redis.