Flotte

Pourquoi une seule extension peut faire tomber tout un site

Heckgalerie 1.2.5 et Leadlotse 1.4.3, deux de nos extensions du répertoire WordPress, provoquaient ensemble une erreur fatale sur chaque page d’un site. Aucune extension n’était défectueuse à elle seule. Le problème venait d’une bibliothèque que cinq de nos extensions embarquent, et pour une bonne part de deux particularités de Composer et de WordPress que nous avions négligées. Cela concerne quiconque partage du code entre extensions.

Le message d’erreur est court. Le voici, reproduit dans WordPress Playground :

PHP Fatal error:  Uncaught Error: Call to undefined method
Hafenstudios\Core\Admin\DesignSystem::action_button()
in /wordpress/wp-content/plugins/heckgalerie/heckgalerie.php:163

Le plus curieux : la méthode existe. Heckgalerie l’apporte dans sa propre copie de la bibliothèque. PHP a pourtant chargé une copie plus ancienne venant d’une autre extension, Leadlotse, qui ne l’avait pas encore.

L’erreur était de notre fait. Mais deux de ses trois couches tiennent à la façon dont Composer et WordPress interagissent, et elles concernent quiconque partage du code entre extensions. C’est pourquoi nous les décrivons ici.

Une bibliothèque, cinq copies

Heckgalerie, Kurato, Leadlotse, Wellenbrecher et Wimpel partagent une bibliothèque, le Hafenstudios Core (à ne pas confondre avec notre extension Hafen Core) : réglages, événements, envoi d’e-mails, journal des consentements, briques des écrans d’administration. C’est par elle que les extensions travaillent ensemble. Quand un formulaire Leadlotse signale une nouvelle demande, Kurato écoute le même événement et crée un contact.

Chaque extension apporte sa propre copie, via Composer dans vendor/hafenstudios/core. C’est obligatoire : une extension du répertoire doit aussi fonctionner seule. Et comme chaque extension a son propre rythme de versions, un site réunit vite plusieurs versions côte à côte :

Extensiondans le répertoire jusqu’au 11/10/2026Core embarquédepuis le 12/10/2026Core embarqué
Heckgalerie1.2.51.4.01.2.61.4.4
Leadlotse1.4.31.3.01.5.11.4.4
Wellenbrecher1.3.21.4.01.3.31.4.4
Wimpel1.2.01.4.11.3.11.4.4

Dans une requête, PHP ne connaît qu’une classe par nom. Celui qui charge Hafenstudios\Core\Core en premier la fixe pour tous. La réponse habituelle vient de l’univers WooCommerce : Action Scheduler est embarqué par de nombreuses extensions, chaque copie enregistre sa version, et sur le hook plugins_loaded seule la plus haute est chargée. C’est exactement ce que nous avions construit.

Ce choix repose sur deux conditions auxquelles on pense rarement. Chaque copie doit s’enregistrer. Et la copie choisie doit réellement fournir les classes. Aucune des deux n’était remplie.

Premier constat : Composer se place en tête

Composer génère un autoloader distinct pour chaque extension. Il s’enregistre avec l’option prepend, c’est-à-dire en tête de la file que PHP interroge pour les classes inconnues. C’est ce qu’indique le fichier vendor/composer/autoload_real.php généré :

$loader->register(true);

WordPress charge les extensions actives dans l’ordre de la liste active_plugins, qu’il trie par ordre alphabétique à l’activation. Chaque extension supplémentaire place son chargeur Composer devant les précédents. Au final, le premier de la file est celui de l’extension dont le dossier vient en dernier dans l’alphabet.

Notre choix de version intervenait ensuite, sur plugins_loaded, et ajoutait le chargeur de la copie choisie en fin de file. Quand une recherche arrivait jusque-là, un chargeur Composer avait répondu depuis longtemps. Nous l’avons reproduit sans WordPress, avec les vrais paquets du répertoire de Heckgalerie 1.2.5 et Leadlotse 1.4.3, dans l’ordre où WordPress les charge. Le résultat, en résumé :

