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).