WordPress
17 juin 2026

Webhook produit PrestaShop vers ERP : fiabiliser sans casser les MAJ

Laptop on a round table showing a multi-panel software dashboard with a large blue Wi‑Fi icon overlay in a green circle.

Un gestionnaire de catalogue met un produit en ligne depuis la liste produits du back-office. L’ERP devrait se synchroniser dans la foulée, et il ne se passe rien. Le webhook existe pourtant, il a été développé, testé, validé. Il fonctionne même très bien, tant qu’on se contente de créer des produits. Le jour où on couvre la vraie vie d’un catalogue, les mises à jour, les mises en ligne, les mises hors ligne, tout se grippe.

Ce n’est pas un bug exotique, c’est un classique des flux de synchronisation. Et il se règle sans toucher au hook, à condition de comprendre ce que PrestaShop transmet réellement à ce moment-là. Ce qui suit est le diagnostic complet, avec les vérifications faites dans le cœur de PrestaShop 9.0.0 plutôt que de mémoire.

Ce que vous allez apprendre :

  • Pourquoi un webhook peut « marcher » à la création et être faux dès la première mise à jour
  • Ce que contient réellement le payload du hook, et l’information capitale qui n’y est pas
  • La méthode publique et non documentée qui dit exactement quel champ vient de changer
  • Pourquoi passer par une file d’attente détruit cette information si on s’y prend mal
  • Le piège multiboutique : un seul hook pour N boutiques

Le besoin réel : synchroniser un changement d’état, pas une création

L’équipe catalogue travaille depuis la liste produits. Elle active ou désactive un produit avec le toggle d’état, sans ouvrir la fiche. À chaque bascule, l’ERP doit recevoir l’information pour que le produit apparaisse ou disparaisse côté gestion commerciale.

L’événement attendu n’est donc pas « un produit vient d’être créé », c’est « l’état d’un produit existant vient de changer ». Deux choses radicalement différentes, et c’est cette nuance que le flux d’origine n’avait jamais intégrée.

Le hook n’était pas le problème, et c’est vérifiable

Premier réflexe quand une synchro ne part pas : soupçonner le hook. Autant lever le doute une fois pour toutes, parce que la réponse est dans le code.

Le toggle depuis la liste produits passe par le bus de commandes Symfony, qui appelle ProductRepository::partialUpdate($product, ['active'], ...). Cette méthode délègue à partiallyUpdateObjectModelForShops(), qui finit par appeler $objectModel->update(). Et ObjectModel::update() se termine ainsi :

// classes/ObjectModel.php, lignes 808-810 (PrestaShop 9.0.0)
// @hook actionObject<ObjectClassName>UpdateAfter
Hook::exec('actionObjectUpdateAfter', ['object' => $this]);
Hook::exec('actionObject' . $this->getFullyQualifiedName() . 'UpdateAfter', ['object' => $this]);

Le hook actionObjectProductUpdateAfter est donc bien émis, aussi bien par le toggle que par une sauvegarde de fiche complète. Le chemin historique aboutit au même endroit, ObjectModel::toggleStatus() appelant lui aussi update().

Conclusion : si la synchro ne part pas, le problème est dans le contrat applicatif construit autour du hook, pas dans l’accroche. C’est une distinction qui évite de refaire une intégration qui va très bien.

Ce que le hook vous donne, et ce qu’il ne vous donne pas

Regardez de près la charge transmise : ['object' => $this]. Un seul élément, l’objet produit dans son état d’après modification.

Il n’y a aucun différentiel. Pas d’état précédent, pas de liste de champs modifiés, rien qui distingue « on vient de basculer l’état » de « on vient de corriger une faute dans la description ». C’est la raison structurelle pour laquelle le conseil habituel, n’émettre un webhook que si quelque chose a réellement changé, est beaucoup plus difficile à appliquer qu’il n’y paraît.

Sauf qu’il existe une sortie, et elle est publique depuis 1.5 sans que personne n’en parle :

// classes/ObjectModel.php, ligne 2092
/**
 * @return array<string, bool|array<int, bool>>|null
 */
public function getFieldsToUpdate(): ?array
{
    return $this->update_fields;
}

Quand la mise à jour est partielle, le cœur renseigne cette liste avant d’écrire. C’est explicite dans toggleStatus() :

// classes/ObjectModel.php, ligne 930
// Update only active field
$this->setFieldsToUpdate(['active' => true]);

