MFormations
Modern PHP Engineering

Chapitre 19

19 — Corrections Détaillées

19 — Corrections Détaillées

Chapitre 19 — Corrections Détaillées des Exercices

19.0 Introduction

Ce chapitre fournit les corrigés complets et détaillés des 40 exercices du chapitre 17. Chaque correction inclut le code complet, les explications, les tests associés, les variantes et les pièges à éviter.


19.1 Corrections : OOP PHP 8 (Exercices 1-6)

Exercice 1 — Classes avec readonly properties

Solution complète :

<?php

declare(strict_types=1);

final class Email
{
    private function __construct(
        public readonly string $value
    ) {
        if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
            throw new \InvalidArgumentException("Email invalide : {$value}");
        }
    }

    public static function fromString(string $email): self
    {
        return new self(mb_strtolower(trim($email)));
    }

    public function __toString(): string
    {
        return $this->value;
    }
}

final class User
{
    public function __construct(
        public readonly string $uuid,
        public readonly Email $email,
        public readonly \DateTimeImmutable $createdAt,
        public string $name,
    ) {}

    public function toArray(): array
    {
        return [
            'uuid' => $this->uuid,
            'email' => $this->email->value,
            'name' => $this->name,
            'created_at' => $this->createdAt->format(\DateTimeInterface::ATOM),
        ];
    }
}

Explications :

  • Email est un Value Object immutable : validation dans le constructeur privé, factory method fromString()
  • readonly dans User empêche la modification après construction (sauf name qui est mutable)
  • DateTimeImmutable garantit que la date ne peut pas être modifiée
  • final empêche l'héritage (meilleure pratique pour les Value Objects)

Tests Pest :

it('creates a valid email', function () {
    $email = Email::fromString('user@example.com');
    expect($email->value)->toBe('user@example.com');
});

it('throws on invalid email', function () {
    Email::fromString('not-an-email');
})->throws(\InvalidArgumentException::class);

it('creates user with readonly properties', function () {
    $user = new User(
        uuid: '123e4567-e89b-12d3-a456-426614174000',
        email: Email::fromString('john@example.com'),
        createdAt: new \DateTimeImmutable(),
        name: 'John Doe',
    );
    expect($user->uuid)->toBeString();
    expect($user->email)->toBeInstanceOf(Email::class);
});

Variantes :

  • Utiliser Symfony Uid pour l'UUID
  • Utiliser spatie/laravel-data pour le DTO
  • Ajouter un serialiser JSON personnalisé

Pièges :

  • Oublier declare(strict_types=1) en haut du fichier
  • Ne pas normaliser l'email (lowercase + trim)
  • Rendre $name readonly aussi par erreur

Exercice 2 — Enums backed avec méthode statique

Solution complète :

<?php

declare(strict_types=1);

enum OrderStatus: string
{
    case Pending = 'pending';
    case Confirmed = 'confirmed';
    case Shipped = 'shipped';
    case Delivered = 'delivered';
    case Cancelled = 'cancelled';

    private const LABELS = [
        'pending' => 'En attente',
        'confirmed' => 'Confirmée',
        'shipped' => 'Expédiée',
        'delivered' => 'Livrée',
        'cancelled' => 'Annulée',
    ];

    private const TRANSITIONS = [
        self::Pending->name => [self::Confirmed, self::Cancelled],
        self::Confirmed->name => [self::Shipped, self::Cancelled],
        self::Shipped->name => [self::Delivered],
        self::Delivered->name => [],
        self::Cancelled->name => [],
    ];

    public function label(): string
    {
        return self::LABELS[$this->value];
    }

    public static function fromLabel(string $label): self
    {
        $case = array_search($label, self::LABELS, true);
        if ($case === false) {
            throw new \InvalidArgumentException("Label inconnu : {$label}");
        }
        return self::from($case);
    }

    public function canTransitionTo(self $target): bool
    {
        return in_array($target, self::TRANSITIONS[$this->name], true);
    }
}

final class Order
{
    public function __construct(
        public readonly string $id,
        public OrderStatus $status = OrderStatus::Pending,
    ) {}

    public function transitionTo(OrderStatus $newStatus): void
    {
        if (!$this->status->canTransitionTo($newStatus)) {
            throw new \DomainException(
                "Transition impossible de {$this->status->name} vers {$newStatus->name}"
            );
        }
        $this->status = $newStatus;
    }
}

Explications :

  • Backed Enum avec string pour la persistance en base
  • LABELS constants pour les libellés français
  • Machine à états avec TRANSITIONS — seules les transitions valides sont autorisées
  • canTransitionTo() respecte les règles métier

Tests :

it('has correct labels', function () {
    expect(OrderStatus::Pending->label())->toBe('En attente');
    expect(OrderStatus::Delivered->label())->toBe('Livrée');
});

it('allows valid transitions', function () {
    expect(OrderStatus::Pending->canTransitionTo(OrderStatus::Confirmed))->toBeTrue();
    expect(OrderStatus::Pending->canTransitionTo(OrderStatus::Cancelled))->toBeTrue();
});

it('blocks invalid transitions', function () {
    expect(OrderStatus::Delivered->canTransitionTo(OrderStatus::Pending))->toBeFalse();
});

it('finds status from label', function () {
    expect(OrderStatus::fromLabel('En attente'))->toBe(OrderStatus::Pending);
});

Exercice 3 — Pattern Strategy avec interfaces

Solution complète :

<?php

declare(strict_types=1);

interface NotificationChannel
{
    public function send(string $to, string $message): bool;
    public function name(): string;
}

final class EmailChannel implements NotificationChannel
{
    public function send(string $to, string $message): bool
    {
        // Dans un vrai projet : Mail::to($to)->send(new NotificationMail($message));
        \Log::info("Email envoyé à {$to}: {$message}");
        return true;
    }

    public function name(): string
    {
        return 'email';
    }
}

final class SMSChannel implements NotificationChannel
{
    public function send(string $to, string $message): bool
    {
        // Dans un vrai projet : Sms::send($to, $message);
        \Log::info("SMS envoyé à {$to}: {$message}");
        return true;
    }

    public function name(): string
    {
        return 'sms';
    }
}

final class SlackChannel implements NotificationChannel
{
    public function __construct(
        private readonly string $webhookUrl,
    ) {}

    public function send(string $to, string $message): bool
    {
        // Dans un vrai projet : Http::post($this->webhookUrl, ['text' => $message]);
        \Log::info("Slack envoyé à {$to}: {$message}");
        return true;
    }

    public function name(): string
    {
        return 'slack';
    }
}

final class NotificationService
{
    /** @param NotificationChannel[] $channels */
    public function __construct(
        private readonly array $channels
    ) {}