Entrées dans __composer_autoload_files :  1
Inscrites pour le choix :                Core 1.4.0 (Heckgalerie)
Choisie :                                Core 1.4.0 de Heckgalerie
DesignSystem fourni par :                Leadlotse (Core 1.3.0)
action_button() présente :               non

Le choix annonçait 1.4.0, le code exécuté était le 1.3.0. Notre propre contrôle de version ne lisait que la constante fixée par le choix, et n’a donc rien vu.

À quel point le résultat était aléatoire, un essai dans Playground avec les quatre anciennes extensions le montre : le site fonctionnait. Wellenbrecher vient après Leadlotse dans l’alphabet, son chargeur passait donc devant, et sa copie (1.4.0) connaît la méthode. Wimpel vient encore plus tard dans l’alphabet, mais sa version 1.2.0 apporte son propre chargeur au lieu de celui de Composer, et celui-ci se place en fin de file. Qu’un site fonctionne ou non dépendait de l’ordre des noms des extensions.

La première correction, Core 1.4.2 du 6 octobre, enregistre elle aussi le chargeur de la copie choisie avec prepend. En plus, les nouvelles versions de nos extensions interceptent les erreurs au démarrage et les signalent dans l’administration au lieu d’entraîner le site dans leur chute. Cette correction n’a été livrée que le 12 octobre, avec les deux suivantes.

Deuxième constat : le fichier de démarrage ne s’exécute qu’une fois

Pour du code qui doit s’exécuter dès le chargement, comme des fonctions ou un enregistrement, Composer propose l’autoload files. Notre bibliothèque l’utilise pour son bootstrap.php, dans lequel chaque copie s’inscrit pour le choix. Le composer.json de la bibliothèque contient entre autres :

"autoload": {
    "files": [ "bootstrap.php" ]
}

Composer en tire ce verrou, qu’il écrit dans chaque autoloader :

if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) {
    $GLOBALS['__composer_autoload_files'][$fileIdentifier] = true;
    require $file;
}

Composer construit l’identifiant $fileIdentifier comme le md5 du nom du paquet et du chemin. Pour notre fichier de démarrage, cela donne md5('hafenstudios/core:bootstrap.php'), soit b6716d317db73fd5f2cd1d7c8ab2ffd8, la même valeur dans toutes les extensions. Et $GLOBALS vaut pour toute la requête, pas pour une seule extension. Dès que le fichier de démarrage de la première extension s’est exécuté, l’identifiant est noté, et Composer saute le fichier dans toutes les autres.

Dans un projet unique, c’est le bon comportement : un fichier de fonctions ne s’exécute pas deux fois et PHP ne s’arrête pas sur « Cannot redeclare ». Sur un site WordPress avec plusieurs dossiers vendor, le même verrou frappe des copies différentes dans des versions différentes.

Pour notre choix de version, cela signifiait qu’en règle générale seule s’inscrivait la copie de l’extension que WordPress charge en premier, le plus souvent Heckgalerie chez nous. Dans la reproduction ci-dessus, il y a exactement une entrée notée, et Leadlotse n’apparaît pas parmi les copies inscrites, alors qu’il apporte la sienne. Le choix n’avait qu’un seul candidat. Nous ne l’avons remarqué que le 11 octobre, en construisant Wimpel 1.3.1 dans Playground.

Ce n’est pas une particularité de notre bibliothèque. symfony/polyfill-mbstring charge son bootstrap.php par la même voie, et Guzzle son fichier de fonctions. Si deux extensions embarquent le même paquet sans en changer l’espace de noms, le site exécute le fichier de l’extension chargée en premier, quelle que soit la version apportée par l’autre.

La correction dans Core 1.4.4 : chaque extension inclut en plus elle-même son fichier de démarrage, directement dans le fichier principal. En simplifié :

require_once __DIR__ . '/vendor/autoload.php';
// Composer n'exécute bootstrap.php que pour la première extension chargée.
require_once __DIR__ . '/vendor/hafenstudios/core/bootstrap.php';