Et le chemin Symfony fait la même chose, via setFieldsToUpdate($this->formatPropertiesToUpdate(['active'])). Le listener peut donc savoir précisément ce qui était visé :

public function hookActionObjectProductUpdateAfter(array $params): void
{
    /** @var Product $product */
    $product = $params['object'];

    $champs = $product->getFieldsToUpdate();

    // null   = sauvegarde complète de la fiche
    // tableau = mise à jour partielle, ex. ['active' => true] pour un toggle
    if (is_array($champs) && !isset($champs['active'])) {
        return; // rien qui concerne l'état, on n'émet pas
    }

    $this->fileDattente->empiler(
        idProduit: (int) $product->id,
        actif: (bool) $product->active,
        boutiques: $product->id_shop_list ?: [(int) $product->id_shop_default],
        champs: $champs
    );
}

C’est ce qui permet une idempotence honnête : on n’émet pas un webhook d’état quand seul le prix a bougé, et on n’a pas besoin de relire l’ancienne valeur en base pour le savoir.

Le piège de la file d’attente

Ce détail mérite sa propre section, parce qu’il annule silencieusement la technique ci-dessus si on l’ignore.

La couche qui pilote la mise à jour partielle nettoie derrière elle :

// src/Core/Repository/AbstractObjectModelRepository.php
try {
    if (!$objectModel->update()) {
        throw new $exceptionClass(...);
    }
} catch (PrestaShopException $e) {
    throw new CoreException(...);
} finally {
    $objectModel->setFieldsToUpdate(null);
}

Le finally s’exécute une fois update() terminé, donc après le hook. L’information est disponible pendant l’exécution du hook, et remise à null juste après.

La conséquence est mécanique : si votre listener empile l’objet produit dans une file d’attente et que le consommateur appelle getFieldsToUpdate() plus tard, il obtiendra null à tous les coups. Il conclura donc à une sauvegarde complète et émettra un webhook pour chaque modification, y compris celles qui ne concernent pas l’état.

La règle est donc simple : on extrait la liste des champs au moment du hook, et on empile des valeurs scalaires, pas l’objet. C’est vrai de toute façon pour une file d’attente sérialisée, mais ici l’oubli ne provoque aucune erreur, juste un flux qui redevient bavard sans qu’on comprenne pourquoi.

Le diagnostic : trois défauts qui se cumulent

En lisant le code du module de synchronisation, trois défauts ressortent. Pris séparément, chacun paraît anodin. Ensemble, ils transforment un flux « qui marche » en flux faux dès qu’on sort du cas de la création.

Un anti-doublon qui bloque toutes les mises à jour

Le module stockait les identifiants des produits déjà traités et refusait d’émettre un webhook pour un produit déjà connu. L’intention était probablement de protéger l’ERP contre des envois redondants pendant un import.

Cette logique part d’un postulat faux : qu’un produit ne se synchronise qu’une fois dans sa vie. Or un produit qu’on met en ligne, puis hors ligne, puis de nouveau en ligne, doit générer un webhook à chaque fois. L’anti-doublon, en mémorisant le produit dès son premier passage, condamnait silencieusement tous les changements d’état ultérieurs.

Tout est émis en product_created

Deuxième défaut, plus insidieux parce qu’il ne plante pas : tous les webhooks partaient avec le même type d’événement. Impossible côté ERP de distinguer une création d’une mise à jour. Si l’anti-doublon n’avait pas déjà bloqué les envois, l’ERP aurait tenté de recréer à chaque modification un produit qui existait déjà.

Le payload n’embarquait pas active

Le troisième est presque ironique. Le besoin, c’est de propager la valeur du champ active. Et ce champ était absent du payload. Le webhook transportait l’identifiant, le nom, le prix, mais pas l’information que le toggle était censé propager.

Quand le payload n’embarque pas le champ que l’événement est censé propager, le flux ne synchronise pas une donnée, il en synchronise une autre par accident.

La refonte du contrat

Une fois les trois défauts posés, la correction devient logique. Il ne s’agit pas de rustiner mais de redéfinir le contrat pour qu’il colle au cycle de vie réel d’un produit.

// Avant : création déguisée, état manquant
{
  "event": "product_created",
  "product": { "id": 4821, "name": "...", "price": 49.90 }
}

