Patterns essentiels pour PHP moderne
Les design patterns sont des solutions éprouvées aux problèmes récurrents de conception logicielle. Voici les plus utiles en PHP, illustrés avec du code PHP 8 et Symfony tel qu'on l'écrit aujourd'hui.
Un pattern n'est pas une bibliothèque qu'on installe ni une règle à appliquer partout : c'est un vocabulaire commun et une forme de solution qui a fait ses preuves. Son intérêt principal est de rendre le code prévisible. Quand un développeur lit PricingStrategy ou ProductRepositoryInterface, il sait immédiatement quel rôle joue la classe et où chercher la logique. Les patterns présentés ici sont ceux que l'on retrouve réellement dans une application Symfony de production : certains sont fournis par le framework lui-même, d'autres s'écrivent en quelques lignes grâce aux fonctionnalités modernes du langage (promotion de propriétés dans le constructeur, attributs, types stricts).
Repository Pattern
Le Repository isole la logique d'accès aux données derrière une interface orientée métier. Le reste de l'application demande « les produits de cette catégorie » sans savoir s'ils viennent de MySQL via Doctrine, d'une API externe ou d'un tableau en mémoire pendant les tests.
interface ProductRepositoryInterface
{
public function findById(int $id): ?Product;
public function findByCategory(string $category): array;
public function save(Product $product): void;
}
class DoctrineProductRepository implements ProductRepositoryInterface
{
public function __construct(
private EntityManagerInterface $em,
) {}
public function findById(int $id): ?Product
{
return $this->em->find(Product::class, $id);
}
public function findByCategory(string $category): array
{
return $this->em->getRepository(Product::class)
->findBy(['category' => $category], ['name' => 'ASC']);
}
public function save(Product $product): void
{
$this->em->persist($product);
$this->em->flush();
}
}
Quelques points importants dans cette implémentation :
- Les services dépendent de
ProductRepositoryInterface, jamais de la classe Doctrine. Dans Symfony, l'autowiring résout automatiquement l'interface vers son unique implémentation. - Les méthodes portent des noms métier (
findByCategory) plutôt que d'exposer le QueryBuilder à l'extérieur : la requête reste dans le repository. - Le type de retour
?Productoblige l'appelant à gérer le cas « introuvable » explicitement.
L'avantage le plus concret apparaît dans les tests unitaires : une implémentation en mémoire remplace la base de données sans aucun mock complexe.
final class InMemoryProductRepository implements ProductRepositoryInterface
{
/** @var array<int, Product> */
private array $products = [];
public function findById(int $id): ?Product
{
return $this->products[$id] ?? null;
}
public function findByCategory(string $category): array
{
return array_values(array_filter(
$this->products,
fn (Product $p): bool => $p->getCategory() === $category,
));
}
public function save(Product $product): void
{
$this->products[$product->getId()] = $product;
}
}
Piège classique : appeler flush() dans chaque save() est simple, mais pénalisant quand on enregistre des centaines d'objets dans une boucle. Pour les traitements par lots, prévoyez une méthode dédiée ou laissez la couche applicative (un handler de commande, par exemple) décider du moment du flush().
Strategy Pattern
La Strategy encapsule une famille d'algorithmes interchangeables derrière une même interface. C'est l'antidote aux longues cascades de if/switch qui grossissent à chaque nouveau cas métier : ajouter une règle de tarification revient à ajouter une classe, sans toucher au code existant (principe ouvert/fermé).
interface PricingStrategy
{
public function calculate(float $basePrice): float;
}
class RegularPricing implements PricingStrategy
{
public function calculate(float $basePrice): float
{
return $basePrice;
}
}
class PremiumPricing implements PricingStrategy
{
public function calculate(float $basePrice): float
{
return $basePrice * 0.8; // 20% de réduction
}
}
Reste à choisir la bonne stratégie à l'exécution. Avec Symfony, le plus propre est de taguer automatiquement toutes les implémentations et de les injecter sous forme d'itérable dans un résolveur. Chaque stratégie indique elle-même si elle s'applique :
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
#[AutoconfigureTag('app.pricing_strategy')]
interface CustomerPricingStrategy extends PricingStrategy
{
public function supports(Customer $customer): bool;
}
final class PricingResolver
{
/** @param iterable<CustomerPricingStrategy> $strategies */
public function __construct(
#[AutowireIterator('app.pricing_strategy')]
private iterable $strategies,
) {}
public function priceFor(Customer $customer, float $basePrice): float
{
foreach ($this->strategies as $strategy) {
if ($strategy->supports($customer)) {
return $strategy->calculate($basePrice);
}
}
throw new \LogicException('Aucune stratégie de prix applicable.');
}
}
L'attribut #[AutowireIterator] est disponible depuis Symfony 6.4 ; sur les versions antérieures, #[TaggedIterator] joue le même rôle. Un conseil au passage : les exemples utilisent float pour rester lisibles, mais pour de vrais montants, préférez des entiers en centimes ou une bibliothèque comme brick/money, afin d'éviter les erreurs d'arrondi.
Observer Pattern avec Symfony Events
L'Observer permet à un objet de notifier d'autres objets qu'il ne connaît pas. Dans Symfony, l'EventDispatcher en est une implémentation complète : le code qui crée une commande publie un événement, et chaque listener réagit de son côté (e-mail de confirmation, mise à jour du stock, statistiques). Ajouter une réaction ne demande aucune modification du code émetteur.
#[AsEventListener(event: OrderCreatedEvent::class)]
class SendOrderConfirmation
{
public function __construct(
private MailerInterface $mailer,
) {}
public function __invoke(OrderCreatedEvent $event): void
{
$this->mailer->send(
new OrderConfirmationEmail($event->getOrder())
);
}
}
Côté émetteur, il suffit de dispatcher l'événement une fois la commande enregistrée :
final class OrderService
{
public function __construct(
private EventDispatcherInterface $dispatcher,
) {}
public function place(Order $order): void
{
// ... persistance de la commande
$this->dispatcher->dispatch(new OrderCreatedEvent($order));
}
}
Attention : les listeners de l'EventDispatcher s'exécutent de manière synchrone, dans la même requête HTTP. Si l'envoi de l'e-mail échoue ou prend deux secondes, c'est l'utilisateur qui attend. Pour les traitements lents ou faillibles, publiez plutôt un message avec Symfony Messenger et traitez-le dans un worker asynchrone. Autre piège : un listener ne doit pas dépendre de l'ordre d'exécution des autres ; si c'est nécessaire, utilisez le paramètre priority de l'attribut, mais c'est souvent le signe d'un couplage caché.
Builder Pattern
Le Builder construit un objet complexe étape par étape grâce à une interface fluide. Il évite les constructeurs à dix paramètres optionnels et rend le code appelant lisible comme une phrase. Doctrine (QueryBuilder), Symfony Mailer (Email) ou le composant Form (FormBuilder) l'utilisent abondamment.
class QueryBuilder
{
private array $conditions = [];
private ?int $limit = null;
public function where(string $field, mixed $value): self
{
$this->conditions[$field] = $value;
return $this;
}
public function limit(int $limit): self
{
$this->limit = $limit;
return $this;
}
public function build(): Query
{
return new Query($this->conditions, $this->limit);
}
}
Utilisation :
$query = (new QueryBuilder())
->where('status', 'published')
->where('category', 'php')
->limit(10)
->build();
La méthode build() est le bon endroit pour valider la cohérence de l'ensemble (champs obligatoires, combinaisons interdites) et lever une exception avant de produire un objet invalide. Idéalement, l'objet produit (Query) est immuable : le Builder est mutable, le résultat ne l'est pas.
Bonus : Decorator avec Symfony
Le Decorator ajoute un comportement à un service sans le modifier, en l'enveloppant dans une classe qui implémente la même interface. C'est idéal pour le cache, les logs ou les métriques. Symfony le prend en charge nativement avec l'attribut #[AsDecorator] :
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
#[AsDecorator(decorates: ProductRepositoryInterface::class)]
final class CachedProductRepository implements ProductRepositoryInterface
{
public function __construct(
#[AutowireDecorated]
private ProductRepositoryInterface $inner,
private CacheInterface $cache,
) {}
public function findById(int $id): ?Product
{
return $this->inner->findById($id);
}
public function findByCategory(string $category): array
{
return $this->cache->get(
'products_category_'.md5($category),
function (ItemInterface $item) use ($category): array {
$item->expiresAfter(300);
return $this->inner->findByCategory($category);
},
);
}
public function save(Product $product): void
{
$this->inner->save($product);
}
}
Tous les services qui dépendent de ProductRepositoryInterface reçoivent désormais la version avec cache, sans qu'aucune ligne de leur code change. Attention toutefois à mettre en cache des entités Doctrine : une fois désérialisées, elles ne sont plus gérées par l'EntityManager. Pour du cache, préférez des identifiants ou des DTO en lecture seule.
Récapitulatif
- Repository : abstraction de la couche de persistance
- Strategy : algorithmes interchangeables
- Observer : découplage par événements
- Builder : construction d'objets complexes
- Decorator : ajout de comportement (cache, logs) sans modifier le service d'origine
Quand ne pas utiliser un pattern
Le risque principal des design patterns n'est pas de les ignorer, mais de les appliquer partout. Une interface avec une seule implémentation qui n'a aucune chance d'en avoir une deuxième, une Strategy pour deux cas qui ne changeront jamais, une Factory qui se contente d'appeler new : tout cela ajoute des fichiers et de l'indirection sans rien apporter. Quelques repères :
- Commencez par le code le plus simple ; introduisez un pattern quand un deuxième cas concret apparaît, pas « au cas où ».
- Évitez le Singleton : dans une application Symfony, le conteneur de services partage déjà une instance unique, et un état global rend les tests fragiles.
- Utilisez les patterns que le framework fournit (événements, décoration, services tagués) plutôt que de les réimplémenter.
- Nommez les classes selon leur rôle métier ; le nom du pattern peut apparaître, mais il ne doit pas remplacer le sens.
Bien employés, ces quelques patterns suffisent à structurer la grande majorité des applications PHP : la persistance derrière des repositories, les règles variables dans des stratégies, les effets de bord dans des listeners, et les préoccupations transverses dans des décorateurs.