MFormations
Modern PHP Engineering

Chapitre 12

12 — Architecture PHP

12 — Architecture PHP

Course : Architecture PHP

1. Introduction à l'Architecture Logicielle

1.1 Pourquoi l'architecture ?

L'architecture logicielle est l'art de structurer un système pour qu'il soit compréhensible, maintenable, testable et évolutif. Une mauvaise architecture mène au "Big Ball of Mud" — un code spaghetti impossible à modifier sans casser autre chose.

1.2 Évolution des architectures PHP

2000s : PHP procédural (index.php tout-en-un)
2010s : MVC (Model-View-Controller) avec frameworks
2015s : Service Layer + Repository pattern
2020s : DDD, Hexagonal, CQRS, Event Sourcing

1.3 Principes SOLID

  • S — Single Responsibility : une classe, une responsabilité
  • O — Open/Closed : ouvert à l'extension, fermé à la modification
  • L — Liskov Substitution : les sous-types doivent être substituables
  • I — Interface Segregation : des interfaces spécifiques plutôt que générales
  • D — Dependency Inversion : dépendre d'abstractions, pas de concrétions

2. Service Layer Pattern

2.1 Problème : Controllers "gros"

// ❌ Controller obèse — à éviter
class PostController
{
    public function store(Request $request): JsonResponse
    {
        $validated = $request->validate([...]);
        $post = new Post($validated);

        // Logique métier dans le controller
        if ($request->hasFile('image')) {
            $path = $request->file('image')->store('posts');
            $post->image = $path;
        }

        // Notification
        $subscribers = Subscriber::where('post_id', $post->id)->get();
        foreach ($subscribers as $sub) {
            Mail::to($sub->email)->send(new NewPostNotification($post));
        }

        $post->save();

        Log::info('Post created', ['id' => $post->id]);

        return response()->json($post, 201);
    }
}

2.2 Solution : Service Layer

// ✅ Service — contient la logique métier
class PostService
{
    public function __construct(
        private PostRepository $repository,
        private ImageService $imageService,
        private NotificationService $notificationService,
        private Logger $logger
    ) {}

    public function create(array $data): Post
    {
        $post = $this->repository->create($data);

        if (isset($data['image'])) {
            $this->imageService->attach($post, $data['image']);
        }

        $this->notificationService->notifySubscribers($post);
        $this->logger->info('Post created', ['id' => $post->id]);

        return $post;
    }
}

// ✅ Controller — mince, ne fait que déléguer
class PostController
{
    public function __construct(private PostService $postService) {}

    public function store(StorePostRequest $request): JsonResponse
    {
        $post = $this->postService->create(
            $request->validated()
        );

        return response()->json($post, 201);
    }
}

2.3 Injection de dépendances

// AppServiceProvider.php
public function register(): void
{
    $this->app->bind(PostService::class, function ($app) {
        return new PostService(
            $app->make(PostRepository::class),
            $app->make(ImageService::class),
            $app->make(NotificationService::class),
            $app->make(Logger::class)
        );
    });
}

3. Domain-Driven Design (DDD)

3.1 Concepts clés

┌─────────────────────────────────────────────┐
│              Domain-Driven Design             │
│  ┌─────────────┐   ┌─────────────────────┐   │
│  │ Bounded     │   │  Ubiquitous Language │   │
│  │ Context     │   │  (Langage ubiquiste) │   │
│  └─────────────┘   └─────────────────────┘   │
│  ┌─────────────┐   ┌─────────────────────┐   │
│  │ Entities    │   │  Value Objects      │   │
│  └─────────────┘   └─────────────────────┘   │
│  ┌─────────────┐   ┌─────────────────────┐   │
│  │ Aggregates  │   │  Domain Events      │   │
│  └─────────────┘   └─────────────────────┘   │
│  ┌─────────────┐   ┌─────────────────────┐   │
│  │ Repositories│   │  Domain Services    │   │
│  └─────────────┘   └─────────────────────┘   │
└─────────────────────────────────────────────┘

3.2 Bounded Contexts

// Contexte "Catalogue"
namespace Domain\Catalog\Entities;
class Product
{
    public function __construct(
        private ProductId $id,
        private string $name,
        private Money $price
    ) {}
}