require_once retient les fichiers par leur chemin. La copie déjà exécutée par Composer ne s’exécute pas une seconde fois, toutes les autres exactement une fois. Action Scheduler procède d’ailleurs de la même façon : d’après sa documentation, chaque extension inclut directement le fichier de démarrage. En outre, depuis la 1.4.4, chaque copie tient une entrée sous son propre dossier, pour que deux copies portant le même numéro restent visibles.

Troisième constat : la façade amputée

La troisième couche était de notre fait. Nos paquets pour le répertoire WordPress sont plus légers que ceux que nous distribuons nous-mêmes : les parties de la bibliothèque dont une extension n’a pas besoin restent dehors, pour Wimpel 1.2.0 par exemple l’envoi d’e-mails, le journal des consentements et les outils de confidentialité. Le script de construction modifiait aussi la classe centrale Core.php, par laquelle toutes les extensions accèdent à ces services, et en retirait des méthodes comme consent(), mailer() et privacy().

Dans l’essai sous Playground avec les quatre anciennes extensions, cela est resté sans conséquence. La copie choisie y était celle, amputée, de Wimpel 1.2.0, Core 1.4.1, alors le numéro le plus élevé du répertoire. Elle s’était inscrite malgré le verrou, parce que le chargeur propre à Wimpel 1.2.0 inclut lui-même le fichier de démarrage avec require_once. Toutes les classes chargées venaient pourtant de Wellenbrecher. Depuis la première correction, le chargeur de la copie choisie passe toutefois en tête. Si une copie amputée remporte alors le choix, ces méthodes manquent à toutes les autres extensions, et chaque envoi Leadlotse échoue. Nous l’avons constaté en construisant les nouvelles versions, avant d’en livrer une seule.

La correction comporte deux volets. Depuis la 1.4.4, Core.php reste complet dans chaque paquet, et au démarrage la bibliothèque vérifie avec class_exists si un service est présent. En outre, le chargeur de la copie choisie comble lui-même les lacunes : s’il lui manque un fichier, il le cherche dans toutes les copies inscrites du site, par ordre de version décroissant. Ce n’est que si aucune ne l’a que les chargeurs Composer des extensions entrent en jeu.

Le 12 octobre, le test d’intégration a mis au jour un cas de plus. Dans une version intermédiaire de 1.4.4, encore avant la livraison, le script de construction retirait toujours ai() de Core.php pour les paquets sans fonctions d’IA. Cette copie amputée du paquet de répertoire de Leadlotse et la copie complète de Kurato portaient toutes deux le numéro 1.4.4. À numéro égal, la dernière chargée l’emportait, donc l’amputée, et Kurato a échoué sur une fonction d’IA. Depuis, ai() reste en place lui aussi, et à numéro égal c’est la copie la plus complète qui figure sur la liste du choix. Un numéro de version devrait correspondre à un seul contenu. Là où ce n’est pas possible, le choix a besoin d’un second critère.

Mesuré dans WordPress Playground

Avant la livraison, les paquets ont tourné dans onze combinaisons dans WordPress Playground, avec WordPress 7.1.3 et PHP 8.3, requêtes externes bloquées. Le jour de la livraison, nous avons mesuré à nouveau, dans les mêmes conditions, ce que change une seule extension nouvelle dans l’ancienne combinaison fautive. Les principaux résultats :

CombinaisonRésultat
Heckgalerie 1.2.5 et Leadlotse 1.4.3 du répertoireerreur fatale dans heckgalerie.php:163, le message ci-dessus
Seul Heckgalerie 1.2.6 nouveau ; Leadlotse 1.4.3 ancienfonctionne, DesignSystem issu de Heckgalerie (Core 1.4.4)
Seul Leadlotse 1.5.1 nouveau ; Heckgalerie 1.2.5 ancienfonctionne, DesignSystem issu de Leadlotse (Core 1.4.4)
Seul Wimpel 1.3.1 nouveau ; Heckgalerie et Leadlotse anciensfonctionne, formulaire envoyé, e-mail avec les trémas corrects
Seul Kurato 1.9.3 nouveau ; Heckgalerie 1.2.5 et Leadlotse 1.4.3 anciensfonctionne, DesignSystem issu de Kurato (Core 1.4.4)
Heckgalerie, Leadlotse, Wellenbrecher et Wimpel nouveaux, depuis le répertoirefonctionne, les quatre copies inscrites
Kurato 1.9.3 en distribution propre ; Heckgalerie, Leadlotse et Wellenbrecher nouveaux, depuis le répertoirefonctionne, les quatre copies inscrites
De l’ancien au nouveau, une extension après l’autre remplacée par téléversement, avec une galerie et une entrée de formulaireaprès chaque échange, toutes les pages vérifiées en statut 200, contenus conservés