    public function notifyAll(string $to, string $message): array
    {
        $results = [];

        foreach ($this->channels as $channel) {
            try {
                $success = $channel->send($to, $message);
                $results[$channel->name()] = [
                    'success' => $success,
                    'error' => null,
                ];
            } catch (\Throwable $e) {
                $results[$channel->name()] = [
                    'success' => false,
                    'error' => $e->getMessage(),
                ];
            }
        }

        return $results;
    }
}

Explications :

  • Strategy Pattern : chaque canal implémente NotificationChannel
  • NotificationService ne connaît que l'interface, pas les implémentations
  • Ouvert à l'extension (ajout d'un canal Telegram = nouvelle classe)
  • Gestion d'erreurs par canal

Exercice 4 — Generic-like collections typées

(Voir la solution dans l'énoncé du chapitre 17 — elle est déjà complète)

Améliorations possibles :

// Ajout de reduce
public function reduce(callable $callback, mixed $initial = null): mixed
{
    return array_reduce($this->items, $callback, $initial);
}

// Ajout de each
public function each(callable $callback): self
{
    foreach ($this->items as $key => $item) {
        $callback($item, $key);
    }
    return $this;
}

// Ajout de pluck
public function pluck(string $key): Collection
{
    return new self(array_map(fn($item) => $item[$key] ?? null, $this->items));
}

Exercice 5 — Value Objects immutables

<?php

declare(strict_types=1);

final class Money
{
    public const array CURRENCIES = ['EUR', 'USD', 'GBP'];

    private function __construct(
        public readonly int $cents,
        public readonly string $currency,
    ) {
        if ($cents < 0) {
            throw new \InvalidArgumentException('Le montant ne peut pas être négatif');
        }
        if (!in_array($currency, self::CURRENCIES, true)) {
            throw new \InvalidArgumentException("Devise non supportée : {$currency}");
        }
    }

    public static function fromFloat(float $amount, string $currency = 'EUR'): self
    {
        return new self((int) round($amount * 100), strtoupper($currency));
    }

    public static function fromCents(int $cents, string $currency = 'EUR'): self
    {
        return new self($cents, strtoupper($currency));
    }

    public function add(self $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new \DomainException(
                "Impossible d'additionner {$this->currency} et {$other->currency}"
            );
        }
        return new self($this->cents + $other->cents, $this->currency);
    }

    public function subtract(self $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new \DomainException(
                "Impossible de soustraire {$other->currency} de {$this->currency}"
            );
        }
        if ($other->cents > $this->cents) {
            throw new \DomainException('Solde insuffisant');
        }
        return new self($this->cents - $other->cents, $this->currency);
    }

    public function multiply(float $factor): self
    {
        return new self((int) round($this->cents * $factor), $this->currency);
    }

    public function greaterThan(self $other): bool
    {
        return $this->cents > $other->cents;
    }

    public function equals(self $other): bool
    {
        return $this->cents === $other->cents && $this->currency === $other->currency;
    }

    public function toFloat(): float
    {
        return $this->cents / 100;
    }

    public function __toString(): string
    {
        $formatted = number_format($this->toFloat(), 2, ',', ' ');
        $symbols = ['EUR' => '€', 'USD' => '$', 'GBP' => '£'];
        $symbol = $symbols[$this->currency] ?? $this->currency;
        return "{$formatted} {$symbol}";
    }
}

Tests :

it('creates money from float', function () {
    $money = Money::fromFloat(12.34);
    expect($money->cents)->toBe(1234);
});

it('adds money correctly', function () {
    $a = Money::fromFloat(10.00);
    $b = Money::fromFloat(5.50);
    $result = $a->add($b);
    expect($result->toFloat())->toBe(15.50);
});

it('is immutable', function () {
    $original = Money::fromFloat(10.00);
    $original->add(Money::fromFloat(5.00));
    expect($original->toFloat())->toBe(10.00);
});

it('throws on different currencies', function () {
    Money::fromFloat(10, 'EUR')->add(Money::fromFloat(10, 'USD'));
})->throws(\DomainException::class);

it('formats correctly', function () {
    expect((string) Money::fromFloat(12.34))->toMatch('/12,34/');
});

Exercice 6 — Active Record vs Data Mapper

<?php

declare(strict_types=1);

// === APPROCHE ACTIVE RECORD ===
abstract class ActiveRecord
{
    protected static PDO $pdo;
    protected static string $table;
    protected ?int $id = null;

    public static function setPdo(PDO $pdo): void
    {
        self::$pdo = $pdo;
    }

    public static function find(int $id): ?static
    {
        $stmt = self::$pdo->prepare(
            "SELECT * FROM " . static::$table . " WHERE id = ?"
        );
        $stmt->execute([$id]);
        $data = $stmt->fetch(PDO::FETCH_ASSOC);
        return $data ? static::hydrate($data) : null;
    }

    public function save(): void
    {
        $data = $this->toArray();
        if ($this->id) {
            $this->update($data);
        } else {
            $this->insert($data);
        }
    }

    public function delete(): bool
    {
        $stmt = self::$pdo->prepare(
            "DELETE FROM " . static::$table . " WHERE id = ?"
        );
        return $stmt->execute([$this->id]);
    }

    abstract protected function toArray(): array;
    abstract protected static function hydrate(array $data): static;

    private function insert(array $data): void
    {
        $columns = implode(', ', array_keys($data));
        $placeholders = implode(', ', array_fill(0, count($data), '?'));
        $stmt = self::$pdo->prepare(
            "INSERT INTO " . static::$table . " ({$columns}) VALUES ({$placeholders})"
        );
        $stmt->execute(array_values($data));
        $this->id = (int) self::$pdo->lastInsertId();
    }

    private function update(array $data): void
    {
        $sets = implode(', ', array_map(fn($col) => "{$col} = ?", array_keys($data)));
        $stmt = self::$pdo->prepare(
            "UPDATE " . static::$table . " SET {$sets} WHERE id = ?"
        );
        $stmt->execute([...array_values($data), $this->id]);
    }
}

final class ProductAR extends ActiveRecord
{
    protected static string $table = 'products';

    public function __construct(
        public ?int $id,
        public string $name,
        public float $price,
    ) {}

    protected function toArray(): array
    {
        return [
            'name' => $this->name,
            'price' => $this->price,
        ];
    }

    protected static function hydrate(array $data): static
    {
        return new static($data['id'], $data['name'], (float) $data['price']);
    }
}

// === APPROCHE DATA MAPPER ===
final class ProductDM
{
    public function __construct(
        public readonly ?int $id,
        public readonly string $name,
        public readonly float $price,
    ) {}
}

interface ProductMapperInterface
{
    public function findById(int $id): ?ProductDM;
    public function save(ProductDM $product): void;
    public function delete(int $id): bool;
}

final class PDOProductMapper implements ProductMapperInterface
{
    public function __construct(
        private readonly PDO $pdo,
    ) {}

