Silence complet du hook, ou bloc affiché en double : l’origine se situe presque toujours au même endroit, du côté de ce qui raccroche votre module au point d’accroche et que stocke ps_hook_module, puis de la façon dont votre thème réclame ce rendu. Le nom de méthode, et son orthographe, règlent le reste. Je vous déroule ma manière de remonter cette chaîne, sur une 1.7, une 8.x comme une 9.x, avec sous les yeux le comportement effectif de la classe Hook.
Vérifier d’abord si le hook est greffé en base
Il y a trois tables à regarder. ps_hook stocke le nom officiel ainsi qu’un drapeau active, ps_hook_module relie un module, un hook et une boutique en leur donnant une position, pendant que ps_hook_alias met les anciennes appellations en face des nouvelles. Tant qu’aucune ligne n’existe dans ps_hook_module pour la boutique que vous affichez, rien n’appelle votre module, même s’il est écrit à la perfection.
Un point précis renverse la lecture du problème : dans ps_hook_module, la clé primaire couvre les trois colonnes id_module, id_hook et id_shop. Impossible, du coup, d’obtenir un doublon réel sur un même hook pour une même boutique (gardez ça en tête, on y revient plus loin). Je passe systématiquement les requêtes ci-dessous : elles disent l’état exact du greffage.
-- Tous les points d'accroche d'un module, boutique par boutique. -- La cle primaire de ps_hook_module est (id_module, id_hook, id_shop) : -- un vrai doublon sur un meme hook et une meme boutique est impossible. SELECT h.id_hook, h.name AS hook, h.active, hm.id_shop, hm.position FROM ps_hook_module hm JOIN ps_hook h ON h.id_hook = hm.id_hook JOIN ps_module m ON m.id_module = hm.id_module WHERE m.name = 'monmodule' ORDER BY hm.id_shop, h.name; -- Cause reelle d'un double affichage : le module est greffe a la fois sur -- un hook et sur l'un de ses alias, que Hook::getHookModuleExecList ajoute -- a la liste d'execution du hook canonique. SELECT ha.alias, ha.name AS hook_canonique, m.name AS module, hm.id_shop FROM ps_hook_alias ha JOIN ps_hook h ON h.name = ha.alias JOIN ps_hook_module hm ON hm.id_hook = h.id_hook JOIN ps_module m ON m.id_module = hm.id_module WHERE m.name = 'monmodule'; -- Modules greffes deux fois sur un meme hook via deux boutiques du groupe. SELECT h.name AS hook, m.name AS module, COUNT(*) AS greffages FROM ps_hook_module hm JOIN ps_hook h ON h.id_hook = hm.id_hook JOIN ps_module m ON m.id_module = hm.id_module GROUP BY h.name, m.name HAVING greffages > 1 ORDER BY greffages DESC;
Première requête vide : allez regarder du côté de l’installation du module, votre thème n’y est pour rien. Des lignes remontent, mais avec un id_shop qui ne correspond pas à la boutique affichée ? Le greffage a eu lieu sous un autre contexte multiboutique, et le symptôme que j’observe alors est rigoureusement le même (aussi trompeur, d’ailleurs).
Greffer à l’installation, rattraper par un upgrade
Pour ma part, le greffage va dans install(), et surtout pas dans le constructeur. Module::registerHook() repasse la main à Hook::registerHook(), qui fabrique la ligne ps_hook quand le hook manque encore, puis ajoute une ligne pour chacune des boutiques du contexte, et se montre idempotente : avant toute insertion elle interroge isModuleRegisteredOnHook(), si bien qu’un second appel ne produit aucun doublon (bonne nouvelle). Si le point d’accroche visé est absent du cœur, ajoutez d’abord un hook personnalisé avant de greffer, parce que registerHook() se contente de déclarer la ligne ps_hook qui va avec.
Quand le module tourne déjà chez votre client, hors de question de réinstaller pour lui coller un hook de plus : toute sa configuration disparaît. Je préfère un fichier d’upgrade. Le cœur inspecte upgrade/, casse au tiret le nom qu’il y trouve, puis va chercher la fonction globale qui porte ce nom.
<?php
// modules/lijeblocinfo/lijeblocinfo.php
// Le greffage se fait a l'installation, jamais dans le constructeur.
public function install()
{
if (!parent::install()) {
return false;
}
// Hook::registerHook est idempotent : il verifie d'abord
// isModuleRegisteredOnHook() pour chaque boutique du contexte.
return $this->registerHook([
'displayHome',
'actionObjectProductUpdateAfter',
]);
}
// Le nom de la methode est 'hook' . ucfirst($nomDuHook).
// En mode debug, registerHook leve une PrestaShopModuleException si elle manque ;
// en production, il se contente d'ecrire un avertissement dans les logs.
public function hookDisplayHome(array $params)
{
$this->smarty->assign('lijeblocinfo_message', Configuration::get('LIJEBLOCINFO_MESSAGE'));
return $this->display(__FILE__, 'views/templates/hook/home.tpl');
}
public function hookActionObjectProductUpdateAfter(array $params)
{
/** @var Product $produit */
$produit = $params['object'];
PrestaShopLogger::addLog('Produit ' . (int) $produit->id . ' mis a jour', 1);
}
<?php
// modules/lijeblocinfo/upgrade/upgrade-1.1.0.php
// Greffer un hook sur un module deja installe, sans le reinstaller.
//
// Le coeur scanne le dossier upgrade/, decoupe le nom de fichier sur le tiret
// et cherche la fonction globale upgrade_module_<version_avec_underscores>.
if (!defined('_PS_VERSION_')) {
exit;
}
function upgrade_module_1_1_0(Module $module): bool
{
// registerHook cree la ligne ps_hook, puis une ligne ps_hook_module
// par boutique du contexte, uniquement si elle n'existe pas deja.
return $module->registerHook('actionObjectProductUpdateAfter');
}
La version dicte le nom de la fonction, sans surprise : upgrade-1.1.0.php va chercher upgrade_module_1_1_0, et ce fichier doit en plus faire monter le numéro de version que déclare le module. Le déclenchement arrive quand vous ouvrez la page des modules du back-office pour la première fois (pas avant).
Le nom de la méthode, et le mythe de la casse
Le calcul du nom attendu se fait dans Hook::getMethodName(), qui colle bêtement 'hook' devant le résultat de ucfirst() appliqué au nom du hook. Un hook baptisé displayHome réclame donc hookDisplayHome. Beaucoup d’articles racontent l’inverse, pourtant la casse ne joue aucun rôle : le nom recherché passe par strtolower() dans Hook::getIdByName(), et PHP se moque de toute façon de la casse des méthodes. Cherchez ailleurs.
Deux coupables : méthode absente, méthode mal écrite. À partir de PrestaShop 8, registerHook() contrôle l’existence de la méthode avant d’écrire la ligne, et le comportement dépend de l’environnement : en mode debug une PrestaShopModuleException est levée et stoppe net l’installation, tandis qu’en production un avertissement part dans les journaux et le processus continue. Du coup, une installation restée muette et incomplète en production se met à parler aussitôt que vous basculez en mode debug. Quand un module s’installe proprement sans jamais rien afficher, je démarre toujours par ce test-là.
<?php
// Hook::getMethodName() construit 'hook' . ucfirst($nomDuHook).
// La casse du nom enregistre en base n'a aucune importance : getIdByName()
// applique strtolower(), et les noms de methodes PHP sont insensibles a la
// casse. Ce qui casse, c'est l'orthographe.
class Lijeblocinfo extends Module
{
// Hook 'displayHome' -> methode hookDisplayHome
public function hookDisplayHome(array $params)
{
return '';
}
// Hook 'actionFrontControllerSetMedia' -> hookActionFrontControllerSetMedia
public function hookActionFrontControllerSetMedia(array $params)
{
$this->context->controller->registerStylesheet(
'lijeblocinfo-front',
'modules/' . $this->name . '/views/css/front.css',
['media' => 'all', 'priority' => 150]
);
}
// Alias historique : ps_hook_alias fait correspondre 'header' a
// 'displayHeader'. Hook::getAllKnownNames() resout l'alias, donc une
// methode hookHeader reste appelee, mais le nom canonique est preferable.
public function hookDisplayHeader(array $params)
{
return '';
}
}
Appeler le point d’accroche dans le thème
Un point d’accroche greffé que personne n’appelle restera muet. PrestaShop déclare sur le front-office une fonction Smarty portant le nom hook, dans config/smarty.config.inc.php, et son travail consiste à faire passer le paramètre h à Hook::exec(). Deux options complètent ça : mod réduit l’affichage à un module unique, excl en met une liste de côté.
En back-office, autre syntaxe. Une fonction et un filtre baptisés renderhook arrivent avec l’extension PrestaShopBundle\Twig\HookExtension, et tout y tient en minuscules (oui, c’est bien ça). Tapez renderHook dans un gabarit Twig, ça refuse de compiler. Smarty garde la main sur le front, jusqu’en 9.x comprise.
{* themes/montheme/templates/index.tpl *}
{* La fonction Smarty "hook" est enregistree dans config/smarty.config.inc.php
et appelle Hook::exec() avec le nom passe dans h. *}
{hook h='displayHome'}
{* Restreindre l'appel a un seul module, ou en exclure certains. *}
{hook h='displayHome' mod='lijeblocinfo'}
{hook h='displayHome' excl='ps_banner,ps_imageslider'}
{* Un module qui implemente WidgetInterface se rend aussi sans etre greffe. *}
{widget name='lijeblocinfo' hook='displayHome'}
{* Back-office uniquement, en Twig : la fonction s'ecrit en minuscules et
elle est declaree dans PrestaShopBundle\Twig\HookExtension. *}
{* {{ renderhook('displayAdminCustomers', {'id_customer': customerId}) }} *}
Deux contrôles que j’enchaîne derrière. Peu importe la casse que vous donnez au nom dans h, l’orthographe en revanche compte : un hook qui n’existe pas ne renvoie ni erreur ni avertissement, juste du vide. Ajoutez à cela qu’un module implémentant WidgetInterface sait s’afficher par {widget} sans le moindre greffage, et vous tenez l’explication d’un bloc visible chez vous alors que ps_hook_module paraît vide.
Quand le module s’exécute deux fois
La clé primaire bloquant tout doublon en base, je vais voir ailleurs. Piste numéro un, le gabarit : quand un appel {hook h='displayHome'} est écrit dans votre thème enfant alors qu’il figure déjà dans le gabarit hérité, le rendu sort deux fois. Piste numéro deux, le module en personne, accroché à deux hooks distincts qui atterrissent sur la même page. Pour savoir lequel des deux, sans rien bousculer chez le client, faites-en une copie sous un nom différent, que vous n’accrochez qu’à l’un des deux. L’origine du doublon vous saute au visage.
Une exécution déclenchée par un alias n’ajoute aucun doublon non plus : Hook::getHookModuleExecList() rassemble bien ce qui est inscrit sur les alias, puis met dehors tout module figurant déjà dans la liste. Les suppositions ne mènent nulle part, alors je fais parler le module sur son appelant, le temps d’un essai (qu’on supprime derrière, bien entendu).
<?php
// Tracer qui appelle reellement le hook, plutot que de supposer.
// A placer au debut de la methode suspecte, le temps d'un test.
public function hookDisplayHome(array $params)
{
if (_PS_MODE_DEV_) {
$pile = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 8);
$chemin = [];
foreach ($pile as $niveau) {
if (!isset($niveau['function'])) {
continue;
}
$chemin[] = (isset($niveau['class']) ? $niveau['class'] . '::' : '')
. $niveau['function']
. (isset($niveau['line']) ? '@' . $niveau['line'] : '');
}
PrestaShopLogger::addLog(
'hookDisplayHome appele depuis ' . implode(' < ', $chemin),
1,
null,
'Module',
(int) $this->id
);
}
return $this->display(__FILE__, 'views/templates/hook/home.tpl');
}
Il suffit d’une visite : les journaux gardent la pile d’appels, et elle désigne le gabarit ou le contrôleur fautif. Un cas y échappe, celui des hooks d’action, dont certains repartent légitimement plusieurs fois au sein d’une même requête, typiquement quand un écran enregistre un objet par passes successives. Là, je ne corrige rien.
Désenregistrer proprement à la désinstallation
Le retrait de la ligne dans ps_hook_module revient à Hook::unregisterHook(), qui appelle dans la foulée cleanPositions() afin de réattribuer un numéro aux modules toujours en place sur ce hook. Zappez ça, et vous héritez d’une numérotation trouée, plus des enregistrements fantômes qui désignent un module parti depuis longtemps, bonjour la lisibilité de votre écran des positions en back-office.
Un deuxième paramètre existe. C’est la liste des identifiants de boutiques. En multiboutique, pas d’autre moyen pour décrocher un module d’un hook sur une seule boutique en laissant les autres intactes. Nom de hook ou identifiant numérique, les deux font l’affaire, et le retour vaut false quand le hook demeure introuvable (sans lever la moindre exception).
<?php
// modules/lijeblocinfo/lijeblocinfo.php (extrait)
public function uninstall()
{
// Hook::unregisterHook supprime la ligne ps_hook_module, puis appelle
// cleanPositions() pour renumeroter les modules restants sur ce hook.
foreach (['displayHome', 'actionObjectProductUpdateAfter'] as $hook) {
$this->unregisterHook($hook);
}
Configuration::deleteByName('LIJEBLOCINFO_MESSAGE');
return parent::uninstall();
}
/**
* Retirer un seul point d'accroche sans desinstaller le module,
* et uniquement pour la boutique courante en multiboutique.
*/
public function detacherDeLAccueil(): bool
{
return $this->unregisterHook('displayHome', [(int) Context::getContext()->shop->id]);
}
Le ménage ne s’arrête pas là : balayez les tables du module, les clés de configuration qu’il a posées, ainsi que les onglets glissés dans le menu d’administration. Je ne livre jamais sans avoir rejoué le cycle entier, installer puis désinstaller, sur une boutique de recette (c’est par là que passera votre client dès que quelque chose l’agacera).
Déclencher les hooks d’action avec ObjectModel
Cherchez du côté d’ObjectModel pour les hooks d’action attachés aux objets, le contrôleur n’y est pour rien. Ses quatre méthodes add(), update(), delete() et duplicateObject() lancent chaque fois un duo de hooks, l’un générique, l’autre propre à la classe, une passe avant l’écriture et une passe après, le second nom se déduisant de get_class() une fois les antislash enlevés, soit actionObjectProductUpdateAfter pour un produit.
Concrètement, pour vous : rien ne se déclenche quand l’écriture emprunte le SQL brut ou Db::getInstance()->update(). Ni vos modules d’indexation, ni vos synchronisations ERP, ni vos flux marchands n’auront vent du changement, et en général, c’est pile là que je retrouve les décalages entre une boutique et l’outil tiers branché dessus.
<?php
// Les hooks actionObject<Classe><Action>Before/After sont emis par ObjectModel.
// Une requete SQL directe ne les declenche jamais.
// A eviter : la ligne change en base, aucun hook n'est appele.
Db::getInstance()->update(
'product',
['active' => 0],
'id_product = ' . (int) $idProduct
);
// A privilegier : ObjectModel::update() emet actionObjectUpdateBefore,
// puis actionObjectProductUpdateBefore, et les equivalents *After.
$produit = new Product((int) $idProduct);
if (Validate::isLoadedObject($produit)) {
$produit->active = false;
$produit->update();
}
// Le nom du hook derive de get_class() avec les antislash retires :
// Product -> actionObjectProductUpdateAfter
// OrderInvoice -> actionObjectOrderInvoiceUpdateAfter
// Address -> actionObjectAddressAddAfter
Le revers, c’est la performance. Monter un ObjectModel en mémoire pour retoucher une colonne sur cent mille enregistrements se paie cher en temps (plusieurs minutes, parfois nettement davantage). Quand le volume devient énorme, je m’en tiens au SQL brut et j’expédie ensuite le hook attendu à la main. Cet arbitrage-là, je le règle au cas par cas dans mes missions de développement sur mesure.
Purger les caches avant de conclure
Hook::getAllHookRegistrations() garde la liste des modules greffés en cache, sous une clé qui varie selon la boutique et selon le client connecté. Aussi longtemps que ce cache tient, un greffage fraîchement écrit en base ne se voit nulle part. Vous croyez alors que votre correction n’a servi à rien, et le symptôme ressemble à celui de la surcharge que PrestaShop semble ignorer, sauf que là le trompe-l’œil vient du cache d’autoload plutôt que de celui des hooks.
En PrestaShop 8, une commande unique fait le travail. La 9 découpe les applications : l’option --app-id de bin/console est sur admin faute de précision, et les deux autres noyaux se visent avec front et admin-api. Purger le cache de l’administration laisse donc intact celui du front-office.
cd /var/www/boutique # PrestaShop 8 : un seul kernel. php bin/console cache:clear --env=prod --no-warmup # PrestaShop 9 : bin/console accepte --app-id et vise le kernel admin par # defaut. Le cache de chaque application doit etre vide separement. php bin/console cache:clear --env=prod --app-id=admin php bin/console cache:clear --env=prod --app-id=front php bin/console cache:clear --env=prod --app-id=admin-api # Les templates Smarty compiles vivent sous var/cache/<environnement>/smarty. rm -rf var/cache/prod/smarty/compile var/cache/prod/smarty/cache chown -R www-data:www-data var/cache
Vos gabarits Smarty compilés se trouvent dans var/cache/<environnement>/smarty, plus du tout dans le vieux dossier /cache de la 1.6. Un cache externe branché par-dessus, Redis par exemple, se vide lui aussi (ça m’échappe une fois sur deux). Tout ça porte sur le greffage et le rendu, rien au-delà : si votre hook est appelé comme il faut mais que sa méthode retourne une chaîne vide faute de condition métier remplie, ces contrôles resteront muets, et je reprends le code du module ligne après ligne. S’il vous reste un doute, la page contact est ouverte, j’en discute volontiers.
Questions fréquentes
Mon registerHook ne fonctionne pas à l’installation, que vérifier ?
Commencez par la méthode elle-même. À partir de PrestaShop 8, la classe du module est inspectée par Hook::registerHook(), qui y traque hook accolé au nom du hook. Absente, elle fait lever au mode debug une PrestaShopModuleException qui plante l’installation. En production, ça passe. Un avertissement, rien d’autre.
Vient ensuite le contexte boutique : sans liste explicite, registerHook() écrit une ligne pour chaque boutique que lui renvoie Shop::getCompleteListOfShopsID(). Allez regarder ps_hook_module directement, vous serez fixé en dix secondes. L’appel reste idempotent, donc rejouez-le tranquillement depuis un upgrade.
Comment appeler un hook dans un thème Smarty ou en Twig ?
Dans vos .tpl, vous tapez {hook h='displayHome'} et rien de plus. Cette fonction Smarty vient de config/smarty.config.inc.php, qui lui attache deux options : mod cantonne l’affichage à un module désigné, excl en met plusieurs de côté, séparés par des virgules.
Du côté de Twig, le nom est renderhook, intégralement en minuscules, et vous n’y avez accès qu’en back-office vu que sa déclaration vit dans PrestaShopBundle\Twig\HookExtension. Pour information, c’est toujours Smarty qui sert le front-office, PrestaShop 9 comprise.
Pourquoi mon module s’exécute-t-il deux fois sur le même hook ?
Aucun doublon en base là-dedans. Les trois colonnes id_module, id_hook et id_shop forment la clé primaire de ps_hook_module, et ce verrou interdit la seconde insertion. Les alias non plus n’y peuvent rien, Hook::getHookModuleExecList() laissant de côté tout module figurant déjà dans la liste d’exécution.
Deux explications tiennent la route. La première, votre hook appelé à deux endroits des gabarits, en général une fois par le thème enfant et une fois par celui dont il hérite. La seconde, un module accroché à deux points d’accroche différents rendus sur la même page. Semez une trace dans la méthode visée, le fautif se montre à la première visite.
Quels changements de hooks entre PrestaShop 1.7, 8 et 9 ?
Ouvrez $deprecated_hooks dans la classe Hook et vous tenez l’inventaire des noms mis au rebut, strictement le même en 8.2.3 qu’en 9.0.0. Y figurent backOfficeFooter avec displayBackOfficeFooter, déclarés obsolètes dès la 1.7.0, puis displayAdminOrderLeft avec displayAdminOrderRight à partir de la 1.7.7.
ps_hook_alias continue de résoudre les alias au moment de l’exécution, et un ancien nom marche donc encore. Le vrai changement de la 9.x se situe ailleurs : les noyaux front, admin et admin-api sont séparés, ce qui vous oblige à vider trois caches différents après la moindre retouche de greffage.
Comment un hook d’action se déclenche-t-il sur une modification de produit ?
Par ObjectModel, le contrôleur n’y est pour rien. Prenez add(), update(), delete() ou duplicateObject() : chacune lance un hook générique doublé d’un hook rattaché à sa classe, une première fois avant d’écrire, une seconde après. Côté produit, cela vous vaut actionObjectProductUpdateBefore suivi de actionObjectProductUpdateAfter, l’objet circulant dans la clé object.
Le SQL direct saute par-dessus tout ça. D’ailleurs, voilà pourquoi un import maison fabriqué à coups de requêtes brutes laisse à l’abandon vos index de recherche, vos connecteurs ERP et vos flux marchands. J’en raconte d’autres du même acabit sur le blog.