PrestaShop multiboutique : le module est installé, mais pas sur la boutique secondaire

Par Maxime Bénard

15 min de lecture

PrestaShop multiboutique : le module est installé, mais pas sur la boutique secondaire

Sur la boutique principale, ouverte au public, tout fonctionne. Sur la boutique secondaire, réservée aux clients professionnels, des pans entiers ont disparu : la jauge du panier, le panier latéral, les rubans sur les produits, les tuiles de catégories, les vidéos produit. Ce n’est pas le marchand qui l’a signalé : on l’a vu en tests internes, juste après l’installation, en passant sur la boutique secondaire. Côté technique, rien ne semble cassé : l’installation des modules par la CI s’est terminée sans erreur, le back-office les affiche comme installés et actifs, et la table des hooks contient bien des lignes pour les deux boutiques.

C’est un bug muet typique du multiboutique PrestaShop : aucune exception, aucun log, juste des hooks qui ne s’exécutent jamais sur une boutique. La réponse courte : le module n’a pas de ligne dans module_shop pour cette boutique, parce que Module::enable() ne rattache le module qu’aux boutiques du contexte courant, et enable(true) n’y change rien. Ce comportement n’a pas bougé de PrestaShop 1.7.8.11 à la 9.2.0-rc.1. La suite montre comment le vérifier en deux requêtes SQL et comment réparer sans réinstaller.

Module installé en CLI, absent de la boutique secondaire : le constat

Le contexte : un marchand PrestaShop en multiboutique, deux boutiques, une douzaine de modules maison qui portent le panier, le merchandising et le contenu. Le projet tourne en PrestaShop 9.1. Les modules sont installés par la ligne de commande, dans un job de post-déploiement du pipeline qui lit un fichier de suivi (une ligne install ou upgrade par module) et appelle la console pour chacune (extrait simplifié) :

bash
"$PHP_BIN" bin/console prestashop:module "$command" "$module" &>> module_installation.log

La commande rend la main proprement. Le module apparaît dans le gestionnaire de modules. Et pourtant, sur la boutique secondaire, ses hooks ne produisent rien.

Sur notre environnement local, le constat a été net : 9 modules maison sur 12 n’étaient pas rattachés à la boutique secondaire. Dans module_shop, ils ne connaissaient que la boutique 1, la boutique publique. Un seul module maison échappait au problème par construction : son install() basculait déjà le contexte sur toutes les boutiques, et c’est ce patron qu’on a fini par généraliser. La préproduction n’a pas pu être contrôlée avant le correctif, mais elle passait par exactement le même mécanisme d’installation.

La fausse piste qui rassure : hook_module est rempli pour les deux boutiques

Le premier réflexe, quand un module ne s’affiche pas, est de regarder s’il est greffé sur le hook. On l’a fait, et c’est précisément ce qui a retardé le diagnostic : hook_module contenait bien des lignes pour la boutique principale et pour la boutique secondaire. Ce remplissage masque le problème : c’est module_shop qui fait foi.

L’explication est dans le core. Hook::registerHook() (fichier classes/Hook.php, tag 9.0.0) greffe par défaut le module sur toutes les boutiques existantes, sans regarder le contexte :

php
// If shop lists is null, we fill it with all shops
if (null === $shop_list) {
    $shop_list = Shop::getCompleteListOfShopsID();
}

Shop::getCompleteListOfShopsID() fait un simple SELECT id_shop FROM ps_shop. Donc un $this->registerHook('displayShoppingCart') écrit une ligne par boutique dans hook_module, quel que soit le contexte d’exécution.

Mais pour exécuter un hook en front, PrestaShop exige deux choses. Derrière Hook::getHookModuleExecList(), la requête (construite dans la méthode privée Hook::getAllHookRegistrations()) joint la table module_shop via Shop::addSqlAssociation('module', 'm', true), qui produit un INNER JOIN filtré sur la boutique courante, puis ajoute :

php
$sql->where('hm.`id_shop` = ' . (int) $shop->id);

Il faut donc une ligne dans hook_module et une ligne dans module_shop pour la boutique courante. Sans la seconde, l’INNER JOIN élimine le module, silencieusement. Les hooks sont là, le module est « installé », il n’existe simplement pas pour cette boutique.