// Contexte "Commande"
namespace Domain\Order\Entities;
class OrderItem
{
    public function __construct(
        private ProductId $productId,
        private int $quantity,
        private Money $unitPrice
    ) {}
}

3.3 Entities vs Value Objects

// Entity — a une identité (ID)
class User
{
    public function __construct(
        private UserId $id,      // ← identité
        private string $name,
        private Email $email
    ) {}

    public function equals(User $other): bool
    {
        return $this->id->equals($other->id);
    }
}

// Value Object — défini par ses attributs, immuable
class Money
{
    public function __construct(
        private int $amount,
        private string $currency
    ) {}

    public function add(Money $other): Money
    {
        if ($this->currency !== $other->currency) {
            throw new CurrencyMismatchException;
        }
        return new Money($this->amount + $other->amount, $this->currency);
    }

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

3.4 Domain Events

// Event
class OrderPlaced
{
    public function __construct(
        public readonly OrderId $orderId,
        public readonly CustomerId $customerId,
        public readonly Money $total,
        public readonly \DateTimeImmutable $occurredAt
    ) {}
}

// Aggregate Root émet l'événement
class Order
{
    private array $events = [];

    public function place(): void
    {
        // Validation métier
        if ($this->status !== Status::Draft) {
            throw new OrderAlreadyPlacedException;
        }

        $this->status = Status::Placed;

        $this->events[] = new OrderPlaced(
            $this->id,
            $this->customerId,
            $this->total(),
            new \DateTimeImmutable()
        );
    }

    public function releaseEvents(): array
    {
        $events = $this->events;
        $this->events = [];
        return $events;
    }
}

// Event Subscriber
class SendOrderConfirmation
{
    public function handle(OrderPlaced $event): void
    {
        Mail::to($event->customerId)->send(
            new OrderConfirmation($event->orderId)
        );
    }
}

4. Architecture Hexagonale (Ports/Adapters)

4.1 Principe

L'architecture hexagonale (Alistair Cockburn) isole le cœur métier des dépendances techniques :

                    ┌──────────────────┐
                    │     HTTP API     │  ← Adapter (entrée)
                    └────────┬─────────┘
                             │ Port (entrée)
                    ┌────────▼─────────┐
                    │                  │
                    │   Cœur Métier    │
                    │  (Domain + App)  │
                    │                  │
                    └────────┬─────────┘
                             │ Port (sortie)
                    ┌────────▼─────────┐
                    │   MySQL / Redis  │  ← Adapter (sortie)
                    └──────────────────┘

4.2 Implémentation

// PORT D'ENTRÉE : interface pour le use case
interface CreateOrderUseCase
{
    public function execute(CreateOrderRequest $request): OrderResponse;
}

// ADAPTER D'ENTRÉE : controller HTTP
class CreateOrderController
{
    public function __construct(private CreateOrderUseCase $useCase) {}

    public function __invoke(Request $request): JsonResponse
    {
        $orderRequest = new CreateOrderRequest(
            customerId: $request->user_id,
            items: $request->items,
        );

        $response = $this->useCase->execute($orderRequest);

        return response()->json($response, 201);
    }
}

// PORT DE SORTIE : interface pour le repository
interface OrderRepositoryInterface
{
    public function save(Order $order): void;
    public function findById(OrderId $id): ?Order;
}

// ADAPTER DE SORTIE : implémentation Eloquent
class EloquentOrderRepository implements OrderRepositoryInterface
{
    public function save(Order $order): void
    {
        OrderModel::updateOrCreate(
            ['uuid' => $order->id->toString()],
            ['data' => serialize($order)]
        );
    }

    public function findById(OrderId $id): ?Order
    {
        $model = OrderModel::where('uuid', $id->toString())->first();
        return $model ? unserialize($model->data) : null;
    }
}

4.3 Tests de l'hexagone

it('creates an order', function () {
    // Arrange
    $repository = Mockery::mock(OrderRepositoryInterface::class);
    $repository->shouldReceive('save')->once();

    $useCase = new CreateOrderUseCaseImpl($repository);
    $request = new CreateOrderRequest(customerId: 1, items: [...]);

    // Act
    $response = $useCase->execute($request);

    // Assert
    expect($response->orderId)->toBeString();
    expect($response->status)->toBe('placed');
});

5. CQRS (Command Query Responsibility Segregation)

5.1 Principe

Séparation des opérations de lecture (Queries) et d'écriture (Commands) :

┌──────┐           ┌───────────────┐           ┌───────┐
│ Client│──Query──▶│  Query Bus    │───▶ Read DB│(Lecture)
└──────┘           └───────────────┘           └───────┘
┌──────┐           ┌───────────────┐           ┌───────┐
│ Client│──Command─▶│  Command Bus  │───▶ Write DB(Écriture)
└──────┘           └───────────────┘           └───────┘

5.2 Command Bus

// Command
class CreatePostCommand
{
    public function __construct(
        public readonly string $title,
        public readonly string $content,
        public readonly ?array $tags
    ) {}
}

// Command Handler
class CreatePostHandler
{
    public function __construct(
        private PostRepository $repository,
        private EventDispatcher $dispatcher
    ) {}

