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
- Créez un contrôleur API REST avec HttpClient pour consommer une API externe
- Implémentez un Messenger Handler pour l'envoi d'email asynchrone
- Créez un Voter personnalisé pour la gestion des permissions
- Générez un CRUD complet avec MakerBundle
- 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