C’est le même type de piège que celui qu’on décrit dans notre article sur le hook greffé mais mort : regarder la greffe ne suffit pas à prouver l’exécution.

La cause réelle : Module::enable() n’écrit dans module_shop que pour le contexte courant

Module::install() appelle enable() juste après l’insertion dans la table module, avec ce commentaire explicite dans la source :

php
// Enable the module for current shops in context
if (!$this->enable()) {
    return false;
}

Et voici le cœur de Module::enable(), tel qu’il est dans classes/module/Module.php au tag 9.0.0 (extrait, la gestion des overrides est coupée) :

php
public function enable($force_all = false)
{
    // Retrieve all shops where the module is enabled
    $list = Shop::getContextListShopID();
    if (!$this->id || !is_array($list)) {
        return false;
    }
    $sql = 'SELECT `id_shop` FROM `' . _DB_PREFIX_ . 'module_shop`
            WHERE `id_module` = ' . (int) $this->id .
            ((!$force_all) ? ' AND `id_shop` IN(' . implode(', ', $list) . ')' : '');

    // [...] lecture des résultats dans $items, installation des overrides

    // Enable module in the shop where it is not enabled yet
    $moduleActivated = false;
    foreach ($list as $id) {
        if (!in_array($id, $items)) {
            Db::getInstance()->insert('module_shop', [
                'id_module' => $this->id,
                'id_shop' => $id,
            ]);
            $moduleActivated = true;
        }
    }
    // [...]
}

Deux choses à lire attentivement.

La boucle d’insertion suit le contexte boutique

L’insertion boucle sur $list, c’est-à-dire sur Shop::getContextListShopID(). Si le contexte est une boutique précise (Shop::CONTEXT_SHOP), la liste ne contient que cette boutique. En ligne de commande, c’est la boutique par défaut : bin/console charge config/config.inc.php, dont Shop::initialize() place toute exécution CLI en Shop::CONTEXT_SHOP sur PS_SHOP_DEFAULT. Le listener MultishopCommandListener ajoute bien les options --id_shop et --id_shop_group à toutes les commandes, mais aucune pour « toutes les boutiques ». Sur notre projet, la commande de la CI ne passait aucune option --id_shop : installés par le pipeline, les modules se retrouvaient donc rattachés à la seule boutique 1.

Pourquoi Module::enable(true) ne rattache pas le module à toutes les boutiques

Le paramètre $force_all ne change que le SELECT. Avec enable(true), la requête lit les associations existantes sur toutes les boutiques au lieu de se limiter au contexte, mais la boucle d’insertion parcourt toujours $list. Autrement dit, enable(true) ne rattache jamais le module à une boutique hors contexte. Le docblock dit pourtant @param bool $force_all If true, enable module for all shop. C’est cette promesse qui nous avait fait écrire, dans l’un de nos modules (extrait raccourci) :

php
public function install(): bool
{
    if (!parent::install()) {
        return false;
    }

    // Multishop: activate the module (and therefore its hooks) on every
    // shop, not only the install-context shop.
    $this->enable(true);

    // [...] tables, hooks, configuration
}

Le commentaire dit exactement ce qu’on croyait. Le code, lui, n’insère rien de plus.

Ce code donne l’impression de traiter le multiboutique. Il ne fait rien de plus que l’enable() simple déjà appelé par parent::install().

L’asymétrie est d’autant plus trompeuse que disable($force_all) fonctionne, lui, comme on s’y attend : avec true, il retire la condition sur id_shop et supprime les lignes module_shop de toutes les boutiques.

php
$whereIdShop = $force_all ? '' : ' AND `id_shop` IN(' . implode(', ', Shop::getContextListShopID()) . ')';
$result &= Db::getInstance()->delete('module_shop', '`id_module` = ' . (int) $this->id . $whereIdShop);

