NexusCS

Symfony

PHP
Quick reference for Symfony 8.0 - PHP web application framework with MVC architecture, Doctrine ORM, Twig templating, and powerful CLI tools.
php
framework
web
mvc
orm
doctrine

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