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
▶ Mini-projet SF08 🧠 QCM SF08 🏆 Projet Final →