    public function handle(CreatePostCommand $command): void
    {
        $post = Post::create(
            title: $command->title,
            content: $command->content,
        );

        $this->repository->save($post);
        $this->dispatcher->dispatch(new PostCreated($post->id));
    }
}

// Command Bus
class SimpleCommandBus
{
    private array $handlers = [];

    public function register(string $command, string $handler): void
    {
        $this->handlers[$command] = $handler;
    }

    public function dispatch(object $command): void
    {
        $handlerClass = $this->handlers[$command::class]
            ?? throw new HandlerNotFoundException;

        app($handlerClass)->handle($command);
    }
}

5.3 Query Bus

// Query
class GetPublishedPostsQuery
{
    public function __construct(
        public readonly int $page = 1,
        public readonly int $perPage = 15
    ) {}
}

// Query Handler
class GetPublishedPostsHandler
{
    public function __construct(
        private PostRepository $repository
    ) {}

    public function handle(GetPublishedPostsQuery $query): PaginatedResult
    {
        return $this->repository->findPublished(
            page: $query->page,
            perPage: $query->perPage
        );
    }
}

5.4 Package Laravel (brunocfalcao/laravel-cqrs)

class PostController
{
    public function __construct(private CommandBus $commandBus) {}

    public function store(StorePostRequest $request): JsonResponse
    {
        $this->commandBus->dispatch(
            new CreatePostCommand(...$request->validated())
        );

        return response()->json(['message' => 'Post created'], 201);
    }
}

6. Event Sourcing

6.1 Principe

Au lieu de stocker l'état actuel, on stocke la séquence d'événements qui ont mené à cet état :

// Événements
class MoneyDeposited
{
    public function __construct(
        public readonly AccountId $accountId,
        public readonly Money $amount,
        public readonly \DateTimeImmutable $occurredAt
    ) {}
}

class MoneyWithdrawn
{
    public function __construct(
        public readonly AccountId $accountId,
        public readonly Money $amount,
        public readonly \DateTimeImmutable $occurredAt
    ) {}
}

// Aggregate reconstruit depuis les événements
class Account
{
    private Money $balance;

    public static function fromEvents(array $events): self
    {
        $account = new self;
        foreach ($events as $event) {
            $account->apply($event);
        }
        return $account;
    }

    private function apply(object $event): void
    {
        match ($event::class) {
            MoneyDeposited::class => $this->balance = $this->balance->add($event->amount),
            MoneyWithdrawn::class => $this->balance = $this->balance->subtract($event->amount),
            default => null,
        };
    }
}

6.2 Event Store

class EventStore
{
    public function __construct(private DB $database) {}

    public function append(AggregateRoot $aggregate): void
    {
        foreach ($aggregate->releaseEvents() as $event) {
            $this->database->table('events')->insert([
                'aggregate_id' => $aggregate->id(),
                'aggregate_type' => $aggregate::class,
                'event_type' => $event::class,
                'data' => serialize($event),
                'occurred_at' => $event->occurredAt,
            ]);
        }
    }

    public function getEvents(string $aggregateId): array
    {
        $rows = $this->database->table('events')
            ->where('aggregate_id', $aggregateId)
            ->orderBy('occurred_at')
            ->get();

        return $rows->map(fn($row) => unserialize($row->data))->toArray();
    }
}

7. Action Pattern

7.1 Single Action Controller

// ✅ Une classe = une action
class RegisterUserAction
{
    public function __construct(
        private UserRepository $repository,
        private Hasher $hasher,
        private EventDispatcher $events
    ) {}

