Chaque développeur venu du monde des frameworks généralistes (Symfony, Laravel) et découvrant WordPress se pose tôt ou tard la même question : comment organiser proprement les classes d’une extension un peu ambitieuse ? Le Singleton, longtemps la norme de facto dans l’écosystème WordPress, cède progressivement du terrain face à des approches plus proches de l’injection de dépendances. Aucune des deux n’est universellement supérieure : le contexte de l’extension doit guider le choix.
Cet article compare trois architectures concrètes, avec leurs compromis réels plutôt qu’une préférence dogmatique importée d’un autre écosystème.
Le Singleton : simple, mais pas sans coût
namespace Acme\MonExtension;
class Plugin {
private static ?Plugin $instance = null;
public static function instance(): Plugin {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
private function __construct() {
// initialisation
}
private function __clone() {}
}
add_action( 'plugins_loaded', [ Plugin::class, 'instance' ] );
L’intérêt du Singleton dans un contexte WordPress est réel : une extension n’a, par définition, besoin que d’une seule instance de son point d’entrée principal, et l’accès global via Plugin::instance() évite de faire circuler l’objet dans tout le code. Le revers de la médaille apparaît dès qu’on écrit des tests unitaires : l’état statique partagé entre les tests rend l’isolation difficile, et un test qui modifie une propriété de l’instance peut affecter le test suivant si le Singleton n’est pas explicitement réinitialisé entre chaque cas.
Le service container léger, un entre-deux pragmatique
Plutôt qu’un Singleton unique qui centralise tout, un conteneur de services léger permet d’enregistrer et de résoudre des dépendances sans imposer la complexité d’un framework DI complet avec autowiring par réflexion.
namespace Acme\MonExtension;
class Container {
private array $services = [];
private array $instances = [];
public function set( string $id, callable $factory ): void {
$this->services[ $id ] = $factory;
}
public function get( string $id ) {
if ( ! isset( $this->instances[ $id ] ) ) {
$this->instances[ $id ] = ( $this->services[ $id ] )( $this );
}
return $this->instances[ $id ];
}
}
$container = new Container();
$container->set( 'logger', function () {
return new Logger();
} );
$container->set( 'stock_service', function ( Container $c ) {
return new StockService( $c->get( 'logger' ) );
} );
Cette approche conserve la testabilité : dans un test unitaire, il suffit d’enregistrer un faux logger (un mock) sous l’identifiant logger avant de résoudre stock_service, sans avoir à modifier le code métier. C’est un gain concret par rapport au Singleton pour toute extension qui prévoit une suite de tests sérieuse.

Les hooks WordPress, une contrainte que les frameworks n’ont pas
Une différence structurelle sépare WordPress des frameworks MVC classiques : le système de hooks fonctionne par callbacks enregistrés globalement, pas par injection dans un contrôleur appelé une seule fois par requête. Cela signifie que l’instanciation d’une classe et son enregistrement auprès des hooks WordPress doivent se faire tôt, généralement sur plugins_loaded ou init, quelle que soit l’architecture choisie.
add_action( 'plugins_loaded', function () use ( $container ) {
$container->get( 'stock_service' )->enregistrer_hooks();
} );
class StockService {
public function __construct( private Logger $logger ) {}
public function enregistrer_hooks(): void {
add_action( 'save_post_produit', [ $this, 'verifier_stock' ], 10, 1 );
}
public function verifier_stock( int $post_id ): void {
$this->logger->info( "Vérification du stock pour le produit {$post_id}" );
}
}
Ce détail explique pourquoi l’autowiring complet par réflexion, courant dans Symfony, apporte peu de valeur ajoutée en contexte WordPress : le nombre de classes à instancier au démarrage reste généralement limité, et le coût de performance d’une réflexion PHP à chaque requête (sans cache de compilation dédié, contrairement à un conteneur Symfony compilé) n’est pas toujours justifié pour une extension de taille moyenne.
Comparatif des trois approches
| Approche | Simplicité | Testabilité | Adapté à |
|---|---|---|---|
| Singleton simple | Élevée | Faible sans précaution | Petite extension, peu de dépendances |
| Service container maison | Moyenne | Bonne | Extension de taille moyenne à grande |
| Framework DI complet (autowiring) | Faible (courbe d’apprentissage) | Très bonne | Suite d’extensions partageant une base commune |
Sur nos projets, le seuil de bascule vers un service container maison se situe généralement autour de la dizaine de classes de service actives : en dessous, le Singleton reste largement suffisant et plus rapide à écrire ; au-delà, la testabilité gagnée compense largement la légère complexité additionnelle.
Un compromis à éviter : le Singleton qui devient un fourre-tout
Le vrai risque architectural n’est pas le choix initial entre ces approches, mais l’évolution non maîtrisée d’un Singleton simple vers une classe qui accumule, au fil des versions, la responsabilité de dix domaines fonctionnels différents. Ce symptôme, souvent appelé classe « Dieu », rend le code difficile à faire évoluer indépendamment par domaine. Un signal d’alerte fiable : si la classe Plugin dépasse quelques centaines de lignes ou plus de cinq responsabilités distinctes, c’est le moment de déléguer vers des classes de service dédiées, avec ou sans conteneur.
En résumé
Aucune architecture n’est universellement supérieure en contexte WordPress. Le Singleton reste un choix raisonnable pour une extension modeste ; un service container léger apporte une testabilité réelle dès que la complexité augmente ; un framework DI complet ne se justifie que pour une suite d’extensions partageant une base de code commune conséquente. Le vrai signal à surveiller reste la croissance non maîtrisée des responsabilités d’une classe unique, quelle que soit l’architecture retenue au départ.