On a vérifié la logique d’enable() sur les tags 1.7.8.11, 8.1.7, 9.0.0, 9.1.0 et 9.2.0-rc.1 : le SELECT conditionné par $force_all et la boucle d’insertion sur getContextListShopID() sont identiques. Ce n’est donc ni une régression de PrestaShop 9, ni quelque chose que la 9.2 corrige : c’est un contrat ancien et mal documenté. Si vous migrez un multiboutique, le problème vous suit : voir notre retour sur la migration PrestaShop 1.7 vers 8 et l’anticipation de PrestaShop 9.

Comment vérifier si votre multiboutique PrestaShop est concerné : compter les boutiques par module

Quand une fonctionnalité manque sur une seule boutique, ne commencez pas par hook_module. Commencez par module_shop, et comparez au nombre de boutiques. Cette requête liste les modules qui ne sont pas rattachés partout (préfixe ps_ à adapter) :

sql
SELECT m.name,
       m.active,
       GROUP_CONCAT(ms.id_shop ORDER BY ms.id_shop) AS boutiques,
       COUNT(ms.id_shop) AS nb_boutiques
FROM ps_module m
LEFT JOIN ps_module_shop ms ON ms.id_module = m.id_module
GROUP BY m.id_module, m.name, m.active
HAVING nb_boutiques < (SELECT COUNT(*) FROM ps_shop)
ORDER BY m.name;

Un module avec active = 1 et une seule boutique dans la colonne boutiques, c’est exactement notre symptôme. Attention, un module natif volontairement désactivé sur une boutique remontera aussi : la requête sert à trier, pas à corriger aveuglément.

Pour confirmer le faux signal, mettez les deux tables côte à côte :

sql
SELECT m.name,
       (SELECT GROUP_CONCAT(DISTINCT hm.id_shop ORDER BY hm.id_shop)
          FROM ps_hook_module hm WHERE hm.id_module = m.id_module) AS shops_hook_module,
       (SELECT GROUP_CONCAT(ms.id_shop ORDER BY ms.id_shop)
          FROM ps_module_shop ms WHERE ms.id_module = m.id_module) AS shops_module_shop
FROM ps_module m
WHERE m.active = 1
HAVING shops_hook_module <> shops_module_shop OR shops_module_shop IS NULL;

Toute ligne qui sort ici mérite un examen. Le cas qui nous intéresse, c’est une boutique présente dans shops_hook_module et absente de shops_module_shop : un module greffé sur une boutique où il ne s’exécutera jamais.

Le réflexe à retenir En multiboutique, un module n’existe pour une boutique que s’il a une ligne dans module_shop pour cette boutique. hook_module rempli ne prouve rien, parce que registerHook() greffe sur toutes les boutiques sans regarder le contexte. Dès qu’une fonctionnalité manque sur une seule boutique, comptez les boutiques par module dans module_shop avant toute autre piste.

Le correctif dans install() : forcer Shop::CONTEXT_ALL, pas le paramètre

Puisque enable() fait confiance au contexte, la correction consiste à lui donner le bon contexte. Dans la classe principale de chaque module, on bascule sur Shop::CONTEXT_ALL autour de parent::install(), et seulement autour de lui, puis on restaure le contexte d’origine quoi qu’il arrive. On retire au passage les enable(true), qui entretenaient l’illusion. Le basculement est isolé dans une méthode dédiée, identique dans chaque module. Voici le code tel qu’il est dans le projet, à la liste de hooks près :

php
public function install(): bool
{
    // Multishop: the hooks are pinned to every shop explicitly, the module
    // itself is attached to all of them by installOnEveryShop().
    $shops = Shop::getShops(true, null, true);

    return $this->installOnEveryShop()
        && $this->runSql('install')
        && $this->registerHook(self::CORE_HOOKS, $shops)
        // [...] autres hooks, configuration
        && $this->installTabs();
}

private function installOnEveryShop(): bool
{
    $context = Shop::getContext();
    $idShop = (int) Shop::getContextShopID();
    $idShopGroup = (int) Shop::getContextShopGroupID();
    Shop::setContext(Shop::CONTEXT_ALL);

    try {
        return parent::install();
    } finally {
        if ($context === Shop::CONTEXT_SHOP && $idShop > 0) {
            Shop::setContext(Shop::CONTEXT_SHOP, $idShop);
        } elseif ($context === Shop::CONTEXT_GROUP && $idShopGroup > 0) {
            Shop::setContext(Shop::CONTEXT_GROUP, $idShopGroup);
        } else {
            Shop::setContext($context);
        }
    }
}