    public function execute(RegisterUserRequest $request): User
    {
        $user = User::create(
            name: $request->name,
            email: $request->email,
            password: $this->hasher->make($request->password),
        );

        $this->repository->save($user);
        $this->events->dispatch(new UserRegistered($user));

        return $user;
    }
}

// Dans une route
Route::post('/register', function (RegisterUserAction $action) {
    $user = $action->execute(RegisterUserRequest::from(request()));
    return response()->json($user, 201);
});

7.2 Action en tant que Controller invokable

class PublishPostAction
{
    public function __construct(
        private PostRepository $posts,
        private PublisherService $publisher
    ) {}

    public function __invoke(PublishPostRequest $request): RedirectResponse
    {
        $post = $this->posts->findOrFail($request->route('post'));
        $this->publisher->publish($post);

        return redirect()->route('posts.show', $post);
    }
}

// Route
Route::post('/posts/{post}/publish', PublishPostAction::class);

8. SOLID en PHP — Exemples concrets

S — Single Responsibility

// ❌ Trop de responsabilités
class Invoice
{
    public function calculateTotal(): float { /* ... */ }
    public function generatePdf(): string { /* ... */ }
    public function sendEmail(): void { /* ... */ }
    public function saveToDatabase(): void { /* ... */ }
}

// ✅ Une seule responsabilité chacun
class Invoice { public function calculateTotal(): float { /* ... */ } }
class PdfGenerator { public function generate(Invoice $invoice): string { /* ... */ } }
class InvoiceMailer { public function send(Invoice $invoice): void { /* ... */ } }
class InvoiceRepository { public function save(Invoice $invoice): void { /* ... */ } }

O — Open/Closed

interface PaymentMethod
{
    public function pay(Money $amount): PaymentResult;
}

class CreditCardPayment implements PaymentMethod { /* ... */ }
class PayPalPayment implements PaymentMethod { /* ... */ }
class CryptoPayment implements PaymentMethod { /* ... */ }

// Nouveau moyen de paiement ? Ajouter une classe sans modifier l'existant
class ApplePayPayment implements PaymentMethod { /* ... */ }

L — Liskov Substitution

abstract class Bird { abstract public function fly(): void; }

class Sparrow extends Bird {
    public function fly(): void { /* vole */ }
}

// ❌ Violation : un pingouin ne vole pas
class Penguin extends Bird {
    public function fly(): void { throw new CannotFlyException; }
}

// ✅ Correction
abstract class Bird {}
abstract class FlyingBird extends Bird { abstract public function fly(): void; }
class Sparrow extends FlyingBird { public function fly(): void { /* ... */ } }
class Penguin extends Bird {}

I — Interface Segregation

// ❌ Interface obèse
interface WorkerInterface
{
    public function work(): void;
    public function eat(): void;
    public function sleep(): void;
}

// ✅ Interfaces spécifiques
interface Workable { public function work(): void; }
interface Eatable { public function eat(): void; }
interface Sleepable { public function sleep(): void; }

class Human implements Workable, Eatable, Sleepable { /* ... */ }
class Robot implements Workable { /* ... */ }

D — Dependency Inversion

// ❌ Dépendance concrète
class UserController
{
    private MySQLUserRepository $repository;

    public function __construct()
    {
        $this->repository = new MySQLUserRepository();
    }
}

// ✅ Dépendance abstraite
class UserController
{
    public function __construct(
        private UserRepositoryInterface $repository  // ← abstraction
    ) {}
}

// L'injection de dépendances permet de passer n'importe quelle implémentation
$controller = new UserController(new RedisUserRepository());
$controller = new UserController(new APIRepository());

9. Résumé

L'architecture PHP moderne s'organise autour de patterns éprouvés :

  • Service Layer : extraire la logique métier des controllers
  • DDD : modéliser le domaine avec des bounded contexts, entities, value objects
  • Hexagonal : isoler le cœur métier via des ports et adapters
  • CQRS : séparer lectures et écritures pour scaler indépendamment
  • Event Sourcing : stocker l'historique des événements plutôt que l'état
  • Action Pattern : une classe par action pour des controllers ultra-minces
  • SOLID : cinq principes fondamentaux pour un code maintenable

L'architecture n'est jamais "finale" — elle évolue avec les besoins. L'important est de faire des choix cohérents et de les documenter (ADR).