Architecture orientée services
Symfony est fondé sur l'Inversion of Control. Le container DI instancie et câble automatiquement tous vos services — vous déclarez les dépendances, Symfony les injecte.
Services & Dependency Injection
# config/services.yaml
services:
_defaults:
autowire: true # injection auto par type-hint
autoconfigure: true # tags auto (EventListener, Command…)
public: false # services privés par défaut (optimisation)
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
# Service explicite avec paramètre
App\Service\MailerService:
arguments:
$from: '%env(MAILER_FROM)%'
# Alias d'interface
App\Contract\PaymentInterface: '@App\Service\StripePaymentService'
<?php
// Service simple — auto-déclaré via autowire
namespace App\Service;
class ArticleService
{
public function __construct(
private readonly ArticleRepository $articleRepo,
private readonly EntityManagerInterface $em,
private readonly LoggerInterface $logger,
private readonly string $uploadDir, // paramètre
) {}
public function publish(Article $article): void
{
if ($article->isPublished()) {
throw new \LogicException('Article already published');
}
$article->setPublished(true);
$article->setPublishedAt(new \DateTimeImmutable());
$this->em->flush();
$this->logger->info('Article published', ['id' => $article->getId()]);
}
}
Injection avancée
#[Autowire] — injection explicite
<?php
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class MyService
{
public function __construct(
// Paramètre de config
#[Autowire('%kernel.project_dir%/var/uploads')]
private readonly string $uploadDir,
// Paramètre d'env
#[Autowire(env: 'MAILER_FROM')]
private readonly string $mailerFrom,
// Service nommé
#[Autowire(service: 'cache.app')]
private readonly CacheInterface $cache,
) {}
}
Tagged Services — patterns décorateur
<?php
// Interface tagger
interface ReportGeneratorInterface
{
public function generate(array $data): string;
public function supports(string $format): bool;
}
// Service qui collecte tous les generators
class ReportManager
{
/** @param iterable<ReportGeneratorInterface> $generators */
public function __construct(
#[TaggedIterator('app.report_generator')]
private readonly iterable $generators,
) {}
public function generate(string $format, array $data): string
{
foreach ($this->generators as $gen) {
if ($gen->supports($format)) return $gen->generate($data);
}
throw new \InvalidArgumentException("Format '$format' non supporté");
}
}
// Implémentation — auto-taggée via autoconfigure
#[AutoconfigureTag('app.report_generator')]
class PdfReportGenerator implements ReportGeneratorInterface { /* … */ }
EventDispatcher — découpler les composants
<?php
// 1. Définir l'événement
namespace App\Event;
use App\Entity\Article;
use Symfony\Contracts\EventDispatcher\Event;
class ArticlePublishedEvent extends Event
{
public const NAME = 'article.published';
public function __construct(public readonly Article $article) {}
}
<?php
// 2. Dispatcher l'événement dans le service
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
class ArticleService
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly EventDispatcherInterface $dispatcher,
) {}
public function publish(Article $article): void
{
$article->setPublished(true);
$this->em->flush();
$this->dispatcher->dispatch(new ArticlePublishedEvent($article));
}
}
<?php
// 3. Listener — réagit à l'événement
namespace App\EventListener;
use App\Event\ArticlePublishedEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
#[AsEventListener(event: ArticlePublishedEvent::class)]
class SendArticleNotification
{
public function __construct(private readonly MailerInterface $mailer) {}
public function __invoke(ArticlePublishedEvent $event): void
{
// Envoyer email, notification Slack, etc.
$article = $event->article;
$this->mailer->send(/* … */);
}
}
Événements Symfony natifsf
<?php
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
// Listener sur kernel.request
#[AsEventListener(event: KernelEvents::REQUEST, priority: 20)]
class LocaleListener
{
public function __invoke(RequestEvent $event): void
{
$request = $event->getRequest();
$locale = $request->query->get('lang', 'fr');
$request->setLocale($locale);
}
}
Symfony Messenger — messages asynchrones
composer require symfony/messenger symfony/doctrine-messenger
# ou pour Redis: composer require symfony/redis-messenger
<?php
// 1. Message (simple DTO)
namespace App\Message;
class SendWelcomeEmail
{
public function __construct(
public readonly int $userId,
public readonly string $email,
) {}
}
<?php
// 2. Handler
namespace App\MessageHandler;
use App\Message\SendWelcomeEmail;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
class SendWelcomeEmailHandler
{
public function __construct(private readonly MailerInterface $mailer) {}
public function __invoke(SendWelcomeEmail $message): void
{
$this->mailer->send(/* email de bienvenue à $message->email */);
}
}
<?php
// 3. Dispatcher dans un controller/service
use Symfony\Component\Messenger\MessageBusInterface;
class RegistrationController extends AbstractController
{
public function __construct(private readonly MessageBusInterface $bus) {}
#[Route('/register', methods: ['POST'])]
public function register(/* … */): Response
{
// … créer l'utilisateur …
$this->bus->dispatch(new SendWelcomeEmail($user->getId(), $user->getEmail()));
return $this->redirectToRoute('app_login');
}
}
# config/packages/messenger.yaml
framework:
messenger:
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000
async_priority_high:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
queue_name: high
routing:
'App\Message\SendWelcomeEmail': async
'App\Message\GenerateReport': async_priority_high
# Consommer les messages en worker
php bin/console messenger:consume async -vv
php bin/console messenger:consume async --time-limit=3600 # 1h max
Console Commands avancées
<?php
namespace App\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\{InputArgument, InputOption, InputInterface};
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:articles:cleanup',
description: 'Supprime les articles dépubliés de plus de 30 jours',
)]
class CleanupArticlesCommand extends Command
{
public function __construct(private readonly ArticleRepository $repo) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('days', InputArgument::OPTIONAL, 'Âge en jours', 30)
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Simulation sans suppression');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$days = (int) $input->getArgument('days');
$dryRun = $input->getOption('dry-run');
$date = new \DateTimeImmutable("-{$days} days");
$articles = $this->repo->findOldUnpublished($date);
$io->title('Nettoyage des articles');
$io->info(sprintf('%d article(s) trouvé(s)', count($articles)));
if ($dryRun) {
$io->warning('Mode simulation — aucune suppression');
return Command::SUCCESS;
}
// … supprimer les articles …
$io->success(sprintf('%d article(s) supprimé(s)', count($articles)));
return Command::SUCCESS;
}
}
Docker multi-stage
# Dockerfile — multi-stage Symfony
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-scripts --prefer-dist
# ── Stage PHP-FPM ──────────────────────────────────────────
FROM php:8.3-fpm-alpine AS app
RUN apk add --no-cache \
icu-dev libpq-dev libzip-dev \
&& docker-php-ext-install intl pdo_pgsql zip opcache
# Configuration OPcache production
COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/opcache.ini
WORKDIR /var/www/html
COPY --from=vendor /app/vendor vendor/
COPY . .
RUN php bin/console cache:warmup --env=prod \
&& chown -R www-data:www-data var/
EXPOSE 9000
# docker-compose.yml
services:
php:
build:
context: .
target: app
volumes:
- .:/var/www/html:cached
environment:
APP_ENV: dev
DATABASE_URL: postgresql://app:secret@db:5432/app
nginx:
image: nginx:alpine
ports:
- "8080:80"
volumes:
- .:/var/www/html
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
volumes:
postgres_data:
Déploiement en production
# Checklist déploiement Symfony
# 1. Variables d'environnement prod
APP_ENV=prod
APP_DEBUG=0
APP_SECRET=xxx-secret-32-chars-xxx
# 2. Installation des dépendances (sans dev)
composer install --no-dev --optimize-autoloader
# 3. Warmup du cache
php bin/console cache:clear --env=prod
php bin/console cache:warmup --env=prod
# 4. Migrations BDD
php bin/console doctrine:migrations:migrate --no-interaction --env=prod
# 5. Assets
php bin/console assets:install --env=prod
# 6. OPcache (PHP) — opcache.preload en PHP 7.4+
# opcache.preload=/var/www/html/var/cache/prod/App_KernelProdContainer.php
CI/CD GitHub Actions
# .github/workflows/deploy.yml
name: Deploy to Production
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: intl, pdo_pgsql, zip
- name: Install deps
run: composer install --no-dev --optimize-autoloader
- name: Run tests
run: php bin/phpunit
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_KEY }}
script: |
cd /var/www/html
git pull origin main
composer install --no-dev --optimize-autoloader
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console cache:clear
sudo systemctl reload php8.3-fpm