    public function findById(int $id): ?ProductDM
    {
        $stmt = $this->pdo->prepare("SELECT * FROM products WHERE id = ?");
        $stmt->execute([$id]);
        $data = $stmt->fetch(PDO::FETCH_ASSOC);
        return $data ? $this->mapToProduct($data) : null;
    }

    public function save(ProductDM $product): void
    {
        $data = $this->mapToArray($product);
        if ($product->id) {
            $this->update($data);
        } else {
            $this->insert($data);
        }
    }

    public function delete(int $id): bool
    {
        $stmt = $this->pdo->prepare("DELETE FROM products WHERE id = ?");
        return $stmt->execute([$id]);
    }

    private function mapToProduct(array $data): ProductDM
    {
        return new ProductDM(
            id: (int) $data['id'],
            name: $data['name'],
            price: (float) $data['price'],
        );
    }

    private function mapToArray(ProductDM $product): array
    {
        return [
            'id' => $product->id,
            'name' => $product->name,
            'price' => $product->price,
        ];
    }

    private function insert(array $data): void
    {
        unset($data['id']);
        // ... INSERT query
    }

    private function update(array $data): void
    {
        // ... UPDATE query
    }
}

Comparaison :

  • Active Record : Simple, idéal pour CRUD simple. Couplage fort entre modèle et persistance.
  • Data Mapper : Testable, découplé, idéal pour domaines complexes. Plus de code boilerplate.

19.2 Corrections : PDO & ORM (Exercices 7-12)

Exercice 7 — CRUD avec requêtes préparées PDO

<?php

declare(strict_types=1);

final class UserRepository
{
    public function __construct(
        private readonly PDO $pdo,
    ) {}

    public function findById(int $id): ?User
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, name, email, created_at FROM users WHERE id = :id'
        );
        $stmt->execute([':id' => $id]);
        $data = $stmt->fetch(PDO::FETCH_ASSOC);
        return $data ? $this->hydrate($data) : null;
    }

    public function findByEmail(string $email): ?User
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, name, email, created_at FROM users WHERE email = :email'
        );
        $stmt->execute([':email' => $email]);
        $data = $stmt->fetch(PDO::FETCH_ASSOC);
        return $data ? $this->hydrate($data) : null;
    }

    public function save(User $user): void
    {
        if ($user->id) {
            $this->update($user);
        } else {
            $this->insert($user);
        }
    }

    public function delete(int $id): bool
    {
        $stmt = $this->pdo->prepare('DELETE FROM users WHERE id = :id');
        return $stmt->execute([':id' => $id]);
    }

    public function findAll(int $page = 1, int $perPage = 20): array
    {
        $offset = ($page - 1) * $perPage;
        $stmt = $this->pdo->prepare(
            'SELECT id, name, email, created_at FROM users ORDER BY id LIMIT :limit OFFSET :offset'
        );
        $stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
        $stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
        $stmt->execute();
        $users = array_map(fn(array $data) => $this->hydrate($data), $stmt->fetchAll(PDO::FETCH_ASSOC));

        $countStmt = $this->pdo->query('SELECT COUNT(*) FROM users');
        $total = (int) $countStmt->fetchColumn();

        return [
            'items' => $users,
            'total' => $total,
            'page' => $page,
            'perPage' => $perPage,
            'lastPage' => (int) ceil($total / $perPage),
        ];
    }

    private function insert(User $user): void
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO users (name, email, password, created_at) VALUES (:name, :email, :password, :created_at)'
        );
        $stmt->execute([
            ':name' => $user->name,
            ':email' => $user->email,
            ':password' => $user->password,
            ':created_at' => $user->createdAt->format('Y-m-d H:i:s'),
        ]);
        $user->id = (int) $this->pdo->lastInsertId();
    }

    private function update(User $user): void
    {
        $stmt = $this->pdo->prepare(
            'UPDATE users SET name = :name, email = :email WHERE id = :id'
        );
        $stmt->execute([
            ':name' => $user->name,
            ':email' => $user->email,
            ':id' => $user->id,
        ]);
    }

    private function hydrate(array $data): User
    {
        $user = new User();
        $user->id = (int) $data['id'];
        $user->name = $data['name'];
        $user->email = $data['email'];
        $user->createdAt = new \DateTimeImmutable($data['created_at']);
        return $user;
    }
}

Points clés :

  • Utilisation des paramètres nommés :id, :name pour la lisibilité
  • bindValue avec PDO::PARAM_INT pour les entiers
  • Pagination avec LIMIT/OFFSET
  • Gestion explicite INSERT vs UPDATE

Exercice 8 — Pagination et filtres sécurisés

<?php

declare(strict_types=1);

final class PaginatedQuery
{
    public static function paginate(
        PDO $pdo,
        string $baseQuery,
        array $params,
        int $page = 1,
        int $perPage = 20,
    ): array {
        $page = max(1, $page);
        $perPage = min(100, max(1, $perPage));

        // Count query
        $countQuery = preg_replace(
            '/SELECT .* FROM/i',
            'SELECT COUNT(*) FROM',
            $baseQuery
        );
        $countStmt = $pdo->prepare($countQuery);
        $countStmt->execute($params);
        $total = (int) $countStmt->fetchColumn();

        // Data query with pagination
        $offset = ($page - 1) * $perPage;
        $dataQuery = $baseQuery . ' LIMIT :limit OFFSET :offset';
        $dataStmt = $pdo->prepare($dataQuery);
        foreach ($params as $key => $value) {
            $dataStmt->bindValue(":{$key}", $value);
        }
        $dataStmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
        $dataStmt->bindValue(':offset', $offset, PDO::PARAM_INT);
        $dataStmt->execute();

        return [
            'items' => $dataStmt->fetchAll(PDO::FETCH_ASSOC),
            'total' => $total,
            'page' => $page,
            'perPage' => $perPage,
            'lastPage' => (int) ceil($total / $perPage),
            'hasMore' => $page * $perPage < $total,
        ];
    }

    public static function sanitizeSearchTerm(string $term): string
    {
        // Échapper les caractères LIKE
        $term = str_replace(['%', '_'], ['\\%', '\\_'], $term);
        return "%{$term}%";
    }
}

// Utilisation
$results = PaginatedQuery::paginate(
    $pdo,
    'SELECT * FROM users WHERE name LIKE :search ORDER BY created_at DESC',
    ['search' => PaginatedQuery::sanitizeSearchTerm('john')],
    page: 2,
    perPage: 15,
);

Exercice 9 — Transactions et rollback

<?php

declare(strict_types=1);

final class OrderProcessor
{
    public function __construct(
        private readonly PDO $pdo,
        private readonly InventoryService $inventory,
        private readonly PaymentGateway $payment,
        private readonly NotificationService $notifications,
    ) {}

