Getting started
Installation
# Create new project (webapp with essential dependencies)
symfony new my_project --version="8.0.*" --webapp
# Create minimal project (microservice, API)
symfony new my_project --version="8.0.*"
# Require Symfony CLI
wget https://get.symfony.com/cli/installer -O - | bash
Project structure
my_project/
├── bin/
│ └── console # CLI entry point
├── config/ # Configuration files
│ ├── packages/ # Package configs
│ ├── routes.yaml # Routes
│ └── services.yaml # Services/DI
├── migrations/ # Database migrations
├── public/
│ └── index.php # Web entry point
├── src/
│ ├── Controller/ # Controllers
│ ├── Entity/ # Doctrine entities
│ ├── Repository/ # Entity repositories
│ └── Kernel.php
├── templates/ # Twig templates
├── var/ # Cache, logs
└── composer.json
Quick example
// src/Controller/HelloController.php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class HelloController extends AbstractController
{
#[Route('/hello/{name}', name: 'app_hello')]
public function index(string $name): Response
{
return $this->render('hello/index.html.twig', [
'name' => $name,
]);
}
}
Console commands
Basic commands
# Run dev server
symfony serve
# List all commands
php bin/console list
# Clear cache
php bin/console cache:clear
# Check configuration
php bin/console debug:config
# Check environment variables
php bin/console debug:dotenv
Maker commands
# Create controller
php bin/console make:controller UserController
# Create entity
php bin/console make:entity Product
# Create form
php bin/console make:form ProductType
# Create CRUD
php bin/console make:crud Product
# Create migration
php bin/console make:migration
# Create command
php bin/console make:command app:import-users
# Create subscriber
php bin/console make:subscriber UserSubscriber
Doctrine commands
# Create database
php bin/console doctrine:database:create
# Drop database
php bin/console doctrine:database:drop --force
# Run migrations
php bin/console doctrine:migrations:migrate
# Generate migration from entities
php bin/console make:migration
# Load fixtures
php bin/console doctrine:fixtures:load
# Validate schema
php bin/console doctrine:schema:validate
# Update schema (dev only!)
php bin/console doctrine:schema:update --force
Routing
Attribute routes
use Symfony\Component\Routing\Attribute\Route;
#[Route('/blog')]
class BlogController extends AbstractController
{
// GET /blog
#[Route('', name: 'blog_list')]
public function list(): Response { }
// GET /blog/123
#[Route('/{id}', name: 'blog_show', requirements: ['id' => '\d+'])]
public function show(int $id): Response { }
// POST /blog/create
#[Route('/create', name: 'blog_create', methods: ['POST'])]
public function create(): Response { }
// Multiple methods
#[Route('/edit/{id}', methods: ['GET', 'POST'])]
public function edit(int $id): Response { }
}
YAML routes
# config/routes.yaml
blog_list:
path: /blog
controller: App\Controller\BlogController::list
blog_show:
path: /blog/{id}
controller: App\Controller\BlogController::show
requirements:
id: '\d+'
methods: GET
# Import controller routes
controllers:
resource: ../src/Controller/
type: attribute
Route parameters
// Required parameter
#[Route('/user/{id}')]
public function show(int $id): Response { }
// Optional parameter
#[Route('/blog/{page}')]
public function list(int $page = 1): Response { }
// Parameter with requirement
#[Route('/article/{slug}', requirements: ['slug' => '[a-z0-9-]+'])]
public function article(string $slug): Response { }
// Priority (lower = first)
#[Route('/blog/latest', priority: 10)]
public function latest(): Response { }
Generating URLs
// In controller
$url = $this->generateUrl('blog_show', ['id' => 123]);
// With absolute URL
$url = $this->generateUrl('blog_show', ['id' => 123], UrlGeneratorInterface::ABSOLUTE_URL);
// Redirect
return $this->redirectToRoute('blog_list');
return $this->redirectToRoute('blog_show', ['id' => 123], 301);
{# In Twig template #}
<a href="{{ path('blog_show', {id: 123}) }}">Read more</a>
{# Absolute URL #}
<a href="{{ url('blog_show', {id: 123}) }}">Share</a>
Controllers
Basic controller
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class ProductController extends AbstractController
{
#[Route('/product/{id}', name: 'product_show')]
public function show(int $id): Response
{
// Return response
return new Response('Product ID: ' . $id);
// Render template
return $this->render('product/show.html.twig', [
'id' => $id,
]);
// Return JSON
return $this->json(['id' => $id, 'name' => 'Product']);
}
}
Request/Response
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\JsonResponse;
#[Route('/example')]
public function example(Request $request): Response
{
// Query parameters (?foo=bar)
$foo = $request->query->get('foo');
// POST data
$bar = $request->request->get('bar');
// Headers
$auth = $request->headers->get('Authorization');
// Cookies
$token = $request->cookies->get('token');
// JSON body
$data = $request->toArray();
// Response types
return new Response('text');
return new JsonResponse(['key' => 'value']);
return $this->json(['key' => 'value']);
return $this->file('/path/to/file.pdf');
}
Flash messages
// Add flash message
$this->addFlash('success', 'Product created!');
$this->addFlash('error', 'Something went wrong');
$this->addFlash('warning', 'Please review');
$this->addFlash('info', 'FYI');
// In template
{% for message in app.flashes('success') %}
<div class="alert alert-success">{{ message }}</div>
{% endfor %}
Controller helpers
// Render template
return $this->render('template.html.twig', $vars);
// Redirect
return $this->redirectToRoute('route_name', $params);
return $this->redirect('/some/path');
// 404 Error
throw $this->createNotFoundException('Not found');
// 403 Access Denied
throw $this->createAccessDeniedException('Access denied');
// Get service
$service = $this->container->get('service_id');
// Check authorization
$this->denyAccessUnlessGranted('ROLE_ADMIN');
Doctrine ORM
Entity definition
// src/Entity/Product.php
namespace App\Entity;
use App\Repository\ProductRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ProductRepository::class)]
class Product
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $name = null;
#[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
private ?string $price = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $description = null;
// Getters and setters
public function getId(): ?int { return $this->id; }
public function getName(): ?string { return $this->name; }
public function setName(string $name): self {
$this->name = $name;
return $this;
}
}
Column types
#[ORM\Column(type: 'string', length: 255)]
private ?string $name = null;
#[ORM\Column(type: 'text')]
private ?string $description = null;
#[ORM\Column(type: 'integer')]
private ?int $quantity = null;
#[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
private ?string $price = null;
#[ORM\Column(type: 'boolean')]
private ?bool $active = null;
#[ORM\Column(type: 'datetime')]
private ?\DateTimeInterface $createdAt = null;
#[ORM\Column(type: 'json')]
private array $options = [];
#[ORM\Column(nullable: true)]
private ?string $optional = null;
Relationships
// ManyToOne
#[ORM\ManyToOne(targetEntity: Category::class, inversedBy: 'products')]
#[ORM\JoinColumn(nullable: false)]
private ?Category $category = null;
// OneToMany
#[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'product', cascade: ['persist', 'remove'])]
private Collection $comments;
public function __construct() {
$this->comments = new ArrayCollection();
}
// ManyToMany
#[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'products')]
private Collection $tags;
// OneToOne
#[ORM\OneToOne(targetEntity: Profile::class, cascade: ['persist', 'remove'])]
private ?Profile $profile = null;
Entity operations
use Doctrine\ORM\EntityManagerInterface;
class ProductController extends AbstractController
{
// Create
#[Route('/product/create')]
public function create(EntityManagerInterface $em): Response
{
$product = new Product();
$product->setName('Widget');
$product->setPrice('19.99');
$em->persist($product);
$em->flush();
return new Response('Created product id ' . $product->getId());
}
// Read
#[Route('/product/{id}')]
public function show(int $id, EntityManagerInterface $em): Response
{
$product = $em->getRepository(Product::class)->find($id);
if (!$product) {
throw $this->createNotFoundException('Product not found');
}
return $this->render('product/show.html.twig', [
'product' => $product
]);
}
// Update
#[Route('/product/{id}/edit')]
public function edit(Product $product, EntityManagerInterface $em): Response
{
$product->setName('Updated name');
$em->flush();
return $this->redirectToRoute('product_show', ['id' => $product->getId()]);
}
// Delete
#[Route('/product/{id}/delete')]
public function delete(Product $product, EntityManagerInterface $em): Response
{
$em->remove($product);
$em->flush();
return $this->redirectToRoute('product_list');
}
}
Repository queries
// src/Repository/ProductRepository.php
namespace App\Repository;
use App\Entity\Product;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ProductRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Product::class);
}
// Custom query methods
public function findByPriceRange(float $min, float $max): array
{
return $this->createQueryBuilder('p')
->andWhere('p.price >= :min')
->andWhere('p.price <= :max')
->setParameter('min', $min)
->setParameter('max', $max)
->orderBy('p.price', 'ASC')
->getQuery()
->getResult();
}
public function findActiveProducts(): array
{
return $this->findBy(['active' => true], ['name' => 'ASC']);
}
// DQL query
public function findByCategory(string $category): array
{
$qb = $this->createQueryBuilder('p')
->join('p.category', 'c')
->where('c.name = :category')
->setParameter('category', $category);
return $qb->getQuery()->getResult();
}
}
Query Builder
$qb = $em->createQueryBuilder();
// Select
$qb->select('p')
->from(Product::class, 'p');
// Where conditions
$qb->where('p.active = :active')
->setParameter('active', true);
$qb->andWhere('p.price > :price')
->setParameter('price', 10);
$qb->orWhere('p.featured = true');
// Joins
$qb->join('p.category', 'c')
->addSelect('c');
$qb->leftJoin('p.tags', 't')
->addSelect('t');
// Order and limit
$qb->orderBy('p.createdAt', 'DESC')
->setMaxResults(10)
->setFirstResult(0);
// Execute
$products = $qb->getQuery()->getResult();
$product = $qb->getQuery()->getOneOrNullResult();
$count = $qb->select('COUNT(p.id)')->getQuery()->getSingleScalarResult();
Forms
Form type
// src/Form/ProductType.php
namespace App\Form;
use App\Entity\Product;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ProductType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, [
'label' => 'Product Name',
'attr' => ['placeholder' => 'Enter name']
])
->add('price', MoneyType::class)
->add('description', TextareaType::class, [
'required' => false
])
->add('submit', SubmitType::class, [
'label' => 'Save Product'
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Product::class,
]);
}
}
Form handling
use App\Form\ProductType;
use Symfony\Component\HttpFoundation\Request;
#[Route('/product/new', name: 'product_new')]
public function new(Request $request, EntityManagerInterface $em): Response
{
$product = new Product();
$form = $this->createForm(ProductType::class, $product);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->persist($product);
$em->flush();
$this->addFlash('success', 'Product created!');
return $this->redirectToRoute('product_show', ['id' => $product->getId()]);
}
return $this->render('product/new.html.twig', [
'form' => $form,
]);
}
Form rendering
{# templates/product/new.html.twig #}
{{ form_start(form) }}
{{ form_row(form.name) }}
{{ form_row(form.price) }}
{{ form_row(form.description) }}
{{ form_row(form.submit) }}
{{ form_end(form) }}
{# Custom rendering #}
{{ form_start(form) }}
<div class="form-group">
{{ form_label(form.name) }}
{{ form_widget(form.name, {'attr': {'class': 'form-control'}}) }}
{{ form_errors(form.name) }}
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
Form field types
use Symfony\Component\Form\Extension\Core\Type\*;
// Text inputs
->add('name', TextType::class)
->add('email', EmailType::class)
->add('password', PasswordType::class)
->add('description', TextareaType::class)
// Numbers
->add('age', IntegerType::class)
->add('price', MoneyType::class, ['currency' => 'USD'])
->add('percentage', PercentType::class)
// Choices
->add('category', ChoiceType::class, [
'choices' => [
'Electronics' => 'electronics',
'Books' => 'books',
]
])
->add('category', EntityType::class, [
'class' => Category::class,
'choice_label' => 'name',
])
// Dates
->add('publishedAt', DateType::class)
->add('publishedAt', DateTimeType::class)
// Boolean
->add('featured', CheckboxType::class, ['required' => false])
// File upload
->add('attachment', FileType::class, ['required' => false])
// Collections
->add('tags', CollectionType::class, [
'entry_type' => TextType::class,
'allow_add' => true,
])
Validation
Entity constraints
namespace App\Entity;
use Symfony\Component\Validator\Constraints as Assert;
class Product
{
#[Assert\NotBlank(message: 'Name is required')]
#[Assert\Length(min: 3, max: 255)]
private ?string $name = null;
#[Assert\NotBlank]
#[Assert\Positive]
private ?float $price = null;
#[Assert\Email(message: 'Invalid email')]
private ?string $email = null;
#[Assert\Url]
private ?string $website = null;
#[Assert\Regex(
pattern: '/^[a-zA-Z0-9-]+$/',
message: 'Slug can only contain letters, numbers and hyphens'
)]
private ?string $slug = null;
}
Common constraints
use Symfony\Component\Validator\Constraints as Assert;
// String
#[Assert\NotBlank]
#[Assert\Length(min: 3, max: 255)]
#[Assert\Email]
#[Assert\Url]
#[Assert\Regex(pattern: '/^\d{3}-\d{4}$/')]
// Numbers
#[Assert\Positive]
#[Assert\PositiveOrZero]
#[Assert\Negative]
#[Assert\Range(min: 1, max: 100)]
// Comparison
#[Assert\EqualTo(value: 5)]
#[Assert\GreaterThan(value: 0)]
#[Assert\LessThanOrEqual(value: 100)]
// Date
#[Assert\Date]
#[Assert\DateTime]
#[Assert\GreaterThan('today')]
#[Assert\LessThan('+1 year')]
// Choice
#[Assert\Choice(choices: ['small', 'medium', 'large'])]
#[Assert\Choice(callback: 'getAvailableSizes')]
// Collection
#[Assert\Count(min: 1, max: 10)]
#[Assert\Unique]
// File
#[Assert\File(maxSize: '1024k', mimeTypes: ['application/pdf'])]
#[Assert\Image(maxWidth: 1200, maxHeight: 800)]
// Other
#[Assert\Isbn]
#[Assert\Json]
#[Assert\Uuid]
#[Assert\IsFalse]
#[Assert\IsTrue]
Manual validation
use Symfony\Component\Validator\Validator\ValidatorInterface;
public function validate(ValidatorInterface $validator): Response
{
$product = new Product();
$product->setName('X'); // Too short
$errors = $validator->validate($product);
if (count($errors) > 0) {
foreach ($errors as $error) {
echo $error->getPropertyPath() . ': ' . $error->getMessage();
}
}
return new Response('Validation complete');
}
Custom constraint
// src/Validator/Constraints/ValidSlug.php
namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
#[\Attribute]
class ValidSlug extends Constraint
{
public string $message = 'The slug "{{ slug }}" is not valid.';
}
// src/Validator/Constraints/ValidSlugValidator.php
namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
class ValidSlugValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint): void
{
if (null === $value || '' === $value) {
return;
}
if (!preg_match('/^[a-z0-9-]+$/', $value)) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ slug }}', $value)
->addViolation();
}
}
}
// Usage
#[ValidSlug]
private ?string $slug = null;
Twig templates
Template syntax
{# Variables #}
{{ variable }}
{{ object.property }}
{{ array.key }}
{{ object.method() }}
{# Filters #}
{{ name|upper }}
{{ price|number_format(2) }}
{{ date|date('Y-m-d') }}
{{ content|raw }}
{{ text|escape }}
{{ list|join(', ') }}
{# Functions #}
{{ path('route_name', {id: 123}) }}
{{ url('route_name') }}
{{ asset('images/logo.png') }}
{{ dump(variable) }}
{# Control structures #}
{% if condition %}
...
{% elseif other %}
...
{% else %}
...
{% endif %}
{% for item in items %}
{{ loop.index }}: {{ item.name }}
{% else %}
No items
{% endfor %}
Template inheritance
{# templates/base.html.twig #}
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}My App{% endblock %}</title>
{% block stylesheets %}{% endblock %}
</head>
<body>
<header>{% block header %}Default header{% endblock %}</header>
<main>
{% block body %}{% endblock %}
</main>
<footer>{% block footer %}{% endblock %}</footer>
{% block javascripts %}{% endblock %}
</body>
</html>
{# templates/product/show.html.twig #}
{% extends 'base.html.twig' %}
{% block title %}{{ product.name }}{% endblock %}
{% block body %}
<h1>{{ product.name }}</h1>
<p>{{ product.description }}</p>
{% endblock %}
Template includes
{# Include template #}
{% include 'partials/header.html.twig' %}
{# Include with variables #}
{% include 'product/card.html.twig' with {product: item} %}
{# Include only if exists #}
{% include 'sidebar.html.twig' ignore missing %}
{# Embed (include + extend) #}
{% embed 'modal.html.twig' %}
{% block content %}
<p>Modal content here</p>
{% endblock %}
{% endembed %}
Common patterns
{# Flash messages #}
{% for label, messages in app.flashes %}
{% for message in messages %}
<div class="alert alert-{{ label }}">
{{ message }}
</div>
{% endfor %}
{% endfor %}
{# Forms #}
{{ form_start(form) }}
{{ form_row(form.name) }}
{{ form_row(form.email) }}
{{ form_row(form.submit) }}
{{ form_end(form) }}
{# Pagination #}
{% for item in items %}
{{ item.name }}
{% endfor %}
<nav>
{% if page > 1 %}
<a href="{{ path('list', {page: page - 1}) }}">Previous</a>
{% endif %}
{% if page < totalPages %}
<a href="{{ path('list', {page: page + 1}) }}">Next</a>
{% endif %}
</nav>
{# User info #}
{% if app.user %}
Welcome, {{ app.user.username }}!
<a href="{{ path('logout') }}">Logout</a>
{% else %}
<a href="{{ path('login') }}">Login</a>
{% endif %}
{# Assets #}
<link rel="stylesheet" href="{{ asset('css/app.css') }}">
<script src="{{ asset('js/app.js') }}"></script>
<img src="{{ asset('images/logo.png') }}" alt="Logo">
Services & DI
Service autowiring
// Services are autowired by default
namespace App\Service;
class EmailService
{
public function __construct(
private MailerInterface $mailer,
private string $fromEmail
) {}
public function sendWelcome(string $to): void
{
$email = (new Email())
->from($this->fromEmail)
->to($to)
->subject('Welcome!')
->text('Welcome to our app!');
$this->mailer->send($email);
}
}
// Use in controller
class UserController extends AbstractController
{
public function __construct(
private EmailService $emailService
) {}
#[Route('/register')]
public function register(): Response
{
$this->emailService->sendWelcome('user@example.com');
// ...
}
}
Service configuration
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: "../src/"
exclude:
- "../src/DependencyInjection/"
- "../src/Entity/"
- "../src/Kernel.php"
# Explicit service
App\Service\EmailService:
arguments:
$fromEmail: "%env(FROM_EMAIL)%"
# Alias
App\Service\EmailServiceInterface:
alias: App\Service\EmailService
# Public service
App\Service\ReportGenerator:
public: true
# Tagged service
App\EventListener\ExceptionListener:
tags:
- { name: kernel.event_listener, event: kernel.exception }
Service locator
use Symfony\Component\DependencyInjection\Attribute\AutowireLocator;
class MyService
{
public function __construct(
#[AutowireLocator([
'service1' => Service1::class,
'service2' => Service2::class,
])]
private ContainerInterface $locator
) {}
public function doSomething(): void
{
$service = $this->locator->get('service1');
}
}
Tagged services
# config/services.yaml
App\Handler\:
resource: "../src/Handler"
tags: ["app.handler"]
# Use tagged services
services:
App\HandlerChain:
arguments:
- !tagged_iterator app.handler
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
class HandlerChain
{
public function __construct(
#[TaggedIterator('app.handler')]
private iterable $handlers
) {}
public function process(): void
{
foreach ($this->handlers as $handler) {
$handler->handle();
}
}
}
Configuration
Environment files
# .env (default values)
APP_ENV=dev
APP_SECRET=changeme
DATABASE_URL="mysql://user:pass@127.0.0.1:3306/dbname"
# .env.local (local overrides, gitignored)
DATABASE_URL="mysql://root:root@localhost:3306/mydb"
# .env.prod (production defaults)
APP_ENV=prod
Config files
# config/packages/framework.yaml
framework:
secret: "%env(APP_SECRET)%"
session:
handler_id: null
cookie_secure: auto
cookie_samesite: lax
http_method_override: false
php_errors:
log: true
# config/packages/doctrine.yaml
doctrine:
dbal:
url: "%env(resolve:DATABASE_URL)%"
orm:
auto_generate_proxy_classes: true
enable_lazy_ghost_objects: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true
mappings:
App:
type: attribute
is_bundle: false
dir: "%kernel.project_dir%/src/Entity"
prefix: 'App\Entity'
Environment-specific
# config/packages/dev/monolog.yaml (dev only)
monolog:
handlers:
main:
type: stream
path: '%kernel.logs_dir%/%kernel.environment%.log'
level: debug
# config/packages/prod/monolog.yaml (prod only)
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
nested:
type: stream
path: php://stderr
level: debug
Parameters
# config/services.yaml
parameters:
app.upload_dir: "%kernel.project_dir%/public/uploads"
app.max_file_size: 5242880
services:
App\Service\FileUploader:
arguments:
$uploadDir: "%app.upload_dir%"
$maxSize: "%app.max_file_size%"
// Use in service
public function __construct(
private string $uploadDir,
private int $maxSize
) {}
// Use in controller
$uploadDir = $this->getParameter('app.upload_dir');
Security
User entity
// src/Entity/User.php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[ORM\Entity]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 180, unique: true)]
private ?string $email = null;
#[ORM\Column]
private array $roles = [];
#[ORM\Column]
private ?string $password = null;
public function getUserIdentifier(): string
{
return (string) $this->email;
}
public function getRoles(): array
{
$roles = $this->roles;
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
public function getPassword(): string
{
return $this->password;
}
public function eraseCredentials(): void
{
// Clear temporary sensitive data
}
}
Security config
# config/packages/security.yaml
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: "auto"
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_user_provider
form_login:
login_path: login
check_path: login
logout:
path: logout
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/profile, roles: ROLE_USER }
Authentication
// Hash password
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
public function register(
UserPasswordHasherInterface $passwordHasher
): Response {
$user = new User();
$user->setEmail('user@example.com');
$hashedPassword = $passwordHasher->hashPassword(
$user,
'plain_password'
);
$user->setPassword($hashedPassword);
// Save user...
}
// Login controller
#[Route('/login', name: 'login')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
$error = $authenticationUtils->getLastAuthenticationError();
$lastUsername = $authenticationUtils->getLastUsername();
return $this->render('security/login.html.twig', [
'last_username' => $lastUsername,
'error' => $error,
]);
}
// Logout
#[Route('/logout', name: 'logout')]
public function logout(): void
{
throw new \LogicException('This method can be blank');
}
Authorization
// In controller
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/admin')]
#[IsGranted('ROLE_ADMIN')]
class AdminController extends AbstractController
{
#[Route('/users')]
public function users(): Response
{
// Only accessible to ROLE_ADMIN
}
#[Route('/edit/{id}')]
public function edit(User $user): Response
{
// Check in method
$this->denyAccessUnlessGranted('ROLE_ADMIN');
// Or throw manually
if (!$this->isGranted('ROLE_ADMIN')) {
throw $this->createAccessDeniedException();
}
}
}
// In Twig
{% if is_granted('ROLE_ADMIN') %}
<a href="{{ path('admin_panel') }}">Admin</a>
{% endif %}
Common tasks
Logging
use Psr\Log\LoggerInterface;
class MyService
{
public function __construct(
private LoggerInterface $logger
) {}
public function process(): void
{
$this->logger->info('Processing started');
$this->logger->debug('Debug info', ['data' => $data]);
$this->logger->warning('Warning message');
$this->logger->error('Error occurred', ['exception' => $e]);
$this->logger->critical('Critical issue');
}
}
// In controller
$this->logger->info('User logged in', ['user' => $user->getId()]);
Sessions
use Symfony\Component\HttpFoundation\Session\SessionInterface;
public function example(SessionInterface $session): Response
{
// Set
$session->set('key', 'value');
// Get
$value = $session->get('key');
$default = $session->get('missing', 'default');
// Check
if ($session->has('key')) { }
// Remove
$session->remove('key');
// Clear all
$session->clear();
// Get all
$all = $session->all();
}
File uploads
use Symfony\Component\HttpFoundation\File\UploadedFile;
#[Route('/upload', methods: ['POST'])]
public function upload(Request $request): Response
{
/** @var UploadedFile $file */
$file = $request->files->get('attachment');
if ($file) {
$originalFilename = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
$safeFilename = transliterator_transliterate('Any-Latin; Latin-ASCII; [^A-Za-z0-9_] remove; Lower()', $originalFilename);
$newFilename = $safeFilename . '-' . uniqid() . '.' . $file->guessExtension();
$file->move(
$this->getParameter('upload_directory'),
$newFilename
);
}
return $this->redirectToRoute('upload_success');
}
Events
// Subscribe to event
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
class RequestSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => 'onKernelRequest',
];
}
public function onKernelRequest(RequestEvent $event): void
{
// Handle request event
}
}
// Dispatch custom event
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
class MyService
{
public function __construct(
private EventDispatcherInterface $dispatcher
) {}
public function doSomething(): void
{
$event = new CustomEvent($data);
$this->dispatcher->dispatch($event, 'custom.event');
}
}
Cache
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class StatsService
{
public function __construct(
private CacheInterface $cache
) {}
public function getStats(): array
{
return $this->cache->get('stats', function (ItemInterface $item) {
$item->expiresAfter(3600);
// Expensive computation
return $this->computeStats();
});
}
public function clearStats(): void
{
$this->cache->delete('stats');
}
}
HTTP Client
use Symfony\Contracts\HttpClient\HttpClientInterface;
class ApiService
{
public function __construct(
private HttpClientInterface $client
) {}
public function fetchData(): array
{
$response = $this->client->request('GET', 'https://api.example.com/data', [
'headers' => [
'Authorization' => 'Bearer ' . $token,
],
'query' => [
'limit' => 10,
],
]);
return $response->toArray();
}
public function postData(array $data): void
{
$response = $this->client->request('POST', 'https://api.example.com/resource', [
'json' => $data,
]);
$statusCode = $response->getStatusCode();
}
}
Testing
Unit test
// tests/Service/CalculatorTest.php
namespace App\Tests\Service;
use App\Service\Calculator;
use PHPUnit\Framework\TestCase;
class CalculatorTest extends TestCase
{
public function testAdd(): void
{
$calculator = new Calculator();
$result = $calculator->add(2, 3);
$this->assertSame(5, $result);
}
public function testDivideByZero(): void
{
$this->expectException(\InvalidArgumentException::class);
$calculator = new Calculator();
$calculator->divide(10, 0);
}
}
Functional test
// tests/Controller/ProductControllerTest.php
namespace App\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class ProductControllerTest extends WebTestCase
{
public function testIndex(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/products');
$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('h1', 'Products');
$this->assertCount(10, $crawler->filter('.product-item'));
}
public function testCreate(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/product/new');
$form = $crawler->selectButton('Save')->form([
'product[name]' => 'Test Product',
'product[price]' => '19.99',
]);
$client->submit($form);
$this->assertResponseRedirects('/products');
$client->followRedirect();
$this->assertSelectorExists('.alert-success');
}
}
Repository test
namespace App\Tests\Repository;
use App\Entity\Product;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
class ProductRepositoryTest extends KernelTestCase
{
private EntityManagerInterface $em;
private ProductRepository $repository;
protected function setUp(): void
{
$kernel = self::bootKernel();
$this->em = $kernel->getContainer()
->get('doctrine')
->getManager();
$this->repository = $this->em->getRepository(Product::class);
}
public function testCount(): void
{
$count = $this->repository->count([]);
$this->assertGreaterThan(0, $count);
}
public function testFind(): void
{
$product = $this->repository->find(1);
$this->assertInstanceOf(Product::class, $product);
}
}
Gotchas
Async params in Next.js style
// Symfony 8.0 params are NOT promises (unlike Next.js 15+)
// This is correct:
public function show(int $id): Response { }
// NOT this (Next.js style):
public function show(Promise $params): Response {
$id = await $params['id']; // WRONG
}
Entity serialization
// Don't return entities directly in JSON responses
// BAD:
return $this->json($product); // Causes circular references
// GOOD: Use serializer groups
use Symfony\Component\Serializer\Annotation\Groups;
class Product {
#[Groups(['product:read'])]
private ?string $name = null;
}
return $this->json($product, 200, [], ['groups' => 'product:read']);
Doctrine relationships lazy loading
// Accessing relationships in templates can cause N+1 queries
// BAD:
{% for product in products %}
{{ product.category.name }} {# Triggers query per product #}
{% endfor %}
// GOOD: Use JOIN in repository
$qb->select('p', 'c')
->from(Product::class, 'p')
->join('p.category', 'c');
Form validation
// Always check isValid() after handleRequest()
$form->handleRequest($request);
// BAD:
if ($form->isSubmitted()) {
$em->persist($entity); // Might persist invalid data
}
// GOOD:
if ($form->isSubmitted() && $form->isValid()) {
$em->persist($entity);
}
Cache clearing
# Always clear cache after config changes
php bin/console cache:clear
# In production, use --env=prod
php bin/console cache:clear --env=prod --no-debug
# Warmup after clearing
php bin/console cache:warmup
Also see
- Symfony Documentation - Official docs
- Symfony Best Practices - Recommended patterns
- Doctrine ORM Documentation - Database layer
- Twig Documentation - Template engine
- SymfonyCasts - Video tutorials
- Symfony CLI - Local dev server
- API Platform - REST/GraphQL API framework built on Symfony