Loïc Bonin
← Veille

backend

Les Design Patterns appliqués à Laravel

19 juin 2026·10 min de lecture


Les Design Patterns

Les Design Patterns sont des schémas d'organisation standards pour les problèmes d'architecture récurrents.

Voici un rappel des design patterns principaux et leur applications avec le framework Laravel.

Les Patterns de Création

L'objectif est d'instancier les objets de manière simple et lisible, en regroupant ou masquant la logique de création des objets.

Singleton (Instance unique)

Il sert à garantir qu'une classe n'aura qu'une seule et unique instance vivante en mémoire pendant toute l'exécution du script, et fournir un point d'accès global à celle-ci. Avec Laravel, il est géré par le Conteneur du Framework

code
namespace App\Services;
 
//use Illuminate\Container\Attributes\Singleton; // Pour éviter de toucher au Service Provider,
// Depuis Laravel v12.21 on peu maintenant mettre l'attribution Singleton directement sur la classe.
 
// #[Singleton] // Laravel détecte automatiquement qu'il doit le traiter comme un singleton
class HeavyConfigManager
{
public array $config = [];
 
public function __construct()
{
// Une opération lourde qu'on ne veut faire qu'une seule fois
$this->config = json_decode(file_get_contents('https://api.external.com/config'), true);
}
}
code
namespace App\Providers;
// La methode la plus robuste reste de configurer dans le AppServiceProvider
use Illuminate\Support\ServiceProvider;
use App\Services\HeavyConfigManager;
use Illuminate\Contracts\Foundation\Application;
 
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
// On dit à Laravel : "Si quelqu'un demande HeavyConfigManager, donne lui toujours la MÊME instance"
$this->app->singleton(HeavyConfigManager::class, function (Application $app) {
return new HeavyConfigManager();
});
}
}
code
//Utilisation dans un Controller
namespace App\Http\Controllers;
 
use App\Services\HeavyConfigManager;
 
class OrderController extends Controller
{
public function __construct(
private HeavyConfigManager $configManager // Instance unique injectée ici
) {}
}

Scoped (Instance unique sur une seule requête HTTP)

Sert pour stocker un état temporaire comme un utilisateur connecté ou des données de formulaires ,pour éviter que des données propre à une session utilisateur se retrouve sur une autre session

code
namespace App\Providers;
 
use Illuminate\Support\ServiceProvider;
use App\Services\HeavyConfigManager;
 
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
// C'est ici que la magie opère pour la requête en cours
$this->app->scoped(HeavyConfigManager::class, function () {
return new HeavyConfigManager();
});
}
}