    public function process(CheckoutData $data): Order
    {
        $this->pdo->beginTransaction();

        try {
            // 1. Créer la commande
            $order = $this->createOrder($data);

            // 2. Décrémenter le stock
            foreach ($data->items as $item) {
                $this->inventory->decrementStock($item->productId, $item->quantity);
            }

            // 3. Traiter le paiement
            $payment = $this->payment->charge(
                amount: $data->total,
                token: $data->paymentToken,
            );

            // 4. Lier le paiement à la commande
            $this->linkPayment($order->id, $payment->id);

            $this->pdo->commit();

            // 5. Envoyer notification (après commit)
            $this->notifications->sendOrderConfirmation($order);

            return $order;
        } catch (\Throwable $e) {
            $this->pdo->rollBack();
            \Log::error('Échec traitement commande', [
                'error' => $e->getMessage(),
                'data' => $data,
            ]);
            throw new OrderProcessingException(
                'Impossible de traiter la commande : ' . $e->getMessage(),
                previous: $e,
            );
        }
    }

    private function createOrder(CheckoutData $data): Order
    {
        // INSERT INTO orders ...
        // INSERT INTO order_items ...
        return $order;
    }

    private function linkPayment(int $orderId, int $paymentId): void
    {
        $stmt = $this->pdo->prepare(
            'UPDATE orders SET payment_id = :payment_id WHERE id = :id'
        );
        $stmt->execute([':payment_id' => $paymentId, ':id' => $orderId]);
    }
}

Exercice 10 — Relations Eloquent complexes

<?php

// === MODELS ===

// app/Models/Team.php
final class Team extends Model
{
    use HasFactory;

    public function users(): BelongsToMany
    {
        return $this->belongsToMany(User::class)
            ->withPivot('role_id')
            ->withTimestamps();
    }

    public function projects(): HasMany
    {
        return $this->hasMany(Project::class);
    }

    public function scopeActive(Builder $query): Builder
    {
        return $query->where('is_active', true);
    }

    public function admins(): BelongsToMany
    {
        return $this->users()->wherePivot('role_id', Role::ADMIN);
    }
}

// app/Models/User.php
final class User extends Model
{
    use HasFactory, HasRoles;

    public function teams(): BelongsToMany
    {
        return $this->belongsToMany(Team::class)
            ->withPivot('role_id')
            ->withTimestamps();
    }

    public function projects(): BelongsToMany
    {
        return $this->belongsToMany(Project::class)
            ->withTimestamps();
    }
}

// app/Models/Role.php
final class Role extends Model
{
    public const ADMIN = 1;
    public const MEMBER = 2;
    public const VIEWER = 3;
}

// app/Models/Project.php
final class Project extends Model
{
    use HasFactory;

    public function team(): BelongsTo
    {
        return $this->belongsTo(Team::class);
    }

    public function users(): BelongsToMany
    {
        return $this->belongsToMany(User::class)
            ->withTimestamps();
    }
}

// === TRAIT ===

// app/Models/Traits/HasRoles.php
trait HasRoles
{
    public function hasRole(string $role, ?Team $team = null): bool
    {
        $roles = $team
            ? $this->teams()->where('team_id', $team->id)->first()?->pivot?->role_id
            : $this->teams()->first()?->pivot?->role_id;

        return $roles === Role::ADMIN;
    }

    public function isAdminInAnyTeam(): bool
    {
        return $this->teams()
            ->wherePivot('role_id', Role::ADMIN)
            ->exists();
    }
}

Exercice 11 — Doctrine DQL avancé

<?php

// src/Repository/BlogPostRepository.php

final class BlogPostRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, BlogPost::class);
    }

    public function findWithFilters(array $filters, int $page = 1, int $perPage = 20): array
    {
        $qb = $this->createQueryBuilder('p')
            ->leftJoin('p.category', 'c')
            ->leftJoin('p.author', 'a')
            ->leftJoin('p.comments', 'co')
            ->addSelect('c', 'a')
            ->addSelect('COUNT(co.id) as HIDDEN commentCount')
            ->groupBy('p.id');

        // Filtres conditionnels
        if (!empty($filters['category_id'])) {
            $qb->andWhere('c.id = :categoryId')
               ->setParameter('categoryId', $filters['category_id']);
        }

        if (!empty($filters['author_id'])) {
            $qb->andWhere('a.id = :authorId')
               ->setParameter('authorId', $filters['author_id']);
        }

        if (!empty($filters['search'])) {
            $qb->andWhere('p.title LIKE :search OR p.content LIKE :search')
               ->setParameter('search', "%{$filters['search']}%");
        }

        if (!empty($filters['date_from'])) {
            $qb->andWhere('p.createdAt >= :dateFrom')
               ->setParameter('dateFrom', new \DateTimeImmutable($filters['date_from']));
        }

        // Tri
        $sort = $filters['sort'] ?? 'p.createdAt';
        $order = $filters['order'] ?? 'DESC';
        $qb->orderBy($sort, $order);

        // Pagination
        $qb->setFirstResult(($page - 1) * $perPage)
           ->setMaxResults($perPage);

        return $qb->getQuery()->getResult();
    }

    public function findWithMostComments(int $limit = 10): array
    {
        $dql = '
            SELECT p, COUNT(c.id) as commentCount
            FROM App\Entity\BlogPost p
            LEFT JOIN p.comments c
            GROUP BY p.id
            ORDER BY commentCount DESC
        ';

        return $this->getEntityManager()
            ->createQuery($dql)
            ->setMaxResults($limit)
            ->getResult();
    }
}

Exercice 12 — Eager/Lazy loading optimization

<?php

// Problème N+1
// SANS eager loading : 101 requêtes !
$posts = Post::all(); // 1 query
foreach ($posts as $post) {
    echo $post->author->name; // 100 queries (N+1)
}

// AVEC eager loading : 2 requêtes
$posts = Post::with('author')->get(); // 2 queries (posts + authors)

// AVEC eager loading multiple relations
$posts = Post::with(['author', 'comments.user', 'category'])
    ->withCount('comments')
    ->get();

// Chargement conditionnel
$posts = Post::all();
if (auth()->user()->isAdmin()) {
    $posts->load('drafts');
}

// Pour les très gros volumes : cursor (lazy collections)
foreach (Post::cursor() as $post) {
    // Traite un post à la fois, mémoire constante
    processPost($post);
}

// Benchmark
DB::enableQueryLog();
$posts = Post::with('author')->take(10)->get();
dump(DB::getQueryLog()); // 2 queries

DB::flushQueryLog();
$posts = Post::take(10)->get();
foreach ($posts as $post) {
    $post->author; // Lazy loading
}
dump(DB::getQueryLog()); // 11 queries !

19.3 Corrections : API REST (Exercices 19-23)

Exercice 19 — CRUD API avec Sanctum

<?php

// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
    Route::apiResource('articles', ArticleController::class)->except(['index', 'show']);
});
Route::apiResource('articles', ArticleController::class)->only(['index', 'show']);