La reproduction sans WordPress montre la même chose en miniature. Avec Heckgalerie 1.2.6 au lieu de 1.2.5, DesignSystem vient de Heckgalerie (Core 1.4.4) ; avec Leadlotse 1.5.1 au lieu de 1.4.3, de Leadlotse (Core 1.4.4). Dans les deux cas, la méthode est là.

Ce que le test ne couvre pas : les clics dans un vrai navigateur, le multisite, la mise à jour via le bouton « Mettre à jour maintenant », les mises à jour automatiques par cron, d’autres versions de PHP et de WordPress et, lors du passage de l’ancien au nouveau, un seul des 24 ordres possibles.

Ce que vous pouvez faire en tant que responsable de site

  • Mettez à jour ensemble les extensions d’un même éditeur. Quand des extensions partagent une bibliothèque, l’état mixte est le plus risqué. Chez nous, cela veut dire : Heckgalerie 1.2.6, Leadlotse 1.5.1, Wellenbrecher 1.3.3 et Wimpel 1.3.1 sont dans le répertoire WordPress depuis le 12 octobre 2026, et Kurato 1.9.3 arrive par notre propre canal de mises à jour.
  • En urgence, une seule dépanne déjà. Chacun de ces paquets embarque Core 1.4.4. Cette copie remporte le choix et, comme son chargeur est en tête, c’est elle qui fournit les classes, y compris aux extensions plus anciennes. Dans Playground, l’ancienne combinaison fautive, Heckgalerie 1.2.5 et Leadlotse 1.4.3, tournait sans erreur fatale dès que l’une des deux était à jour. Il en allait de même si l’on ajoutait à la place Wimpel 1.3.1 ou Kurato 1.9.3. Nous n’avons pas testé tous les mélanges ; le travail n’est terminé qu’une fois toutes mises à jour.
  • Si le site est déjà tombé, utilisez le lien vers le mode de récupération que WordPress envoie à l’adresse de l’administrateur après une erreur grave. Pas d’e-mail ? Renommez, par SFTP ou avec le gestionnaire de fichiers de l’hébergeur, le dossier de la dernière extension activée ou mise à jour dans wp-content/plugins/.
  • Lisez attentivement le message d’erreur. S’il cite une méthode prétendument absente dans une classe d’un espace de noms que plusieurs de vos extensions embarquent, un conflit de versions est plus probable qu’une erreur de programmation dans l’extension citée. Pour trouver l’extension en cause, désactivez les extensions une par une. L’extension Health Check & Troubleshooting le permet sans gêner les visiteurs : son mode de dépannage désactive toutes les extensions pour votre seule session, et vous les réactivez une à une.

Comment organiser les mises à jour pour qu’une panne ne vous prenne pas au dépourvu, nous l’expliquons dans mettre à jour ses extensions WordPress.