Bind (Injection d'implémentation simplifié)

ermet de déclarer directement sur une interface quelle implémentation injecter, avec un support natif des environnements (local, testing, production). Cela élimine le binding manuel dans AppServiceProvider pour les cas simples.

code
declare(strict_types=1);
 
namespace App\Contracts;
 
use App\Services\Sms\FastSmsAdapter;
use App\Services\Sms\FakeSmsSender;
use Illuminate\Container\Attributes\Bind;
 
// En production : FastSmsAdapter. En local/testing : FakeSmsSender. Zéro ligne dans AppServiceProvider.
#[Bind(FastSmsAdapter::class)]
#[Bind(FakeSmsSender::class, environments: ['local', 'testing'])]
interface SmsSenderInterface
{
public function send(string $phone, string $message): bool;
}

Combinaison avec Singleton : les deux attributs sont cumulables sur la même interface. Laravel traite l'implémentation comme un singleton ET résout automatiquement le binding. Aucune ligne dans AppServiceProvider.

code
#[Bind(HeavyConfigManager::class)]
#[Singleton]
interface ConfigManagerInterface { ... }

La Factory (Fabrique / Fabrique Abstraite)

C'est une usine à objets. Par exemple, au lieu de faire un new CompteBancaire() directement dans un contrôleur, on passes par une classe CompteFactory. C'est elle qui encapsule la logique d'instanciation, les vérifications initiales et l'injection des dépendances requises pour cet objet.

code
declare(strict_types=1);
 
namespace App\Factories;
 
use App\Contracts\CompteBancaireInterface;
use App\Services\CompteCourant;
use App\Services\CompteEpargne;
use Psr\Log\LoggerInterface;
use InvalidArgumentException;
 
class CompteFactory
{
// On laisse Laravel injecter le Logger automatiquement dans la Factory
public function __construct(private LoggerInterface $logger) {}
 
public function make(string $type): CompteBancaireInterface
{
// On utilise l'expression match de PHP 8 pour une structure propre et stricte
return match ($type) {
'courant' => new CompteCourant($this->logger),
'epargne' => new CompteEpargne($this->logger, (float) config('banque.taux_epargne')),
default => throw new InvalidArgumentException("Le type de compte '{$type}' n'existe pas."),
};
}
}

Pour un peu plus de contexte, on peu imaginer une interface partagé par deux classes produits :

code
declare(strict_types=1);
 
namespace App\Contracts;
 
interface CompteBancaireInterface
{
public function calculerFrais(): float;
}
code
namespace App\Services;
 
use App\Contracts\CompteBancaireInterface;
use Psr\Log\LoggerInterface;
 
class CompteCourant implements CompteBancaireInterface
{
public function __construct(private LoggerInterface $logger) {}
 
public function calculerFrais(): float {
$this->logger->info('Calcul des frais pour un compte courant');
return 5.50;
}
}
 
class CompteEpargne implements CompteBancaireInterface
{
public function __construct(
private LoggerInterface $logger,
private float $tauxInteret
) {}
 
public function calculerFrais(): float {
$this->logger->info('Calcul des frais pour un compte épargne');
return 0.0 - $this->tauxInteret; // L'épargne rapporte de l'argent
}
}

Le controller n'a plus qu'à déléguer la fabrication de compte:

code
declare(strict_types=1);
 
namespace App\Http\Controllers;
 
use App\Factories\CompteFactory;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
 
class BankController extends Controller
{
public function __construct(private CompteFactory $compteFactory) {}
 
public function openAccount(Request $request): JsonResponse
{
$type = $request->input('type'); // 'courant' ou 'epargne'
 
try {
// La Factory fait tout le travail lourd d'instanciation
$compte = $this->compteFactory->make($type);
return response()->json([
'success' => true,
'frais' => $compte->calculerFrais(),
]);
} catch (\InvalidArgumentException $e) {
return response()->json(['error' => $e->getMessage()], 400);
}
}
}
Le Pattern de Structure (Comment on assemble les classes)

Ils aident à faire cohabiter des classes qui n'ont pas été conçues pour fonctionner ensemble à l'origine.

L'Adapter (Adaptateur / traducteur universel)

Pour une interface interne qui envoie des SMS (SmsSenderInterface) avec une méthode send(), une classe Adapter ici peu permettre de changer de prestataire et d'utiliser une bibliothèque tierce qui utilise une nouvelle méthode pushNotification()en implémentant l'interface qui appelle en interne la méthode du prestataire. Pratique pour le découplage et ne pas se retrouvé prisonnier d'un SDK tiers ou d'une API externe. Il suffit de créer un nouvel Adapter et de modifier une ligne dans AppServiceProvider. Ça facilite aussi les tests unitaires (dans cette exemple de code, pour SmsSenderInterface) et ça respecte les principes SOLID via l'inversion des dépendances.

Interface

code
declare(strict_types=1);
 
namespace App\Contracts;
 
interface SmsSenderInterface
{
public function send(string $phone, string $message): bool;
}

Intégration du SDK tiers

code
namespace SuperSmsSdk;
 
class FastNotificationService
{
public function pushNotification(string $receiverNumber, string $textContent): array
{
// Logique interne du SDK externe pour acheminer le SMS
return ['status' => 'success', 'message_id' => 12345];
}
}

L'Adapter

code
declare(strict_types=1);
 
namespace App\Services\Sms;
 
use App\Contracts\SmsSenderInterface;
use SuperSmsSdk\FastNotificationService;
 
class FastSmsAdapter implements SmsSenderInterface
{
// Injection du SDK tiers dans l'adaptateur
public function __construct(
private FastNotificationService $sdk
) {}
 
public function send(string $phone, string $message): bool
{
// 1. Traduction de la demande pour le SDK tiers
$response = $this->sdk->pushNotification(
receiverNumber: $phone,
textContent: $message
);
 
// 2. Adaptation de la réponse pour retourner le booléen attendu par l'application
return $response['status'] === 'success';
}
}

L'association dans le AppServiceProvider

code
namespace App\Providers;
 
use Illuminate\Support\ServiceProvider;
use App\Contracts\SmsSenderInterface;
use App\Services\Sms\FastSmsAdapter;
 
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
// Liaison globale de l'interface à l'adaptateur
$this->app->bind(SmsSenderInterface::class, FastSmsAdapter::class);
}
}