// app/Http/Controllers/Api/ArticleController.php
final class ArticleController extends Controller
{
    public function __construct(
        private readonly ArticleRepository $repository,
    ) {}

    public function index(): ArticleCollection
    {
        $articles = $this->repository->paginate();
        return new ArticleCollection($articles);
    }

    public function store(StoreArticleRequest $request): ArticleResource
    {
        $article = $this->repository->create($request->validated());
        return new ArticleResource($article);
    }

    public function show(Article $article): ArticleResource
    {
        return new ArticleResource($article->load(['author', 'category']));
    }

    public function update(UpdateArticleRequest $request, Article $article): ArticleResource
    {
        $this->authorize('update', $article);
        $article = $this->repository->update($article, $request->validated());
        return new ArticleResource($article);
    }

    public function destroy(Article $article): JsonResponse
    {
        $this->authorize('delete', $article);
        $this->repository->delete($article);
        return response()->json(null, 204);
    }
}

// Tests Pest
it('lists articles with pagination', function () {
    Article::factory()->count(30)->create();
    
    $response = $this->getJson('/api/articles');
    
    $response->assertOk()
        ->assertJsonStructure([
            'data' => [['id', 'title', 'author']],
            'meta' => ['current_page', 'last_page', 'per_page', 'total'],
        ]);
});

it('creates an article when authenticated', function () {
    $user = User::factory()->create();
    $category = Category::factory()->create();
    
    $response = $this->actingAs($user)
        ->postJson('/api/articles', [
            'title' => 'Mon article',
            'content' => 'Contenu',
            'category_id' => $category->id,
        ]);
    
    $response->assertCreated()
        ->assertJsonFragment(['title' => 'Mon article']);
});

it('returns 401 when not authenticated', function () {
    $response = $this->postJson('/api/articles', [
        'title' => 'Test',
        'content' => 'Contenu',
    ]);
    
    $response->assertUnauthorized();
});

Exercice 20 — API Resources et collections

<?php

// app/Http/Resources/ArticleResource.php
final class ArticleResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'slug' => $this->slug,
            'excerpt' => Str::limit($this->content, 150),
            'content' => $this->when(
                $request->route('article') || $request->has('full_content'),
                $this->content,
            ),
            'status' => $this->status->label(),
            'published_at' => $this->published_at?->diffForHumans(),
            'author' => UserResource::make($this->whenLoaded('author')),
            'category' => CategoryResource::make($this->whenLoaded('category')),
            'tags' => TagResource::collection($this->whenLoaded('tags')),
            'comment_count' => $this->whenCounted('comments'),
            'can' => [
                'edit' => $request->user()?->can('update', $this->resource),
                'delete' => $request->user()?->can('delete', $this->resource),
            ],
            'created_at' => $this->created_at->toIso8601String(),
            'updated_at' => $this->updated_at->toIso8601String(),
        ];
    }
}

// app/Http/Resources/ArticleCollection.php
final class ArticleCollection extends ResourceCollection
{
    public $collects = ArticleResource::class;

    public function toArray(Request $request): array
    {
        return [
            'data' => $this->collection,
            'meta' => [
                'current_page' => $this->resource->currentPage(),
                'last_page' => $this->resource->lastPage(),
                'per_page' => $this->resource->perPage(),
                'total' => $this->resource->total(),
            ],
            'links' => [
                'first' => $this->resource->url(1),
                'last' => $this->resource->url($this->resource->lastPage()),
                'prev' => $this->resource->previousPageUrl(),
                'next' => $this->resource->nextPageUrl(),
            ],
        ];
    }
}

Exercice 21 — Rate limiting

<?php

// app/Providers/AppServiceProvider.php
public function boot(): void
{
    RateLimiter::for('api', function (Request $request) {
        $user = $request->user();
        $limit = match (true) {
            $user?->isAdmin() => 300,
            $user !== null => 120,
            default => 60,
        };
        
        return Limit::perMinute($limit)
            ->by($user?->id ?: $request->ip());
    });
}

// app/Http/Middleware/ApiRateLimit.php
final class ApiRateLimit
{
    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);
        
        $remaining = RateLimiter::remaining('api:' . ($request->user()?->id ?: $request->ip()));
        $limit = RateLimiter::attempts('api:' . ($request->user()?->id ?: $request->ip()));
        
        $response->headers->set('X-RateLimit-Limit', $limit);
        $response->headers->set('X-RateLimit-Remaining', max(0, $remaining));
        $response->headers->set('X-RateLimit-Reset', RateLimiter::availableIn('api:' . ($request->user()?->id ?: $request->ip())));
        
        return $response;
    }
}

19.4 Corrections : Tests Pest (Exercices 24-27)

Exercice 24 — Test de Value Object

<?php

uses(Tests\TestCase::class)->in('Feature');

// tests/Unit/MoneyTest.php
describe('Money', function () {
    it('can be created from float', function () {
        $money = Money::fromFloat(12.34);
        expect($money->cents)->toBe(1234);
        expect($money->currency)->toBe('EUR');
    });

    it('can be created from cents', function () {
        $money = Money::fromCents(5000);
        expect($money->toFloat())->toBe(50.00);
    });

    it('can add two amounts', function () {
        $total = Money::fromFloat(10.00)->add(Money::fromFloat(20.50));
        expect($total->toFloat())->toBe(30.50);
    });

    it('cannot add different currencies', function () {
        Money::fromFloat(10, 'EUR')->add(Money::fromFloat(10, 'USD'));
    })->throws(DomainException::class);

    it('can subtract', function () {
        $result = Money::fromFloat(50.00)->subtract(Money::fromFloat(30.00));
        expect($result->toFloat())->toBe(20.00);
    });

    it('cannot subtract more than available', function () {
        Money::fromFloat(10.00)->subtract(Money::fromFloat(20.00));
    })->throws(DomainException::class, 'Solde insuffisant');

    it('is immutable', function () {
        $original = Money::fromFloat(100.00);
        $original->add(Money::fromFloat(50.00));
        expect($original->toFloat())->toBe(100.00);
    });

    it('formats correctly in EUR', function () {
        expect((string) Money::fromFloat(1234.56))->toBe('1 234,56 €');
    });

    it('formats correctly in USD', function () {
        expect((string) Money::fromFloat(99.99, 'USD'))->toBe('99,99 $');
    });

    it('supports comparison', function () {
        $a = Money::fromFloat(100);
        $b = Money::fromFloat(200);
        expect($b->greaterThan($a))->toBeTrue();
        expect($a->equals(Money::fromFloat(100)))->toBeTrue();
    });
});

Exercice 25 — Test HTTP d'API

<?php