Le reste de install() (tables, hooks, configuration) retrouve ainsi le contexte de l’appelant. Les hooks, eux, reçoivent explicitement la liste des boutiques actives en second argument de registerHook(), plutôt que de compter sur le comportement par défaut.

Le finally n’est pas décoratif : Shop::setContext() modifie des propriétés statiques. Si l’installation échoue ou lève une exception et que le contexte reste sur « toutes les boutiques », la suite de la commande (ou de la requête back-office) travaille dans un périmètre qu’elle n’a pas demandé. La dernière branche couvre le cas où l’appelant était déjà en CONTEXT_ALL, ou dans un contexte sans identifiant exploitable : on lui rend le type de contexte qu’il avait.

Une nuance vérifiée dans Shop::getContextListShopID() : en CONTEXT_ALL, la liste vient de Shop::getShops(true, null, true), donc des boutiques actives uniquement. Une boutique désactivée au moment de l’installation ne sera pas rattachée, alors que registerHook() l’aura greffée (il utilise la liste complète), et registerHook() supprime même la ligne module_shop des boutiques inactives qu’il parcourt. Si vous préparez une boutique avant de l’ouvrir, gardez ce cas en tête.

Réparer module_shop sans réinstaller : un script d’upgrade par module

Corriger install() protège les prochaines installations, pas les bases existantes : toutes celles dont les modules avaient déjà été installés par le même pipeline restaient exposées au même problème. On ne réinstalle pas un module en production pour ça (perte de configuration, de données, de positions de hooks). On ajoute un script dans upgrade/, que PrestaShop exécute lors de la montée de version du module.

Le mécanisme est celui de Module::loadUpgradeVersionList() : un fichier upgrade/upgrade-X.Y.Z.php est retenu si sa version est supérieure à la version enregistrée en base et inférieure ou égale à celle du module, et le core appelle la fonction upgrade_module_X_Y_Z(). Voici l’un des scripts réels, seul le docblock est coupé :

php
<?php
// upgrade/upgrade-3.3.1.php

declare(strict_types=1);

if (!defined('_PS_VERSION_')) {
    exit;
}

function upgrade_module_3_3_1(Module $module): bool
{
    $db = Db::getInstance();

    foreach (Shop::getShops(true, null, true) as $idShop) {
        $inserted = $db->insert(
            'module_shop',
            ['id_module' => (int) $module->id, 'id_shop' => (int) $idShop],
            false,
            true,
            Db::INSERT_IGNORE
        );

        if (!$inserted) {
            return false;
        }
    }

    return true;
}

Quatre choix derrière ce script :

  • Il ne dépend pas du contexte. Le script d’upgrade s’exécute lui aussi par la CLI, donc potentiellement dans le même contexte mono-boutique que l’installation fautive. On passe explicitement par la liste des boutiques actives, Shop::getShops(true, null, true), c’est-à-dire celle qu’une installation en CONTEXT_ALL aurait utilisée : le script répare l’état qu’on aurait dû avoir, sans activer le module sur une boutique fermée.
  • Il écrit la ligne lui-même au lieu d’appeler enable(). Passer par Module::enable() aurait aussi basculé module.active, rejoué l’enregistrement des hooks et réinstallé les overrides : beaucoup d’effets de bord pour une seule ligne manquante.
  • Db::INSERT_IGNORE est sûr ici. La clé primaire de module_shop est (id_module, id_shop) dans install-dev/data/db_structure.sql, et enable_device prend sa valeur par défaut (7, tous les appareils). Relancer le script ne crée pas de doublon.
  • Il est rejouable sur tous les environnements. Local, preprod, prod reçoivent la même correction par le même chemin que n’importe quelle montée de version.

Un détail à connaître : si la fonction d’upgrade renvoie false, Module::runUpgradeModule() appelle $this->disable(), qui retire le module des boutiques du contexte courant. En CLI, c’est la boutique par défaut : un script qui échoue désactive donc le module là où il fonctionnait.