Utilisation dans un controller

code
declare(strict_types=1);
 
namespace App\Http\Controllers;
 
use App\Contracts\SmsSenderInterface;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
 
class AuthController extends Controller
{
public function __construct(
private SmsSenderInterface $smsSender
) {}
 
public function sendVerificationCode(Request $request): JsonResponse
{
// Le code de l'application reste propre, standard et découplé
$this->smsSender->send('+33612345678', 'Code de vérification : 4921');
 
return response()->json(['status' => 'Code envoyé']);
}
}
Les Patterns de Comportement (Comment les objets communiquent)

Ils gèrent la répartition des responsabilités entre les objets et la manière dont les algorithmes s'exécutent.

Le pattern Strategy (Algorithmes interchangeables)

C'est un patron de conception comportemental qui permet de basculer d'un algorithme à un autre de manière transparente à l'exécution. Utile pour définir une famille d'algorithmes, les encapsuler chacun dans une classe distincte (qui implémente la même interface), et les rendre interchangeables à la volée selon le contexte.

L'exemple type, c'est le paiement: une interface PaymentStrategy. Ensuite, des stratégies PaypalStrategy, StripeStrategy et VirementStrategy. Le code métier appelle juste payer(), sans se soucier de la méthode sélectionnée par l'utilisateur.

interface

code
declare(strict_types=1);
 
namespace App\Contracts;
 
interface PaymentStrategyInterface
{
public function payer(float $montant): bool;
}

Les stratégies

code
namespace App\Services\Payment;
 
use App\Contracts\PaymentStrategyInterface;
use Illuminate\Support\Facades\Http;
 
class StripeStrategy implements PaymentStrategyInterface
{
public function payer(float $montant): bool
{
// Logique spécifique à l'API Stripe
// Ex: $stripe->charges->create([...]);
return true;
}
}
 
class PaypalStrategy implements PaymentStrategyInterface
{
public function payer(float $montant): bool
{
// Logique spécifique à l'API PayPal
return true;
}
}
 
class VirementStrategy implements PaymentStrategyInterface
{
public function payer(float $montant): bool
{
// Logique pour générer un ordre de virement bancaire
return true;
}
}

Le résolveur de stratégie, qui se charge de retourner la bonne stratégie

code
declare(strict_types=1);
 
namespace App\Services\Payment;
 
use App\Contracts\PaymentStrategyInterface;
use Illuminate\Contracts\Container\Container;
use InvalidArgumentException;
 
readonly class PaymentContextResolver
{
// L'injection du conteneur permet d'instancier les stratégies à la volée avec leurs dépendances
public function __construct(private Container $container) {}
 
public function resolve(string $methode): PaymentStrategyInterface
{
return match ($methode) {
'stripe' => $this->container->make(StripeStrategy::class),
'paypal' => $this->container->make(PaypalStrategy::class),
'virement' => $this->container->make(VirementStrategy::class),
default => throw new InvalidArgumentException("Le moyen de paiement '{$methode}' n'est pas supporté."),
};
}
}

L'Observer (Observateur) Plus connu comme Événement / Écouteur (Event / Listener). Un objet maintient une liste de ses dépendances (les observateurs ou listeners) et les notifie automatiquement de tout changement d'état, qui s'activent pour exécuter leur logique métier. L'écosystème Laravel intègre ce pattern nativement au cœur de son framework à travers le système d'Events (le Sujet) et de Listeners (les Observateurs).