describe('Article API', function () {
    beforeEach(function () {
        $this->user = User::factory()->create();
        $this->category = Category::factory()->create();
    });

    it('lists articles with pagination', function () {
        Article::factory()->count(30)->create();
        
        $response = $this->getJson('/api/articles?per_page=15');
        
        $response->assertOk()
            ->assertJsonCount(15, 'data')
            ->assertJsonStructure([
                'data' => [['id', 'title', 'slug', 'excerpt']],
                'meta' => ['current_page', 'last_page', 'per_page', 'total'],
                'links' => ['first', 'last', 'prev', 'next'],
            ]);
    });

    it('creates an article when authenticated', function () {
        $response = $this->actingAs($this->user)
            ->postJson('/api/articles', [
                'title' => 'Nouvel article',
                'content' => 'Contenu détaillé de l\'article',
                'category_id' => $this->category->id,
            ]);

        $response->assertCreated()
            ->assertJsonFragment(['title' => 'Nouvel article'])
            ->assertJsonStructure(['data' => ['id', 'title', 'author', 'category']]);
    });

    it('returns 422 on invalid data', function () {
        $response = $this->actingAs($this->user)
            ->postJson('/api/articles', [
                'title' => '',
                'content' => '',
            ]);

        $response->assertUnprocessable()
            ->assertJsonValidationErrors(['title', 'content', 'category_id']);
    });

    it('returns 401 when not authenticated for create', function () {
        $response = $this->postJson('/api/articles', [
            'title' => 'Test',
            'content' => 'Contenu',
        ]);

        $response->assertUnauthorized();
    });

    it('shows a single article', function () {
        $article = Article::factory()->hasComments(5)->create();
        
        $response = $this->getJson("/api/articles/{$article->slug}");
        
        $response->assertOk()
            ->assertJsonFragment(['id' => $article->id]);
    });

    it('allows author to update their article', function () {
        $article = Article::factory()->for($this->user, 'author')->create();
        
        $response = $this->actingAs($this->user)
            ->putJson("/api/articles/{$article->slug}", [
                'title' => 'Titre modifié',
            ]);

        $response->assertOk()
            ->assertJsonFragment(['title' => 'Titre modifié']);
    });

    it('allows owner to delete article', function () {
        $article = Article::factory()->for($this->user, 'author')->create();
        
        $response = $this->actingAs($this->user)
            ->deleteJson("/api/articles/{$article->slug}");

        $response->assertNoContent();
        $this->assertSoftDeleted($article);
    });
});

19.5 Corrections : Performance (Exercices 28-30)

Exercice 28 — Configuration OpCache

; php.ini — OpCache configuration
opcache.enable=1
opcache.enable_cli=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.max_wasted_percentage=10
opcache.use_cwd=1
opcache.validate_timestamps=0 ; Production ONLY
opcache.revalidate_freq=2
opcache.revalidate_path=0
opcache.save_comments=1
opcache.fast_shutdown=1
opcache.enable_file_override=1
opcache.optimization_level=0x7FFFBFFF
opcache.inherited_hack=1
opcache.dups_fix=0
opcache.blacklist_filename=/etc/php/opcache-blacklist
opcache.max_file_size=0
opcache.consistency_checks=0
opcache.force_restart_timeout=180
opcache.error_log=/var/log/php/opcache.log
opcache.log_verbosity_level=1
opcache.preload=/var/www/preload.php
opcache.preload_user=www-data

Preloading Laravel :

<?php
// preload.php
require __DIR__ . '/vendor/autoload.php';

$classes = [
    App\Models\User::class,
    App\Models\Article::class,
    App\Models\Category::class,
    App\Http\Controllers\Api\ArticleController::class,
    App\Http\Resources\ArticleResource::class,
    // ... toutes les classes fréquemment utilisées
];

foreach ($classes as $class) {
    if (class_exists($class)) {
        $reflection = new ReflectionClass($class);
        // Force la classe à être chargée
        echo "Preloaded: {$class}\n";
    }
}

Exercice 29 — JIT Tuning

; php.ini — JIT for PHP 8.4
opcache.jit=tracing       ; tracing = meilleure perf globale
opcache.jit_buffer_size=256M  ; Ajuster selon mémoire disponible
opcache.jit_debug=0
opcache.jit_bisect_limit=0
opcache.jit_prof_threshold=0.005

Benchmark JIT :

<?php

function fibonacci(int $n): int
{
    return $n < 2 ? $n : fibonacci($n - 1) + fibonacci($n - 2);
}

function benchmark(int $iterations = 1000): void
{
    $start = microtime(true);
    
    for ($i = 0; $i < $iterations; $i++) {
        fibonacci(30);
    }
    
    $duration = (microtime(true) - $start) * 1000;
    echo "Temps: {$duration}ms pour {$iterations} itérations\n";
    
    $jitEnabled = opcache_get_status()['jit']['en'] ?? false;
    echo "JIT: " . ($jitEnabled ? 'Activé' : 'Désactivé') . "\n";
    
    if ($jitEnabled) {
        $jitInfo = opcache_get_status()['jit'];
        echo "Buffer Size: {$jitInfo['buffer_size']}\n";
        echo "Buffer Used: {$jitInfo['buffer_size'] - $jitInfo['buffer_free']}\n";
    }
}

benchmark(5000);

19.6 Corrections : Symfony (Exercices 31-33)

Exercice 31 — Messenger Handler Async

<?php

// src/Message/ProcessImage.php
final class ProcessImage
{
    public function __construct(
        public readonly int $imageId,
        public readonly array $transformations = ['thumbnail', 'medium', 'large'],
    ) {}
}

// src/MessageHandler/ProcessImageHandler.php
final class ProcessImageHandler implements MessageHandlerInterface
{
    public function __construct(
        private readonly ImageProcessor $processor,
        private readonly EntityManagerInterface $em,
    ) {}

    public function __invoke(ProcessImage $message): void
    {
        $image = $this->em->find(Image::class, $message->imageId);
        if (!$image) {
            throw new \RuntimeException("Image #{$message->imageId} non trouvée");
        }

        foreach ($message->transformations as $transformation) {
            $this->processor->process($image, $transformation);
        }

        $image->markAsProcessed();
        $this->em->flush();
    }
}

// config/packages/messenger.yaml
framework:
    messenger:
        transports:
            async: '%env(MESSENGER_TRANSPORT_DSN)%'
            priority: '%env(MESSENGER_TRANSPORT_PRIORITY_DSN)%'
            failed: 'doctrine://default?queue_name=failed'

        routing:
            'App\Message\ProcessImage': [priority, async]
            'App\Message\SendEmail': async

        failure_transport: failed

// Middleware custom
final class TimingMiddleware implements MiddlewareInterface
{
    public function handle(Envelope $envelope, StackInterface $stack): Envelope
    {
        $start = microtime(true);
        $envelope = $stack->next()->handle($envelope, $stack);
        $duration = (microtime(true) - $start) * 1000;
        
        $messageClass = get_class($envelope->getMessage());
        \Log::info("{$messageClass} processed in {$duration}ms");
        
        return $envelope;
    }
}

Exercice 32 — Serializer custom normalizer

