MFormations
Modern PHP Engineering

Chapitre 6

06 — Symfony

06 — Symfony

Cours 06 — Symfony

06.1 Architecture Symfony

Structure d'un projet Symfony

project/
├── config/
│   ├── packages/          # Configuration des bundles
│   ├── routes/            # Définition des routes
│   └── services.yaml      # Configuration des services
├── migrations/            # Doctrine migrations
├── public/
│   └── index.php          # Point d'entrée
├── src/
│   ├── Controller/        # Contrôleurs
│   ├── Entity/            # Doctrine entities
│   ├── Repository/        # Doctrine repositories
│   ├── Service/           # Services métier
│   ├── EventListener/     # Event listeners
│   └── Kernel.php         # Kernel de l'application
├── templates/             # Twig templates
├── translations/          # Fichiers de traduction
├── .env                   # Variables d'environnement
└── composer.json

Kernel et Container

<?php

// src/Kernel.php
namespace App;

use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;

class Kernel extends BaseKernel
{
    use MicroKernelTrait;
}
<?php

// public/index.php
use App\Kernel;
use Symfony\Component\HttpFoundation\Request;

$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
$request = Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);

Service Container (DI)

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

    App\Service\MailerService:
        arguments:
            $sender: '%env(MAILER_SENDER)%'

    App\EventListener\ExceptionListener:
        tags:
            - { name: kernel.event_listener, event: kernel.exception }

    App\Controller\:
        resource: '../src/Controller/'
        tags: ['controller.service_arguments']
<?php

use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

class MailerService
{
    public function __construct(
        #[Autowire(env: 'MAILER_DSN')]
        private readonly string $dsn,

        #[Autowire('%kernel.project_dir%/var/mail.log')]
        private readonly string $logPath,
    ) {}
}

06.2 HttpClient

<?php

namespace App\Service;

use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Component\HttpClient\RetryableHttpClient;
use Symfony\Component\HttpClient\Caching\HttpClient as CachingHttpClient;
use Symfony\Component\HttpKernel\HttpCache\Store;

class ApiService
{
    public function __construct(
        private readonly HttpClientInterface $client,
    ) {}

    public function fetchUsers(): array
    {
        $response = $this->client->request(
            'GET',
            'https://api.example.com/users',
            [
                'headers' => [
                    'Accept' => 'application/json',
                    'Authorization' => 'Bearer ' . $this->getToken(),
                ],
                'query' => [
                    'page' => 1,
                    'limit' => 100,
                ],
                'timeout' => 5,
            ]
        );

        if ($response->getStatusCode() !== 200) {
            throw new \RuntimeException('API error: ' . $response->getContent(false));
        }

        return $response->toArray();
    }

    public function createUser(array $data): array
    {
        $response = $this->client->request(
            'POST',
            'https://api.example.com/users',
            [
                'json' => $data,
            ]
        );

        return $response->toArray();
    }
}

Configuration HttpClient

# config/packages/http_client.yaml
framework:
    http_client:
        default_options:
            timeout: 5
            max_redirects: 3
            headers:
                Accept: 'application/json'

        scoped_clients:
            github.client:
                base_uri: 'https://api.github.com'
                headers:
                    Authorization: 'Bearer %env(GITHUB_TOKEN)%'
                retry_failed:
                    enabled: true
                    retry_strategy: http_client.retry_strategy.generic
                    max_retries: 3

            logging.client:
                base_uri: 'https://logs.example.com'
                extra:
                    trace_level: 'debug'

Retry et Caching

<?php

use Symfony\Component\HttpClient\RetryableHttpClient;
use Symfony\Component\HttpClient\HttpClient;

// Retry automatique (3 tentatives)
$client = new RetryableHttpClient(
    HttpClient::create(),
    maxRetries: 3,
    delay: 1000, // ms
);

// Cache HTTP intégré
use Symfony\Component\HttpClient\Caching\HttpClient as CachingClient;
use Symfony\Component\HttpKernel\HttpCache\Store;

$store = new Store('/path/to/cache');
$client = CachingClient::create(HttpClient::create(), $store);

06.3 Messenger

Configuration

# config/packages/messenger.yaml
framework:
    messenger:
        transports:
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                options:
                    queue_name: high
                retry_strategy:
                    max_retries: 3
                    delay: 1000
                    multiplier: 2

            sync: 'sync://'

        routing:
            App\Message\SendEmailMessage: async
            App\Message\ProcessPaymentMessage: async
            App\Message\GenerateReportMessage: async