Depuis Laravel 11, l'EventServiceProvider a été supprimé du skeleton. L'Event Discovery est désormais activée par défaut : Laravel scanne automatiquement le dossier app/Listeners et détecte les événements grâce au type-hint de la méthode handle(). Il ne faut donc pas combiner le câblage manuel dans AppServiceProvider avec l'Event Discovery — cela provoquerait l'exécution des listeners deux fois.

Le Sujet (L'Événement)
code
declare(strict_types=1);
 
namespace App\Events;
 
use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
 
class OrderPlaced
{
use Dispatchable, SerializesModels;
 
// Utilisation des propriétés readonly de PHP 8 pour sécuriser les données de l'événement
public function __construct(
public readonly Order $order
) {}
}
Les Observateurs / Listeners

C'est la méthode handle() qui reçoit l'évènement en argument. Laravel détecte automatiquement l'événement à écouter via le type-hint OrderPlaced $event dans handle() L'ajout de l'interface ShouldQueue aux Listeners permet l'exécution de manière asynchrone.

code
namespace App\Listeners;
 
use App\Events\OrderPlaced;
use App\Mail\OrderConfirmationMail;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Mail;
 
class SendOrderConfirmationEmail implements ShouldQueue
{
public function handle(OrderPlaced $event): void
{
// Accès direct aux données de la commande via l'événement
Mail::to($event->order->user->email)
->send(new OrderConfirmationMail($event->order));
}
}
code
namespace App\Listeners;
 
use App\Events\OrderPlaced;
use App\Services\InventoryService;
 
class UpdateInventoryStock
{
public function __construct(private InventoryService $inventoryService) {}
 
public function handle(OrderPlaced $event): void
{
// Décrémentation du stock de manière synchrone
$this->inventoryService->reduceStockForOrder($event->order);
}
}
L'enregistrement (Le Câblage)

Si vos listeners sont dans un dossier non-standard, ou si l'Event Discovery a été désactivé via le fichier bootstrap/app.php ( ->withEvents(discover: []) ), le câblage se fait dans AppServiceProvider::boot().

code
namespace App\Providers;
 
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Event;
use App\Events\OrderPlaced;
use App\Listeners\SendOrderConfirmationEmail;
use App\Listeners\UpdateInventoryStock;
 
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
// Association de l'événement à ses multiples observateurs
// ⚠️ N'utiliser QUE si l'Event Discovery est désactivée
// Sinon chaque listener s'exécutera DEUX fois.
Event::listen(
OrderPlaced::class,
[
UpdateInventoryStock::class,
SendOrderConfirmationEmail::class,
]
);
}
}
Le déclenchement (Dispatch)

Émission de l'évènement après validation de la logique métier

code
declare(strict_types=1);
 
namespace App\Http\Controllers;
 
use App\Models\Order;
use App\Events\OrderPlaced;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
 
class CheckoutController extends Controller
{
public function store(Request $request): JsonResponse
{
// 1. Logique principale : Création de la commande
$order = Order::create([
'user_id' => $request->user()->id,
'total' => $request->input('total'),
'status' => 'paid',
]);
 
// 2. Notification des observateurs via le pattern Observer
OrderPlaced::dispatch($order);
 
return response()->json([
'success' => true,
'message' => 'Commande enregistrée avec succès.',
'order_id' => $order->id
], 201);
}
}

Les Patterns d'Architecture (Accès aux données)

Un pattern qui a été formalisé pour le contexte d'architecture applicative , pas pour la conception pure de code.

Le Service Layer (Couche de Service)

C'est la couche qui orchestre la logique métier de l'application. Elle se positionne entre le Controller et le Repository, et c'est elle qui prend les décisions : quoi récupérer, quoi valider, quoi déclencher. Ça permet un découpage propre des rôles attribué à chaque couche:

  • Le Controller ne fait que recevoir la requête HTTP et retourner une réponse. Il délègue immédiatement au Service.

*

  • Le Service contient toute la logique métier : validations, calculs, orchestration d'autres services, dispatch d'événements.

*

  • Le Repository ne fait qu'exécuter des requêtes. Il ne connaît pas la logique métier.