<?php

// src/Serializer/MoneyNormalizer.php
final class MoneyNormalizer implements NormalizerInterface, DenormalizerInterface
{
    public function normalize(mixed $object, string $format = null, array $context = []): array
    {
        return [
            'amount' => $object->toFloat(),
            'currency' => $object->currency,
            'formatted' => (string) $object,
        ];
    }

    public function denormalize(mixed $data, string $type, string $format = null, array $context = []): Money
    {
        return Money::fromFloat(
            amount: $data['amount'] ?? 0,
            currency: $data['currency'] ?? 'EUR',
        );
    }

    public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool
    {
        return $data instanceof Money;
    }

    public function supportsDenormalization(mixed $data, string $type, string $format = null, array $context = []): bool
    {
        return Money::class === $type;
    }

    public function getSupportedTypes(?string $format): array
    {
        return [Money::class => true];
    }
}

19.7 Corrections : Livewire (Exercices 34-36)

Exercice 34 — Component CRUD avec validation

<?php

// app/Livewire/TaskList.php
final class TaskList extends Component
{
    use WithPagination;

    public string $newTaskTitle = '';
    public string $filter = 'all'; // all, active, completed

    protected function rules(): array
    {
        return [
            'newTaskTitle' => ['required', 'string', 'min:3', 'max:255'],
        ];
    }

    public function addTask(): void
    {
        $this->validate();

        $task = Task::create([
            'title' => $this->newTaskTitle,
            'user_id' => auth()->id(),
        ]);

        $this->newTaskTitle = '';
        $this->dispatch('task-added', taskId: $task->id, title: $task->title);
        $this->resetPage();
    }

    public function toggleTask(int $id): void
    {
        $task = Task::findOrFail($id);
        $this->authorize('update', $task);
        $task->update(['completed' => !$task->completed]);
        $this->dispatch('task-toggled', taskId: $id);
    }

    public function deleteTask(int $id): void
    {
        $task = Task::findOrFail($id);
        $this->authorize('delete', $task);
        $task->delete();
        $this->dispatch('task-deleted', taskId: $id);
    }

    public function render(): View
    {
        $query = Task::query()->where('user_id', auth()->id());

        $tasks = match ($this->filter) {
            'active' => $query->where('completed', false),
            'completed' => $query->where('completed', true),
            default => $query,
        };

        return view('livewire.task-list', [
            'tasks' => $tasks->latest()->paginate(10),
        ]);
    }
}
{{-- resources/views/livewire/task-list.blade.php --}}
<div>
    <form wire:submit="addTask" class="mb-6">
        <div class="flex gap-2">
            <x-input wire:model="newTaskTitle" placeholder="Nouvelle tâche..." class="flex-1" />
            <x-button type="submit">Ajouter</x-button>
        </div>
        @error('newTaskTitle') <p class="text-red-500 text-sm mt-1">{{ $message }}</p> @enderror
    </form>

    <div class="flex gap-4 mb-4">
        <x-button wire:click="$set('filter', 'all')" :active="$filter === 'all'">Toutes</x-button>
        <x-button wire:click="$set('filter', 'active')" :active="$filter === 'active'">Actives</x-button>
        <x-button wire:click="$set('filter', 'completed')" :active="$filter === 'completed'">Terminées</x-button>
    </div>

    <div wire:loading.delay class="text-gray-500 mb-2">Chargement...</div>

    <ul class="space-y-2">
        @foreach ($tasks as $task)
            <li wire:key="task-{{ $task->id }}"
                class="flex items-center gap-3 p-3 bg-white rounded-lg shadow"
                x-data="{ loading: false }"
                x-on:task-toggled.window="if ($event.detail.taskId === {{ $task->id }}) loading = false">
                
                <input type="checkbox"
                    wire:change="toggleTask({{ $task->id }})"
                    @checked($task->completed)
                    class="rounded border-gray-300">

                <span @class(['flex-1', 'line-through text-gray-400' => $task->completed])>
                    {{ $task->title }}
                </span>

                <button wire:click="deleteTask({{ $task->id }})"
                    wire:confirm="Supprimer cette tâche ?"
                    class="text-red-500 hover:text-red-700">
                    &times;
                </button>

                <div wire:loading wire:target="toggleTask({{ $task->id }})"
                     class="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin">
                </div>
            </li>
        @endforeach
    </ul>

    {{ $tasks->links() }}
</div>

19.8 Corrections : WordPress (Exercices 37-39)

Exercice 37 — Custom Gutenberg Block

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "mp/team-members",
  "version": "0.1.0",
  "title": "Membres de l'équipe",
  "category": "media",
  "icon": "groups",
  "description": "Affiche les membres de l'équipe avec photos",
  "attributes": {
    "columns": { "type": "number", "default": 3 },
    "members": {
      "type": "array",
      "default": [],
      "items": {
        "type": "object",
        "properties": {
          "name": { "type": "string" },
          "role": { "type": "string" },
          "bio": { "type": "string" },
          "photoUrl": { "type": "string" },
          "photoId": { "type": "number" },
          "socialLinks": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "platform": { "type": "string" },
                "url": { "type": "string" }
              }
            }
          }
        }
      }
    }
  },
  "supports": {
    "align": ["wide", "full"],
    "html": false,
    "color": { "background": true, "text": true }
  },
  "editorScript": "file:./index.js",
  "render": "mp_team_members_render"
}
<?php
// functions.php or plugin

function mp_team_members_render(array $attributes): string
{
    $members = $attributes['members'] ?? [];
    $columns = min(4, max(1, $attributes['columns'] ?? 3));
    
    if (empty($members)) {
        return '<p>Aucun membre</p>';
    }

    ob_start();
    ?>
    <div class="wp-block-mp-team-members grid grid-cols-<?= $columns ?> gap-8">
        <?php foreach ($members as $member): ?>
            <div class="team-member text-center">
                <?php if (!empty($member['photoUrl'])): ?>
                    <img src="<?= esc_url($member['photoUrl']) ?>"
                         alt="<?= esc_attr($member['name']) ?>"
                         class="rounded-full w-32 h-32 object-cover mx-auto mb-4">
                <?php endif; ?>
                <h3 class="text-xl font-bold"><?= esc_html($member['name']) ?></h3>
                <p class="text-gray-600"><?= esc_html($member['role']) ?></p>
                <p class="mt-2"><?= esc_html($member['bio']) ?></p>
                <?php if (!empty($member['socialLinks'])): ?>
                    <div class="flex justify-center gap-2 mt-3">
                        <?php foreach ($member['socialLinks'] as $link): ?>
                            <a href="<?= esc_url($link['url']) ?>"
                               target="_blank"
                               class="text-blue-500 hover:text-blue-700">
                                <?= esc_html($link['platform']) ?>
                            </a>
                        <?php endforeach; ?>
                    </div>
                <?php endif; ?>
            </div>
        <?php endforeach; ?>
    </div>
    <?php
    return ob_get_clean();
}
add_action('init', function () {
    register_block_type('mp/team-members', [
        'render_callback' => 'mp_team_members_render',
    ]);
});