// Après : événement explicite, état embarqué, portée déclarée
{
  "event": "product_updated",
  "occurred_at": "2026-08-17T14:03:11+02:00",
  "changed_fields": ["active"],
  "shops": [1, 3],
  "product": { "id": 4821, "name": "...", "price": 49.90, "active": false }
}

Le type d’événement dit à l’ERP quoi faire, active lui dit dans quel sens, et changed_fields lui permet d’ignorer ce qui ne le concerne pas sans avoir à comparer l’intégralité de la fiche.

L’anti-doublon n’est pas supprimé aveuglément, il est repensé. On ne veut plus « ne traiter un produit qu’une fois » mais « ne pas émettre deux webhooks strictement identiques dans une fenêtre courte ». La nuance change tout.

Enfin, on instrumente : événement détecté, champs visés, type de webhook choisi, payload émis, réponse de l’ERP. Sur un flux de synchro, l’absence de logs lisibles est exactement ce qui rend un défaut comme l’anti-doublon invisible pendant des mois. Aucune exception n’est avalée : un échec d’envoi est journalisé en error avec l’identifiant produit en contexte.

Le piège multiboutique : un seul hook pour N boutiques

Celui-là ne se voit jamais en développement mono-boutique, et il fait des dégâts durables. Regardons comment le cœur gère la portée d’une mise à jour partielle :

// src/Core/Repository/AbstractMultiShopObjectModelRepository.php
protected function updateObjectModelForShops(
    ObjectModel $objectModel,
    array $shopIds,
    string $exceptionClass,
    int $errorCode = 0
): void {
    // Force internal shop list which is used as an override of the one from Context
    $objectModel->id_shop_list = array_map(function (ShopId $shopId): int {
        return $shopId->getValue();
    }, $shopIds);

    $this->updateObjectModel($objectModel, $exceptionClass, $errorCode);
}

La liste des boutiques est posée sur l’objet, puis update() est appelé une seule fois. Le hook ne se déclenche donc pas une fois par boutique, il se déclenche une fois avec $product->id_shop_list renseigné à [1, 3] par exemple.

Un listener qui lit Context::getContext()->shop->id ou $product->id_shop_default pour déterminer la portée émettra donc un webhook pour une seule boutique, et les autres resteront désynchronisées. Sans erreur, sans log, et sans rien qui distingue ce cas du cas nominal.

La bonne source est $product->id_shop_list, avec repli sur la boutique par défaut quand elle est vide, comme dans l’exemple de listener plus haut. C’est la même vigilance que celle qu’impose toute écriture produit qui contourne le modèle objet : ce qui n’est pas explicitement porté sur l’objet n’existe pas pour le hook.

Les garde-fous : ce qui change quand on émet vraiment à chaque mise à jour

Passer de « un webhook par produit, une fois » à « un webhook à chaque mise à jour » a des conséquences qu’il faut anticiper, sous peine de remplacer un bug par un autre.

Le volume. Chaque modification de fiche, chaque toggle, chaque ajustement de prix devient un événement. Sur un catalogue actif, on passe de quelques centaines d’envois à plusieurs milliers par jour. L’ERP en face doit être dimensionné pour absorber ça.

Les opérations en masse. C’est le piège le plus sournois. Une activation groupée de plusieurs centaines de produits, un import, une mise à jour de prix sur toute une catégorie : chacun de ces gestes déclenche le hook autant de fois qu’il y a de produits touchés. Sans précaution, c’est une rafale émise en quelques secondes, qui ralentit le back-office par-dessus le marché.

La parade est de ne pas émettre en synchrone dans le hook mais d’empiler les événements et de les traiter à un rythme maîtrisé. C’est exactement l’usage d’un bus de messages, et Symfony Messenger fait très bien le travail sur une boutique récente, à condition de se rappeler qu’on empile des valeurs et jamais l’objet produit.

La boucle de synchro. Le garde-fou le plus important et le plus facile à oublier. Si l’ERP, après avoir reçu un webhook, réécrit le produit côté PrestaShop pour confirmer un état ou pousser un stock, cette écriture déclenche à son tour actionObjectProductUpdateAfter, qui émet un nouveau webhook, que l’ERP traite, qui réécrit le produit. Une boucle de synchro ne casse rien immédiatement, elle sature lentement, jusqu’au jour où le volume explose sans raison apparente.