code
declare(strict_types=1);
 
namespace App\Services;
 
use App\Contracts\Repositories\OrderRepositoryInterface;
use App\Events\OrderPlaced;
use App\Models\Order;
use Illuminate\Support\Facades\DB;
 
class OrderService
{
public function __construct(
private OrderRepositoryInterface $orderRepository
) {}
 
public function createOrder(int $userId, float $total): Order
{
// Toute la logique métier est ici, pas dans le Controller
return DB::transaction(function () use ($userId, $total) {
$order = $this->orderRepository->create([
'user_id' => $userId,
'total' => $total,
'status' => 'paid',
]);
 
// Le Service orchestre aussi le dispatch des événements
OrderPlaced::dispatch($order);
 
return $order;
});
}
}

Le Controller ne contient aucune logique :

code
declare(strict_types=1);
 
namespace App\Http\Controllers;
 
use App\Services\OrderService;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
 
class CheckoutController extends Controller
{
public function __construct(private OrderService $orderService) {}
 
public function store(Request $request): JsonResponse
{
// Le Controller reçoit, délègue, retourne. C'est tout.
$order = $this->orderService->createOrder(
userId: $request->user()->id,
total: $request->input('total'),
);
 
return response()->json([
'success' => true,
'order_id' => $order->id,
], 201);
}
}

Avec cette architecture, un même Service peut être appelé depuis un Controller HTTP, une commande Artisan (php artisan orders:reprocess), un Listener ou un Job, sans dupliquer la moindre ligne de logique métier. Le Controller n'est qu'un point d'entrée parmi d'autres.

Le Repository Pattern (Dépot de données)

Sous Laravel, il consiste à abstraire toute la couche d'accès aux données (Eloquent, base externe, cache, API) derrière une interface, afin que le code métier (Services, Controllers) ne dépende jamais directement d'Eloquent. Ça découple la logique métier de la persistance (on peut passer d'Eloquent à une API tierce sans toucher aux Services) Ça rend les tests unitaires simples. Et ça centralise toutes les requêtes de données en un seul endroit.

L'interface :

code
declare(strict_types=1);
 
namespace App\Contracts\Repositories;
 
use App\Models\Order;
use Illuminate\Support\Collection;
 
interface OrderRepositoryInterface
{
public function findById(int $id): Order;
public function findByUserId(int $userId): Collection;
public function create(array $data): Order;
}

L'implémentation Eloquent :

code
declare(strict_types=1);
 
namespace App\Repositories;
 
use App\Contracts\Repositories\OrderRepositoryInterface;
use App\Models\Order;
use Illuminate\Support\Collection;
 
class EloquentOrderRepository implements OrderRepositoryInterface
{
public function findById(int $id): Order
{
return Order::findOrFail($id);
}
 
public function findByUserId(int $userId): Collection
{
return Order::where('user_id', $userId)->get();
}
 
public function create(array $data): Order
{
return Order::create($data);
}
}

Le binding dans AppServiceProvider :

code
namespace App\Providers;
 
use Illuminate\Support\ServiceProvider;
use App\Contracts\Repositories\OrderRepositoryInterface;
use App\Repositories\EloquentOrderRepository;
 
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(OrderRepositoryInterface::class, EloquentOrderRepository::class);
}
}

Utilisation dans un Service

code
declare(strict_types=1);
 
namespace App\Services;
 
use App\Contracts\Repositories\OrderRepositoryInterface;
use Illuminate\Support\Collection;
 
class OrderService
{
public function __construct(
private OrderRepositoryInterface $orderRepository
) {}
 
public function getOrdersForUser(int $userId): Collection
{
// Le Service ne sait pas que c'est Eloquent derrière. Ça pourrait être Redis ou une API.
return $this->orderRepository->findByUserId($userId);
}
}

Fake pour les tests unitaires

code
//On injecte FakeOrderRepository dans les tests : zéro base de données, exécution instantanée.
class FakeOrderRepository implements OrderRepositoryInterface
{
private array $orders = [];
 
public function create(array $data): Order
{
$order = new Order($data);
$this->orders[] = $order;
return $order;
}
// ...
}

LaravelPHP