Message et Handler

<?php

namespace App\Message;

class SendEmailMessage
{
    public function __construct(
        public readonly string $recipient,
        public readonly string $subject,
        public readonly string $body,
        public readonly array $attachments = [],
    ) {}
}
<?php

namespace App\MessageHandler;

use App\Message\SendEmailMessage;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
class SendEmailHandler
{
    public function __construct(
        private readonly \Swift_Mailer $mailer,
    ) {}

    public function __invoke(SendEmailMessage $message): void
    {
        $email = (new \Swift_Message($message->subject))
            ->setTo($message->recipient)
            ->setBody($message->body);

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

Bus et middleware

<?php

use Symfony\Component\Messenger\MessageBusInterface;

class UserService
{
    public function __construct(
        private readonly MessageBusInterface $bus,
    ) {}

    public function registerUser(array $data): void
    {
        // Envoi synchrone (validation)
        $this->bus->dispatch(new ValidateUserMessage($data));

        // Envoi asynchrone (email, notification)
        $this->bus->dispatch(new SendEmailMessage($data['email'], 'Bienvenue !', '...'));
    }
}

06.4 Serializer

<?php

namespace App\Entity;

use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\MaxDepth;

class User
{
    #[Groups(['user:read', 'user:write'])]
    private int $id;

    #[Groups(['user:read', 'user:write'])]
    private string $name;

    #[Groups(['user:read'])]
    private string $email;

    #[Groups(['user:write'])]
    private string $plainPassword;

    #[Groups(['user:read'])]
    #[MaxDepth(1)]
    private array $posts;

    // Getters/Setters...
}
<?php

use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;

class UserController
{
    public function __construct(
        private readonly SerializerInterface $serializer,
    ) {}

    public function show(User $user): JsonResponse
    {
        $json = $this->serializer->serialize(
            $user,
            'json',
            ['groups' => ['user:read']]
        );

        return new JsonResponse($json, 200, [], true);
    }

    public function update(Request $request, User $user): JsonResponse
    {
        $this->serializer->deserialize(
            $request->getContent(),
            User::class,
            'json',
            [
                'groups' => ['user:write'],
                AbstractNormalizer::OBJECT_TO_POPULATE => $user,
            ]
        );

        // $user est maintenant mis à jour
        return new JsonResponse($user, 200);
    }
}

Normalizers personnalisés

<?php

namespace App\Serializer;

use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

class CarbonNormalizer implements NormalizerInterface
{
    public function normalize(mixed $object, ?string $format = null, array $context = []): string
    {
        return $object->format('c'); // ISO 8601
    }

    public function supportsNormalization(mixed $data, ?string $format = null): bool
    {
        return $data instanceof \Carbon\Carbon;
    }
}

Encoders

# config/packages/framework.yaml
framework:
    serializer:
        enabled: true
        enable_attributes: true
        mapping:
            paths: ['%kernel.project_dir%/config/serialization/']

06.5 Security

Configuration firewall

# config/packages/security.yaml
security:
    password_hashers:
        App\Entity\User: '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
            custom_authenticator: App\Security\LoginFormAuthenticator
            logout:
                path: app_logout
                target: app_login

            remember_me:
                secret: '%kernel.secret%'
                lifetime: 604800
                path: /

            entry_point: App\Security\LoginFormAuthenticator

    access_control:
        - { path: ^/admin, roles: ROLE_ADMIN }
        - { path: ^/profile, roles: ROLE_USER }
        - { path: ^/login, roles: PUBLIC_ACCESS }

Voters

<?php

namespace App\Security\Voter;

use App\Entity\Post;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

class PostVoter extends Voter
{
    const string VIEW = 'post.view';
    const string EDIT = 'post.edit';
    const string DELETE = 'post.delete';

    protected function supports(string $attribute, mixed $subject): bool
    {
        return in_array($attribute, [self::VIEW, self::EDIT, self::DELETE])
            && $subject instanceof Post;
    }

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

        if (!$user instanceof User) {
            return false;
        }

        /** @var Post $post */
        $post = $subject;

        return match ($attribute) {
            self::VIEW => $post->isPublished() || $post->getAuthor() === $user,
            self::EDIT => $post->getAuthor() === $user || in_array('ROLE_ADMIN', $user->getRoles()),
            self::DELETE => $post->getAuthor() === $user || in_array('ROLE_ADMIN', $user->getRoles()),
            default => false,
        };
    }
}

Authenticator personnalisé

<?php

namespace App\Security;

use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
use Symfony\Component\Security\Http\Util\TargetPathTrait;

class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
    use TargetPathTrait;