Côté déploiement, chaque montée de version est déclarée dans le fichier de suivi des modules que lit notre pipeline, pour que l’upgrade soit joué au démarrage. On détaille ce mécanisme dans notre article sur le déploiement déclaratif des modules. Sur ce projet, treize modules ont reçu leur script dans une première livraison, et deux autres ont suivi séparément. Chacun a simplement pris un numéro de correctif (1.4.1, 2.2.1, 3.3.1, etc.).

Ces deux retardataires dépendaient chacun d’une autre branche en cours sur le même module, et l’un d’eux, le module de jauge du panier, illustre deux pièges de l’upgrade par version. Le premier est prévisible : l’autre branche ajoutait déjà son propre script. Chaque branche a réservé son numéro (1.4.1 pour l’une, 1.4.2 pour la nôtre), et notre branche a été empilée sur l’autre pour imposer l’ordre de merge. Si la 1.4.2 était passée en premier, la version enregistrée en base aurait dépassé 1.4.1, et loadUpgradeVersionList() aurait ignoré ce script sans rien dire.

Le second, on ne l’a pas vu venir. Entre-temps, le module a été resynchronisé sur sa version de référence, en 2.3.1, qui a remplacé tout son dossier. Le correctif 1.4.2 a disparu avec, sans conflit ni erreur, et la jauge a de nouveau manqué sur la boutique secondaire. Il a fallu le refaire à la source, install() corrigé et script upgrade-2.3.2.php. Leçon : un correctif appliqué dans la copie projet d’un module partagé doit remonter dans sa version de référence, sinon la prochaine synchronisation l’efface.

Valider sur la boutique secondaire, pas sur la principale

Après déploiement, deux contrôles :

  1. la requête de comptage ci-dessus ne doit plus remonter aucun module maison ;
  2. un parcours manuel sur la boutique secondaire, connecté avec un compte professionnel : panier latéral, jauge du panier, rubans produits et tuiles de catégories doivent être présents.

En préproduction, après le déploiement de la première livraison et le passage des upgrades, chacun de ses modules listait bien 1,2 dans module_shop (seul le module qui attendait sa branche restait sur la boutique 1), et une installation lancée depuis un contexte mono-boutique donnait aussi 1,2 en local. Le patron installOnEveryShop() lui-même tourne déjà en production sur d’autres projets multiboutique.

Le second point paraît évident, c’est pourtant lui qui a révélé le problème : sur la boutique principale, tout fonctionnait.

Ce que ça change dans notre façon d’écrire les modules

Ce bug a eu des cousins sur le même projet, tous issus de la même confiance dans le contexte courant : une association boutique figée à la création d’un objet et jamais purgée à la suppression, un helper d’URL appelé sans id_shop qui générait des images avec le domaine de l’autre boutique, un unregisterHook() trop large qui retirait un module sur une boutique sous un autre thème. Le dernier cas rejoint une série de travers qu’on retrouve souvent autour des greffes, détaillés dans nos mauvaises pratiques courantes sur les hooks PrestaShop. La règle qu’on applique depuis : le périmètre boutique est une donnée qu’on passe explicitement, jamais quelque chose qu’on hérite de l’endroit d’où le code est appelé. Et en installation, on ne fait confiance qu’à module_shop. Une tâche est d’ailleurs ouverte pour remonter le patron d’installation dans nos modules de référence, pour que les prochains projets partent avec.

Si votre PrestaShop multiboutique perd des fonctionnalités sur une seule boutique sans le moindre message d’erreur, lancez la requête de comptage avant d’ouvrir le code : elle dit tout de suite si le problème est là. Et si vous reprenez un multiboutique installé par une CI, c’est une bonne première requête à lancer, avant même d’avoir un symptôme.

Partager cet article
Articles

Des articles pour faire pousser vos idées

D'autres retours d'expérience et méthodes à mettre en pratique sur votre site.

Bon, on en discute ?

Demandez votre diagnostic de performance gratuit : un échange de 30 minutes, vos Core Web Vitals relevés sur mobile et une synthèse écrite de ce qui mérite d’être corrigé en premier.

Discuter de mes besoins