Hook PrestaShop qui ne fonctionne pas : greffé, actif, et mort sans une erreur

Sommaire
- Le symptôme : des paniers qui remontent, des achats qui disparaissent
- Les fausses pistes : chercher pourquoi l'envoi échoue
- La cause : une version de module en base plus récente que le code sur disque
- Pourquoi un hook PrestaShop ne fonctionne pas sans lever d'erreur : ce que fait Hook::exec()
- Comment vérifier si un hook greffé est vraiment appelé
- Le correctif : redéployer la version déjà en base, pas baisser ps_module.version
- Ne plus mettre à jour un module PrestaShop depuis le back-office de production
- En résumé : la base déclare, le code décide
L’outil marketing branché sur la boutique voyait toujours paniers et fiches produits, mais plus aucun achat. On l’a trouvé en auditant sur la production la version de chaque module (fichier contre base), après un autre incident causé par une mise à jour faite en back-office. Côté PrestaShop, tout était vert, sans exception ni ligne de log. Au moment de la mesure, les boutiques réellement branchées à l’outil comptaient 374 commandes sur 30 jours, et pas un seul événement d’achat transmis.
Si un hook ne fonctionne pas alors que le module est actif et greffé, la réponse courte est souvent : la méthode hookXxx n’existe plus dans le code qui tourne, et Hook::exec() la saute sans rien dire. Ici, ps_module.version était plus récente que le disque, après une mise à jour en BO écrasée par un déploiement. Un hook greffé n’est pas un hook vivant, et PrestaShop ne vous dira jamais la différence.
Le symptôme : des paniers qui remontent, des achats qui disparaissent
Un module tiers de tracking marketing, sur une multiboutique en PrestaShop 1.7.8.3. Le hook était greffé sur les treize boutiques, mais cinq seulement utilisaient l’outil : sur les huit autres, le jeton d’API était vide et l’URL d’API sur sa valeur par défaut. Installé ne veut pas dire utilisé. Le module pose un script de suivi de navigation, des événements panier et produit, et un envoi de l’achat à la confirmation de commande.
Tous les signaux rassuraient :
- module installé et actif,
ps_modulele déclarant dans sa version la plus récente ; - dans « Apparence > Positions », toujours accroché au hook de confirmation de commande, sur chaque boutique ;
- dans l’outil marketing, le trafic produit et panier continuait d’arriver.
Une intégration morte se voit en une journée ; à moitié vivante, elle peut tourner longtemps. Au moment de la mesure, la mise à jour fautive datait de près d’une semaine.
Les fausses pistes : chercher pourquoi l’envoi échoue
Quand un achat ne remonte plus, on cherche d’abord côté envoi : API de l’éditeur en panne ou clé expirée, consentement cookies qui coupe le script sur la confirmation, override ou thème enfant qui a retiré l’appel au hook. L’audit nous a menés directement au module, mais on aurait commencé par là si l’alerte était venue de l’outil.
La fausse piste qu’on a vraiment eue est plus pointue : une version en base qui avance sans le code, on pense migration ratée. Le schéma était intact : ce n’était pas la base qui manquait, c’était le code.
Toutes ces pistes supposent que le module essaie d’envoyer l’achat. Or il n’essayait plus rien.
La cause : une version de module en base plus récente que le code sur disque
D’abord, le module avait été mis à jour depuis le back-office de production, un matin, par l’éditeur de l’outil lui-même, via une archive zip. Nouveaux fichiers sur le serveur, nouvelle version écrite dans ps_module.version.
Ensuite, en début d’après-midi, un déploiement est parti du dépôt Git, qui contenait toujours l’ancienne version. Le déploiement est un rsync --checksum sans --delete : il écrase tout fichier présent des deux côtés dont le contenu diffère, et ne supprime jamais rien. Le fichier principal a été réécrit avec l’ancien code, et les fichiers ajoutés par la nouvelle version sont restés à côté : un mélange des deux versions, pas un retour propre.
| Où | Ce qu’on y lit |
|---|---|
ps_module.version |
la version récente, écrite par la mise à jour en BO |
ps_hook_module |
displayOrderConfirmation toujours greffé, pour chacun des treize id_shop |
| fichier principal du module sur disque | l’ancienne classe, sans la méthode hookXxx correspondante |
La comparaison qui déclenche les upgrades est dans Module::needUpgrade() (classes/module/Module.php, PrestaShop 9.0.0) :
$moduleVersion = self::getModuleVersion($module);
if (Tools::version_compare($moduleVersion, $module->database_version, '>')) {
// ... charge la liste des fichiers upgrade/ à jouer
}
return null;L’upgrade ne part que si le disque est supérieur à la base. Dans l’autre sens : pas de downgrade, pas d’avertissement, pas de log. En 1.7.8.3, la version de ce projet, même logique en plus simple : needUpgrade() compare directement $module->version à $module->database_version.
La version « disque » ne vient d’ailleurs pas toujours du même endroit. En 9.0.0, getModuleVersion() lit d’abord le config.xml (ou config_xx.xml dans la langue du contexte, $moduleConfig['version'] ?? $module->version) et ne retombe sur $this->version que s’il n’y en a pas. Depuis 9.0.1 (donc 9.1 et 9.2), l’ordre est inversé : fichier principal d’abord, config.xml en repli. Un config.xml resté d’une autre version peut donc fausser la comparaison selon le core, d’où les deux grep de version plus bas.
Pourquoi un hook PrestaShop ne fonctionne pas sans lever d’erreur : ce que fait Hook::exec()
Voici, raccourci, ce que fait Hook::exec() dans classes/Hook.php (PrestaShop 9.0.0) pour chaque module greffé, une fois exceptions de contrôleur et droits vérifiés :
if (!($moduleInstance = Module::getInstanceByName($hookRegistration['module']))) {
continue;
}
// ...
if (Hook::isHookCallableOn($moduleInstance, $registeredHookName)) {
// appel de la méthode hookXxx via Hook::callHookOn()
} elseif (Hook::isDisplayHookName($registeredHookName)) {
if ($moduleInstance instanceof WidgetInterface) {
// appel de renderWidget() via Hook::coreRenderWidget()
}
}
// pas de else : si rien n'est appelable, on passe au module suivantLa boucle est identique en 1.7.8.3, et isHookCallableOn() se résume à un is_callable() sur 'hook' . ucfirst($hookName), alias compris.
Si la méthode n’existe pas, PrestaShop passe au module suivant. Pas de trigger_error(), même avec _PS_MODE_DEV_ : les seuls de cette boucle concernent les alias dépréciés et les modules qui renvoient un tableau sur un hook d’affichage. Et classes/Hook.php en 9.0.0 n’appelle jamais PrestaShopLogger (grep -c PrestaShopLogger classes/Hook.php renvoie 0). Seul repli : sur un hook display..., renderWidget() si le module implémente WidgetInterface. Sinon, l’appel disparaît.
Ici, sur displayOrderConfirmation, rien ne prenait le relais, sur les treize boutiques. Et le JavaScript de l’ancienne version ne gérait que panier et produit : d’où le trafic qui continuait sans les achats.
C’est un choix défendable (un module greffé par erreur ne doit pas faire tomber la page), mais ps_hook_module décrit ce qui a été déclaré un jour, pas ce que le code sait faire aujourd’hui.
Comment vérifier si un hook greffé est vraiment appelé
Croiser version en base, version sur disque et existence réelle de chaque méthode greffée. Trois sources, dans cet ordre.
En SQL, ce que ps_module et ps_hook_module déclarent
SELECT m.name, m.version, m.active, h.name AS hook, hm.id_shop,
ms.id_shop IS NOT NULL AS actif_sur_boutique
FROM ps_module m
INNER JOIN ps_hook_module hm ON hm.id_module = m.id_module
INNER JOIN ps_hook h ON h.id_hook = hm.id_hook
LEFT JOIN ps_module_shop ms ON ms.id_module = m.id_module AND ms.id_shop = hm.id_shop
WHERE m.name = 'nom_du_module'
ORDER BY h.name, hm.id_shop;Adaptez le préfixe de tables. ps_hook_module a pour clé primaire le triplet id_module, id_hook, id_shop. La jointure sur ps_module_shop compte : Hook::exec() filtre sur la présence du module dans ps_module_shop pour la boutique courante, pas sur ps_module.active. Un actif_sur_boutique à 0 est une autre façon d’avoir un hook greffé et muet.
Sur disque, ce que le code sait faire
grep -n "this->version" modules/nom_du_module/nom_du_module.php
grep -n "<version>" modules/nom_du_module/config*.xml
grep -n "function hook" modules/nom_du_module/nom_du_module.phpSi le hook greffé manque dans les function hook..., vous tenez le coupable. Mais un module peut déclarer ses hooks dans un trait, une classe parente, ou passer par __call() : le grep est un filtre, pas une preuve.
En PHP, la même décision que le core avec isHookCallableOn()
Pour trancher, utilisez la méthode de Hook::exec(). Script CLI à lancer à la racine de la boutique (écrit pour cet article, à adapter) :
<?php
declare(strict_types=1);
// Usage : php check-module-hooks.php nom_du_module
require __DIR__ . '/config/config.inc.php';
$name = $argv[1] ?? '';
$module = Module::getInstanceByName($name);
if (!$module instanceof Module) {
fwrite(STDERR, "Module introuvable ou non chargeable : {$name}\n");
exit(1);
}
$db = Db::getInstance();
$dbVersion = (string) $db->getValue(
'SELECT version FROM `' . _DB_PREFIX_ . 'module` WHERE name = \'' . pSQL($name) . '\''
);
printf("Version en base : %s\n", $dbVersion);
printf("Version de la classe : %s\n", $module->version);
// PrestaShop 9.x : la version que needUpgrade() compare à celle en base
printf("Version vue par le core : %s\n", Module::getModuleVersion($module));
$rows = $db->executeS(
'SELECT h.name AS hook, hm.id_shop
FROM `' . _DB_PREFIX_ . 'hook_module` hm
INNER JOIN `' . _DB_PREFIX_ . 'hook` h ON h.id_hook = hm.id_hook
WHERE hm.id_module = ' . (int) $module->id . '
ORDER BY h.name, hm.id_shop'
);
$magicCall = method_exists($module, '__call');
foreach ($rows as $row) {
$alive = Hook::isHookCallableOn($module, $row['hook']);
$status = $alive ? 'OK' : 'MORT';
if ($alive && $magicCall) {
$status = 'OK?';
}
printf("%-5s shop %-3d %s\n", $status, (int) $row['id_shop'], $row['hook']);
}Hook::isHookCallableOn() est publique, annotée @since 1.7.1.0, et gère les alias. Module::getModuleVersion() n’existe qu’à partir de 9.0.0 (signature ModuleCore|stdClass, élargie à string depuis 9.0.1) : en 1.7 et 8.x, retirez la ligne (c’était notre cas en 1.7.8.3). Deux limites : OK? signale un module qui définit __call(), pour lequel is_callable() répond toujours vrai (le commentaire de Hook::callHookOn() le dit lui-même) ; et un hook display... MORT peut encore être servi par renderWidget() si le module implémente WidgetInterface.
Sur le projet, la dérive a d’abord été repérée par l’audit manuel (version du fichier principal contre ps_module.version), puis ce contrôle a été automatisé dans le pipeline après chaque déploiement : version du fichier, version en base et présence des fichiers, sans démarrer PrestaShop. Sur la production, même relevé des deux côtés : 152 modules installés, 5 en dérive, trois avec la base en avance sur le disque (dont notre tracking) et deux actifs en base sans aucun fichier. Le croisement avec ps_hook_module a montré quelles greffes étaient mortes.
Le réflexe à retenir Quand un module « actif et à jour » ne fait plus son travail, ne croyez ni le back-office ni
ps_hook_module. Comparezps_module.versionà la version du code sur disque, puis vérifiez, hook par hook et boutique par boutique, que la méthodehookXxxexiste dans le code qui tourne. PrestaShop ne lève rien quand elle manque : c’est à vous de poser la question.
Le correctif : redéployer la version déjà en base, pas baisser ps_module.version
La tentation : baisser ps_module.version à la main pour que PrestaShop rejoue les upgrades. Mauvaise idée. Les scripts de upgrade/ ont déjà tourné en BO (colonnes, tables, hooks, configuration) et rien ne garantit qu’ils soient idempotents : on risque une erreur SQL en plein upgrade ou un état à moitié migré. Le même audit en donne un exemple avec le module natif de recherche à facettes, lui aussi en base plus récente que sur disque : rejouée, upgrade_module_4_0_3() renvoie le résultat de unregisterHook('actionCategoryUpdate') sur un hook déjà dégreffé, donc false. L’upgrade échoue, et Module::runUpgradeModule() désactive le module ($this->disable()).
Ce module de facettes montre que la dérive ne touche pas que le tracking : base en 4.0.4, disque retombé en 3.14.1, et les huit hooks ajoutés par la migration 3.15.0 greffés sur des méthodes absentes du code. Ce qu’on a fait à la place :
- Récupérer le paquet exact de la version en base. Pour le module natif, le paquet 4.0.4 est public. Pour le tracking, seul l’éditeur l’a : son canal de distribution sert un autre de ses modules mais répond 404 pour celui-ci. Il a fallu passer par le client, et c’est le maillon lent : tant que l’archive n’était pas là, le dépôt portait l’ancienne version et la greffe restait morte. Sans paquet exact, on ne touche à rien, ni la base ni un paquet approchant.
- Versionner ce paquet dans le dépôt, tel quel, sans retouche du code tiers.
- Redéployer, pour que le disque rejoigne la base, déjà migrée. Versions égales, aucun upgrade rejoué : exactement ce qu’on veut.
- Revérifier avec le script que chaque hook, sur chaque boutique, répond
OK, puis que les achats remontent dans l’outil. Une fois l’archive récupérée et déployée, le module est revenu à la bonne version, et les achats perdus pendant la panne ont été renvoyés à l’outil.
Le même projet comptait quarante modules mis à jour en BO de production et jamais rapatriés dans le dépôt, dont trois modules de paiement, qu’un prochain déploiement aurait tous rembobinés. On les a rapatriés depuis la prod, diff relu, fichiers runtime et sensibles exclus, passés en préproduction, puis redéployés en prod.
Ne plus mettre à jour un module PrestaShop depuis le back-office de production
Le bug est dans Hook::exec(), la cause est organisationnelle. Tant qu’un back-office de production peut mettre à jour un module, le code a deux sources de vérité, le dépôt et le serveur, et le prochain déploiement choisit toujours le dépôt.
Nos règles :
- une mise à jour de module se fait dans le dépôt, passe par la préproduction, puis part en prod par le pipeline ;
- si une mise à jour a été faite en BO malgré tout (urgence, éditeur), on rapatrie les fichiers dans le dépôt avant tout autre déploiement ;
- la liste des modules à installer ou mettre à jour est décrite dans le dépôt et appliquée au déploiement, pas cliquée en BO. On détaille ce mécanisme dans notre article sur le déploiement déclaratif des modules ;
- après chaque déploiement, un contrôle compare version du fichier et version en base pour chaque module, et signale tout écart ;
--deleten’est pas la solution miracle : simulé à blanc contre la prod, il aurait supprimé plus d’un million de fichiers, dont toutes les images produits. Les fichiers à retirer sont listés dans un fichier déclaré et versionné.
Côté développement, on a déjà listé les mauvaises pratiques courantes sur les hooks PrestaShop. Et une intégration sur hooks d’action peut aussi devenir fausse sans erreur à cause du périmètre des événements couverts : c’est ce qu’on a raconté pour un webhook produit PrestaShop vers un ERP qu’il a fallu fiabiliser.
En résumé : la base déclare, le code décide
Un module peut être actif, « à jour » et greffé, et ne plus rien faire. Module::needUpgrade() compare les versions dans un seul sens, Hook::exec() saute sans bruit les méthodes absentes. Si une intégration de votre boutique s’est tue sans prévenir, c’est la première vérification qu’on fait en reprenant la maintenance d’un PrestaShop.