    public function authenticate(Request $request): Passport
    {
        $email = $request->request->get('email', '');
        $password = $request->request->get('password', '');

        return new Passport(
            new UserBadge($email),
            new PasswordCredentials($password),
            [
                new CsrfTokenBadge('authenticate', $request->request->get('_csrf_token')),
                new RememberMeBadge(),
            ]
        );
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        if ($targetPath = $this->getTargetPath($request, $firewallName)) {
            return new RedirectResponse($targetPath);
        }

        return new RedirectResponse($this->urlGenerator->generate('app_home'));
    }
}

Password Hashing

<?php

use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

class RegistrationService
{
    public function __construct(
        private readonly UserPasswordHasherInterface $passwordHasher,
    ) {}

    public function register(array $data): User
    {
        $user = new User();
        $user->setEmail($data['email']);
        $user->setPassword(
            $this->passwordHasher->hashPassword($user, $data['plainPassword'])
        );

        return $user;
    }
}

06.6 Forms

Form Type

<?php

namespace App\Form;

use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;

class RegistrationFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('name', null, [
                'constraints' => [
                    new NotBlank(['message' => 'Le nom est requis']),
                    new Length(['min' => 2, 'max' => 255]),
                ],
            ])
            ->add('email', EmailType::class, [
                'constraints' => [
                    new NotBlank(),
                ],
            ])
            ->add('plainPassword', RepeatedType::class, [
                'type' => PasswordType::class,
                'first_options' => ['label' => 'Mot de passe'],
                'second_options' => ['label' => 'Confirmer le mot de passe'],
                'constraints' => [
                    new NotBlank(),
                    new Length(['min' => 8]),
                ],
            ])
            ->add('submit', SubmitType::class, [
                'label' => 'S\'inscrire',
            ]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => User::class,
            'csrf_protection' => true,
            'csrf_field_name' => '_csrf_token',
        ]);
    }
}

Controller

<?php

namespace App\Controller;

use App\Form\RegistrationFormType;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class RegistrationController extends AbstractController
{
    public function register(Request $request, EntityManagerInterface $em): Response
    {
        $user = new User();
        $form = $this->createForm(RegistrationFormType::class, $user);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $em->persist($user);
            $em->flush();

            $this->addFlash('success', 'Compte créé avec succès !');

            return $this->redirectToRoute('app_home');
        }

        return $this->render('registration/register.html.twig', [
            'form' => $form->createView(),
        ]);
    }
}

06.7 MakerBundle et Symfony Flex

MakerBundle

# Créer un contrôleur
php bin/console make:controller UserController

# Créer une entité
php bin/console make:entity User

# Créer un formulaire
php bin/console make:form RegistrationFormType

# Créer un CRUD complet
php bin/console make:crud User

# Créer une commande
php bin/console make:command app:generate-report

# Créer un subscriber
php bin/console make:subscriber ExceptionSubscriber

# Créer un Voter
php bin/console make:voter PostVoter

# Créer un authenticator
php bin/console make:auth LoginFormAuthenticator

Symfony Flex

Flex est un gestionnaire de recettes Composer pour Symfony. Il automatise la configuration des bundles.

# Flex est activé par défaut dans Symfony 5.4+
composer create-project symfony/skeleton:"7.0.*" my_project

# Ajouter un bundle = configuration automatique
composer require doctrine/orm      # Configure Doctrine
composer require api-platform      # Configure API Platform
composer require mailer            # Configure Mailer
composer require debug --dev       # Configure Debug bar

# Recettes personnalisées
composer config extra.symfony.endpoint \
    'https://raw.githubusercontent.com/symfony/recipes/flex/main/index.json'

Exercices

  1. Créez un contrôleur API REST avec HttpClient pour consommer une API externe
  2. Implémentez un Messenger Handler pour l'envoi d'email asynchrone
  3. Créez un Voter personnalisé pour la gestion des permissions
  4. Générez un CRUD complet avec MakerBundle
  5. Sécurisez une route avec firewall et authentification

Références

  • symfony.com/doc/current/index.html
  • symfony.com/doc/current/components/index.html
  • symfony.com/doc/current/best_practices.html