19.9 Correction : Architecture (Exercice 40)

Exercice 40 — Refactoring Legacy vers Clean Architecture

<?php

// 1. DTO avec Spatie Laravel Data
// app/Data/OrderData.php
final class OrderData extends Data
{
    public function __construct(
        #[StringType, Min(3), Max(255)]
        public string $customerName,
        #[Email]
        public string $customerEmail,
        #[ArrayType]
        #[DataCollectionOf(OrderItemData::class)]
        public DataCollection $items,
        #[Nullable, Numeric, Min(0)]
        public ?float $discount = null,
        #[StringType]
        public string $paymentMethod = 'card',
    ) {}

    public static function rules(): array
    {
        return [
            'customer_name' => ['required', 'string', 'min:3', 'max:255'],
            'customer_email' => ['required', 'email'],
            'items' => ['required', 'array', 'min:1'],
            'items.*.product_id' => ['required', 'exists:products,id'],
            'items.*.quantity' => ['required', 'integer', 'min:1'],
            'discount' => ['nullable', 'numeric', 'min:0', 'max:100'],
            'payment_method' => ['required', 'in:card,transfer,paypal'],
        ];
    }
}

// 2. Form Request
// app/Http/Requests/StoreOrderRequest.php
final class StoreOrderRequest extends FormRequest
{
    public function authorize(): bool
    {
        return auth()->check();
    }

    public function rules(): array
    {
        return OrderData::rules();
    }

    protected function prepareForValidation(): void
    {
        $this->merge([
            'customer_email' => strtolower(trim($this->customer_email)),
        ]);
    }
}

// 3. Interface Repository
// app/Contracts/OrderRepositoryInterface.php
interface OrderRepositoryInterface
{
    public function findById(int $id): ?Order;
    public function findByReference(string $reference): ?Order;
    public function save(Order $order): void;
    public function delete(Order $order): void;
}

// 4. Implementation Repository
// app/Repositories/EloquentOrderRepository.php
final class EloquentOrderRepository implements OrderRepositoryInterface
{
    public function findById(int $id): ?Order
    {
        return Order::with(['items.product', 'payment'])->find($id);
    }

    public function findByReference(string $reference): ?Order
    {
        return Order::where('reference', $reference)->first();
    }

    public function save(Order $order): void
    {
        $order->save();
    }

    public function delete(Order $order): void
    {
        $order->delete();
    }
}

// 5. Action Class
// app/Actions/StoreOrderAction.php
final class StoreOrderAction
{
    public function __construct(
        private readonly OrderRepositoryInterface $repository,
        private readonly InventoryService $inventory,
        private readonly PaymentService $payment,
        private readonly OrderConfirmationNotification $notification,
    ) {}

    public function execute(OrderData $data): Order
    {
        DB::beginTransaction();

        try {
            $order = $this->createOrder($data);
            $this->reserveInventory($data, $order);
            $payment = $this->processPayment($data, $order);

            $this->linkPayment($order, $payment);

            DB::commit();

            $this->sendConfirmation($order);

            return $order;
        } catch (\Throwable $e) {
            DB::rollBack();
            throw new OrderCreationException(
                'Échec de la création de la commande',
                previous: $e,
            );
        }
    }

    private function createOrder(OrderData $data): Order
    {
        $order = new Order();
        $order->reference = $this->generateReference();
        $order->customer_name = $data->customerName;
        $order->customer_email = $data->customerEmail;
        $order->discount = $data->discount ?? 0;
        $order->status = OrderStatus::Pending;
        $order->total = $this->calculateTotal($data);

        $this->repository->save($order);

        foreach ($data->items as $itemData) {
            $order->items()->create([
                'product_id' => $itemData->productId,
                'quantity' => $itemData->quantity,
                'unit_price' => $itemData->unitPrice,
                'subtotal' => $itemData->quantity * $itemData->unitPrice,
            ]);
        }

        return $order;
    }

    private function calculateTotal(OrderData $data): float
    {
        $subtotal = $data->items->sum(
            fn(OrderItemData $item) => $item->quantity * $item->unitPrice
        );

        $discountAmount = $data->discount
            ? $subtotal * ($data->discount / 100)
            : 0;

        return $subtotal - $discountAmount;
    }

    private function generateReference(): string
    {
        return 'ORD-' . strtoupper(Str::random(10));
    }

    // ... autres méthodes privées
}

// 6. Notification
// app/Notifications/OrderConfirmation.php
final class OrderConfirmation extends Notification
{
    public function __construct(
        private readonly Order $order,
    ) {}

    public function via($notifiable): array
    {
        return ['mail', 'database'];
    }

    public function toMail($notifiable): MailMessage
    {
        return (new MailMessage)
            ->subject("Confirmation commande #{$this->order->reference}")
            ->greeting("Bonjour {$this->order->customer_name}")
            ->line('Votre commande a bien été confirmée.')
            ->line("Total : " . number_format($this->order->total, 2) . " €")
            ->action('Voir ma commande', url("/orders/{$this->order->id}"))
            ->line('Merci de votre confiance !');
    }

    public function toArray($notifiable): array
    {
        return [
            'order_id' => $this->order->id,
            'reference' => $this->order->reference,
            'total' => $this->order->total,
        ];
    }
}

// 7. Contrôleur refactorisé
// app/Http/Controllers/Api/OrderController.php
final class OrderController extends Controller
{
    public function __construct(
        private readonly StoreOrderAction $storeOrderAction,
    ) {}

    public function store(StoreOrderRequest $request): OrderResource
    {
        $orderData = OrderData::from($request->validated());

        $order = $this->storeOrderAction->execute($orderData);

        return OrderResource::make($order);
    }
}

Ce qui a changé :

  1. Form Request → Validation déléguée
  2. DTO → Données typées et validées
  3. Action → Responsabilité unique (création de commande)
  4. Repository → Abstraction de la persistance
  5. Notification → Découplée du contrôleur
  6. Contrôleur → 3 lignes, uniquement de l'orchestration
  7. SOLID : chaque classe a une responsabilité unique
  8. Testable : chaque couche peut être testée indépendamment

19.10 Conclusion

Les 40 exercices couvrent l'ensemble du spectre PHP moderne. Les corrections fournies ici sont des solutions de référence. L'important n'est pas la solution exacte mais la compréhension des principes sous-jacents : SOLID, découplage, testabilité, immutabilité, et patterns d'architecture.

Pour chaque exercice, rappelez-vous :

  1. Strict types en premier lieu
  2. Typage fort des paramètres et retours
  3. Immutabilité quand c'est possible
  4. Tests avant ou immédiatement après
  5. Principes SOLID appliqués naturellement