Deux mécanismes la neutralisent. L’idempotence d’abord, désormais praticable grâce à getFieldsToUpdate(). Un drapeau de provenance ensuite : quand une écriture vient de l’ERP, on la marque pour que le listener sache ne pas la renvoyer à son émetteur.

Le plan de validation

Un flux de synchro ne se valide pas en testant uniquement le cas qui posait problème. C’est précisément l’absence de plan complet qui avait laissé passer le bug initial.

  • Création initiale : un nouveau produit déclenche bien un product_created.
  • Toggle depuis la liste : émission d’un product_updated avec le bon active, et changed_fields réduit à ["active"].
  • Le même produit, trois fois de suite : en ligne, hors ligne, en ligne. C’est exactement ce que l’anti-doublon empêchait.
  • Édition de fiche complète : émission d’un product_updated, avec changed_fields à null puisque la sauvegarde est totale.
  • Modification qui ne concerne pas l’état : un changement de description ne doit pas produire de webhook d’état.
  • Multiboutique : une bascule sur deux boutiques doit produire un événement dont le champ shops contient les deux identifiants.
  • Opération en masse : activation groupée de 200 produits, on vérifie que la file absorbe et que le back-office ne ralentit pas.
  • Écriture entrante depuis l’ERP : elle ne doit produire aucun webhook sortant.

Notre avis

Un webhook qui marche en démo ne prouve rien. La démo teste le cas idéal, un produit neuf créé une fois. Le catalogue réel passe son temps à muter : on active, on désactive, on réédite, on réactive. Le vrai test d’un flux de synchro, ce n’est pas la création, c’est la dixième mise à jour du même produit six mois après la mise en production.

Ce qui frappe à chaque fois, c’est que le hook PrestaShop faisait parfaitement son travail. Tout le problème vivait dans le contrat applicatif : un anti-doublon mal cadré, un type d’événement unique, un payload incomplet. Trois petits choix posés au moment où seul le cas de la création était en tête.

Et ce qui frappe presque autant, c’est que l’information nécessaire pour bien faire était disponible depuis PrestaShop 1.5, dans une méthode publique dont on ne trouve à peu près aucune mention. On conçoit un contrat de webhook pour le cycle de vie complet d’une entité, jamais pour son seul premier instant, et on lit le cœur avant de supposer ce qu’il transmet.

Questions fréquentes

Le hook actionObjectProductUpdateAfter se déclenche-t-il sur le toggle de la liste produits ?
Oui. Le toggle passe par ProductRepository::partialUpdate(), qui aboutit à ObjectModel::update(), lequel émet le hook en fin de méthode. Si la synchro ne part pas, le problème est dans la logique branchée sur le hook, pas dans le hook.

Comment savoir quel champ vient d’être modifié ?
Avec $product->getFieldsToUpdate(), méthode publique de ObjectModel. Elle renvoie un tableau comme ['active' => true] pour une mise à jour partielle, et null pour une sauvegarde complète. Le hook ne transporte pas de différentiel, c’est le seul moyen de le savoir sans relire la base.

Pourquoi getFieldsToUpdate() renvoie null dans mon worker ?
Parce que la couche appelante remet la liste à null dans un bloc finally, juste après update(), donc juste après le hook. Il faut extraire la liste pendant l’exécution du hook et empiler des valeurs scalaires, pas l’objet produit.

Combien de fois le hook se déclenche-t-il en multiboutique ?
Une seule, pour toutes les boutiques concernées. La portée est portée par $product->id_shop_list. Un listener qui se fie au contexte courant ne synchronisera qu’une boutique et laissera les autres dériver, sans erreur.

Faut-il émettre les webhooks en synchrone dans le hook ?
Pas sur un catalogue actif. Une opération en masse déclenche une rafale d’envois qui ralentit le back-office. Une file d’attente avec throttling lisse la charge et protège l’ERP comme l’expérience back-office.

Comment éviter une boucle de synchro ?
Deux garde-fous complémentaires : l’idempotence, pour ne pas émettre quand rien de pertinent n’a changé, et un drapeau de provenance, pour qu’une écriture poussée par l’ERP ne lui soit pas renvoyée.

Votre synchro PrestaShop vers ERP fonctionne « la plupart du temps » et vous ne savez jamais vraiment ce qui remonte ? Commencez par journaliser getFieldsToUpdate() et id_shop_list sur chaque passage du hook, vous saurez en une journée ce que votre flux voit réellement. Si le résultat vous surprend, parlons-en.

Nous vous recommandons aussi