Ce que les développeurs d’extensions peuvent en retenir

  1. Ne confiez pas l’enregistrement à l’autoload files. Ce que chaque copie doit faire a sa place dans le fichier principal, avec require_once. L’autoload files exécute un fichier une fois par requête, pas une fois par extension.
  2. Enregistrez le chargeur de la copie choisie avec prepend. Sinon ce sont les chargeurs Composer des extensions qui répondent, et la dernière chargée l’emporte :
    spl_autoload_register( $chargeur, true, true ); // troisième argument : prepend
  3. N’amputez jamais la façade selon le paquet. Laissez des fichiers de côté s’il le faut, mais pas les méthodes de la classe qu’appellent toutes les extensions. La présence d’un service se vérifie à l’exécution avec class_exists.
  4. Un numéro de version, un contenu. Si une copie amputée et une copie complète portent le même numéro, le choix ne peut pas les distinguer. Il lui faut alors un second critère ; le nôtre est la complétude.
  5. Vérifiez l’origine, pas la constante. Ce que PHP a réellement chargé, c’est le fichier derrière la classe qui le dit :
    $fichier = ( new \ReflectionClass( \Hafenstudios\Core\Admin\DesignSystem::class ) )->getFileName();
  6. Testez à côté d’anciennes versions. Le défaut n’apparaît que lorsqu’une nouvelle extension tourne à côté d’une ancienne, jamais isolément. Le répertoire WordPress continue de proposer les versions antérieures en ZIP.
  7. Si vous n’avez rien à partager, préfixez. Des outils comme PHP-Scoper ou Strauss donnent à chaque extension son propre espace de noms, si bien qu’il n’existe plus de classe commune à se disputer. Nous partageons à dessein, parce que nos extensions communiquent entre elles par la bibliothèque. Si vous n’en avez pas besoin, préfixer est plus simple.

Votre site est en panne ?

Écran blanc, erreur fatale après une mise à jour, formulaire qui n’envoie plus rien : avec notre aide d’urgence WordPress, nous commençons dans les quatre heures les jours ouvrés, sans contrat et sans paiement préalable. 185 € de l’heure.

Demander une aide d’urgence

Questions fréquentes

Pourquoi une seule extension peut-elle faire tomber tout un site WordPress ?

Toutes les extensions actives s’exécutent dans le même processus PHP. Si l’une d’elles s’arrête sur une erreur fatale, toute la requête s’arrête. Quand plusieurs extensions embarquent la même bibliothèque, une seconde voie s’ajoute : PHP ne connaît qu’une classe par nom, et la copie d’une extension peut remplacer celle d’une autre, même si elle est plus ancienne.

Qu’est-ce que l’autoload files de Composer ?

Une entrée du composer.json qui fait exécuter des fichiers par Composer dès le chargement de l’autoloader, par exemple pour des fonctions ou des enregistrements. Chaque fichier reçoit un identifiant formé du md5 du nom du paquet et du chemin, et Composer ne l’exécute qu’une fois par requête. Si plusieurs extensions embarquent le même paquet, leurs fichiers partagent cet identifiant, et seul celui de l’extension chargée en premier s’exécute.

Comment savoir depuis quelle extension une classe a été chargée ?

Avec la réflexion de PHP : getFileName() sur une ReflectionClass de la classe renvoie le chemin du fichier depuis lequel PHP l’a chargée. S’il pointe vers le dossier vendor d’une autre extension, c’est une copie étrangère qui fournit la classe. Une constante de version ne le prouve pas, elle indique seulement quelle copie a été choisie.

Dois-je mettre à jour toutes les extensions Hafenstudios en même temps ?

Oui, idéalement toutes ensemble. Heckgalerie 1.2.6, Leadlotse 1.5.1, Wellenbrecher 1.3.3, Wimpel 1.3.1 et Kurato 1.9.3 embarquent Core 1.4.4. Dans WordPress Playground, l’ancienne combinaison fautive, Heckgalerie 1.2.5 et Leadlotse 1.4.3, tournait déjà sans erreur fatale dès que Heckgalerie, Leadlotse, Wimpel ou Kurato était présente dans sa nouvelle version. Nous n’avons toutefois pas testé tous les mélanges. L’installation n’est complète qu’une fois toutes mises à jour.

Comment les développeurs d’extensions évitent-ils les conflits de bibliothèques partagées ?

Si vous n’avez pas besoin d’état partagé, préfixez les espaces de noms de chaque extension, par exemple avec PHP-Scoper ou Strauss. Si vous partagez une bibliothèque à dessein, enregistrez vous-même chaque copie avec require_once, enregistrez le chargeur de la copie choisie avec prepend, ne réduisez jamais la classe partagée selon le paquet et réservez chaque numéro de version à un seul contenu.

Retour au blog Un article de hafenstudios