PrestaShop 9.2 : Extra Properties natif pour champs multiboutique

PrestaShop 9.2 introduit un mécanisme natif d’Extra Properties pour ajouter des champs multiboutique sans overrides — portée, schéma SQL indexé, intégration Symfony, migration et performance.

Deux écrans montrant l'interface PrestaShop avec des options de propriétés supplémentaires.

Table des matières :

  1. Extra Properties en 9.2 : un vrai correctif d’architecture pour le multiboutique
  2. Définition technique : Extra Property, portée (global/shop/lang) et cycle de persistance
  3. Stockage multiboutique : schéma, indexes, et règles de lecture sans surprises
  4. Implémentation module : injection BO Symfony, validation, et persistance « shop-aware »
  5. Migration depuis un existant (overrides/tables custom) : reprise de données, API, cache, observabilité

Extra Properties en 9.2 : un vrai correctif d’architecture pour le multiboutique

En multiboutique, le cœur de PrestaShop a historiquement deux comportements qui finissent par coûter cher en dette technique : (1) la persistance « shop-scopée » via les tables *_shop et le contexte (id_shop, Shop::getContext()), (2) l’extension des entités via overrides ou tables annexes maison. Sur des entités comme Product/Category/Manufacturer, la moindre donnée spécifique par boutique (un libellé, une promesse logistique, un seuil, un flag SEO) déclenche vite un compromis : soit vous surchargez l’ObjectModel (fragile, difficile à merger à chaque upgrade), soit vous stockez à côté (souvent sans intégration propre dans les formulaires Symfony, ni dans les exports, ni dans les flux API).

Le problème devient très concret dès que vous avez un vrai multiboutique « business » (et pas seulement un clone technique) :

  • Marque A / Marque B sur le même back-office, avec des argumentaires différents.
  • FR / BE / CH avec des contraintes de livraison, retours, ou catalogues différents.
  • B2C / B2B où certains champs (minimum de commande, promesse de disponibilité, tags internes) varient par boutique mais restent rattachés à la même entité produit.

PrestaShop 9.2 (au moment d’écrire, la branche 9.2 est encore en phase beta publique) introduit un mécanisme natif d’Extra Properties destiné à éviter ces deux impasses, surtout quand les champs doivent être multiboutique. L’objectif est clair : permettre à un module d’ajouter des propriétés supplémentaires à une ressource/entité « core » sans override, tout en respectant les règles multistore (contexte, duplication, fallback, édition « toutes boutiques », etc.). Si vous suivez déjà la modernisation 9.x (contrôleurs-services + Twig + formulaires Symfony), c’est le complément qui manquait pour arrêter de réinventer le même pattern dans chaque module (voir aussi : PrestaShop 9 : adapter modules et thèmes aux contrôleurs services et Twig).

Point important : sur une beta, l’API peut bouger (noms de classes, interfaces, points d’injection). La lecture de cet article doit être pragmatique : comprendre le modèle et sécuriser l’implémentation (tests, migration, perf), plutôt que copier-coller une signature qui changera. Pour le contexte général 9.2 (back-office modernisé, nouveaux écrans), vous pouvez recouper avec PrestaShop 9.2 Beta : One Page Checkout natif et modernisation back-office.

Définition technique : Extra Property, portée (global/shop/lang) et cycle de persistance

Une Extra Property est un champ additionnel rattaché à une entité (ex. produit) mais défini et géré par un module, avec des caractéristiques explicitement typées : nom technique, type (string/int/bool/json…), contraintes (nullable, longueur, enum), et surtout portée. En PrestaShop multiboutique, la portée n’est pas un détail : elle conditionne la table cible, le fallback, et la surface d’édition dans le BO.

Les portées utiles en pratique :

  • Global : une valeur unique quel que soit id_shop (rare sur produit, fréquent sur config technique).
  • Shop : valeur spécifique par boutique, alignée sur la logique *_shop.
  • Lang / Lang+Shop : valeur traduisible (et souvent shop-scopée aussi), comparable aux tables *_lang contenant id_lang et parfois id_shop selon les entités.

Un moyen simple de décider « la bonne portée » est de poser deux questions métier (souvent oubliées) :

1) La valeur est-elle identique si je change de boutique ?
2) La valeur est-elle identique si je change de langue ?

Si la réponse est « non » à l’une des deux, vous évitez les champs globaux par défaut. Exemple typique : une promesse de livraison (« Expédition 24h ») est souvent shop-scopée, et parfois traduisible, donc Lang+Shop dans un multiboutique FR/EN.

Le point clé en 9.x : la stack BO passe de plus en plus par Symfony (Form + Command Bus/CQRS sur certaines pages). Pour s’intégrer correctement, l’Extra Property doit suivre le même cycle : (1) définition/registre côté services, (2) injection dans le FormBuilder au bon moment, (3) validation, (4) persistance transactionnelle, (5) relecture avec le bon ShopConstraint. La logique « j’écris mon Db::getInstance()->execute() dans un hook actionObjectUpdateAfter » fonctionne encore, mais c’est précisément ce que 9.2 cherche à rendre moins nécessaire.

“A service is any PHP object that performs some global task.” — Symfony Documentation, Service Container (consulté en 2026) : https://symfony.com/doc/current/service_container.html

Cette citation n’est pas là pour faire joli : si vos Extra Properties sont déclarées comme services (registre/collector), vous obtenez une extension propre, testable, et compatible avec les évolutions de 9.x (au lieu de patcher des classes en dur).

Mini-scénario multiboutique (très réaliste) : vous gérez deux boutiques (Paris et Lyon) avec le même catalogue, mais des stocks et délais différents. Vous ajoutez un champ delivery_promise sur produit. Sans Extra Properties, vous finissez souvent avec :

  • une table custom « fourre-tout »,
  • un hook de save qui oublie le mode « toutes boutiques »,
  • une divergence BO/FO (le champ s’affiche correctement dans le BO de Paris, mais le FO Lyon prend une valeur par défaut…).

Avec une Extra Property « shop-aware », l’intention (portée + règles d’édition) devient explicite et donc plus difficile à casser lors d’une montée de version.

Stockage multiboutique : schéma, indexes, et règles de lecture sans surprises

Même si PrestaShop 9.2 fournit un mécanisme natif, vous devez comprendre où les données finissent réellement stockées, sinon vous vous tirez une balle dans le pied sur la perf et sur la cohérence. Dans PrestaShop « classique », un champ shop-scopé d’une entité X finit dans ps_x_shop (clé composite id_x, id_shop). Un champ traduisible finit dans ps_x_lang (clé composite id_x, id_lang, et parfois id_shop). Le piège récurrent des modules : créer une table unique ps_x_extra avec un JSON blob et faire des SELECT non indexés par shop, ce qui devient rapidement ingérable (latence, full scans, duplications).

Si vous devez (ou pouvez) maîtriser le schéma des Extra Properties (cas typique : migration d’un module existant), un pattern robuste pour du shop-scopé est :

CREATE TABLE ps_product_extra_shop (
  id_product INT UNSIGNED NOT NULL,
  id_shop INT UNSIGNED NOT NULL,
  delivery_promise VARCHAR(64) NULL,
  hazard_flag TINYINT(1) NOT NULL DEFAULT 0,
  PRIMARY KEY (id_product, id_shop),
  KEY idx_shop (id_shop),
  KEY idx_hazard (hazard_flag)
) ENGINE=InnoDB;

Deux compléments utiles en pratique :

  • Si vous avez une propriété Lang+Shop, évitez la tentation « une ligne par produit avec un JSON de traductions ». Une structure type ps_product_extra_lang (avec id_product, id_shop, id_lang) reste plus facilement indexable et requêtable (filtre sur langue + boutique, exports CSV, etc.).
  • Si vous filtrez souvent sur un champ (ex. hazard_flag=1 pour exclure des produits d’un canal), l’index sur ce champ est souvent rentable. À l’inverse, un index sur une colonne très peu discriminante peut être inutile : on mesure, on ne devine pas.

Techniquement, ce qui compte n’est pas le nom de table, mais : (1) clé primaire composite adaptée aux requêtes, (2) index sur id_shop, (3) indexes sélectifs sur les champs filtrés (flags), (4) pas de jointure inutile. Exemple concret : une page catégorie qui liste 48 produits, sur 3 boutiques, avec 2 Extra Properties shop-scopées. Si vous chargez les propriétés une par une (N+1), vous explosez vite (48×2 requêtes additionnelles) ; si vous faites un JOIN mal indexé, vous dégradez le TTFB. Le problème et les arbitrages sont identiques à ceux décrits dans ORM : limites, requêtes N+1 et quand préférer le SQL brut.

Checklist express côté SQL (multistore) :

  • [ ] Mes requêtes les plus fréquentes filtrent-elles par (id_shop, id_product) ou par (id_product, id_shop) ? La PK doit correspondre au chemin principal.
  • [ ] Ai-je un plan clair pour les suppressions ? (ex. suppression produit ⇒ supprimer lignes extras, idéalement via process métier)
  • [ ] Les extra properties apparaissent-elles dans un listing ? Si oui, ai-je un JOIN unique et stable, pas une requête par ligne.
  • [ ] Ai-je des champs « drapeaux » (bool) qui impactent beaucoup le FO ? Indexer seulement si le filtre est courant.

En lecture, la règle à formaliser (et à tester) : quelle valeur quand la boutique n’a pas de valeur ? Deux stratégies existent et doivent être explicites :

1) Strict shop : si pas de valeur pour id_shop, on considère NULL/DEFAULT (pas de fallback).
2) Fallback : on retombe sur une valeur globale ou sur la boutique par défaut du groupe (équivalent de certains comportements *_shop).

Le mécanisme natif d’Extra Properties vise justement à standardiser ce point : si vous le redéfinissez implicitement dans le code du module, vous recréez des comportements divergents entre BO, FO et API.

Conseil de QA multiboutique : ajoutez au minimum 4 cas dans vos tests (manuels ou automatisés) :

  • boutique A a une valeur, boutique B non ⇒ vérifiez strict vs fallback ;
  • duplication d’une boutique ⇒ la propriété est-elle dupliquée comme attendu ?
  • édition en contexte « toutes boutiques » ⇒ le comportement est-il cohérent avec votre choix (écrire partout / désactiver / fallback global) ?
  • import produit (CSV ou ERP) ⇒ l’extra property est-elle écrasée, conservée, ou ignorée ?

Implémentation module : injection BO Symfony, validation, et persistance « shop-aware »

Pré-requis réalistes (à adapter à votre build) : PrestaShop 9.2.x, PHP 8.2+ (et idéalement 8.3 si votre stack est prête), base MySQL/MariaDB conforme aux exigences 9.x. Si vous avez déjà repéré les incohérences de doc et les écarts entre recommandations et réalité, gardez une approche « code source > doc marketing » (cf. PrestaShop 9 : versions PHP recommandées et incohérences de documentation).

Côté BO, l’intégration propre se fait via les hooks de FormBuilder (sur les pages modernisées) plutôt que via des overrides de contrôleurs. Le pattern est le même que pour tout champ additionnel : vous modifiez le formulaire, vous lisez la valeur selon ShopConstraint, puis vous persistez dans le handler.

Exemple (pseudo-code volontairement agnostique sur les noms exacts des classes 9.2) :

public function hookActionProductFormBuilderModifier(array $params): void
{
    $formBuilder = $params['form_builder'];
    $shopId = (int) $params['id_shop']; // dépend du contexte

    $formBuilder->add('delivery_promise', TextType::class, [
        'required' => false,
        'constraints' => [
            new Length(['max' => 64]),
        ],
        'help' => 'Valeur spécifique à la boutique courante',
    ]);

    // Préremplissage
    $productId = (int) $params['id'];
    $value = $this->extraPropertyRepository->getForShop($productId, $shopId);
    $formBuilder->setData(array_merge($formBuilder->getData(), [
        'delivery_promise' => $value,
    ]));
}

public function hookActionAfterUpdateProductFormHandler(array $params): void
{
    $productId = (int) $params['id'];
    $data = $params['form_data'];
    $shopId = (int) $params['id_shop'];

    $this->extraPropertyRepository->upsertForShop(
        $productId,
        $shopId,
        (string) ($data['delivery_promise'] ?? '')
    );
}

Ce qui fait la différence en multiboutique n’est pas l’ajout du champ ; c’est la gestion des modes de contexte : boutique unique, groupe, « toutes boutiques ». En mode « toutes boutiques », vous devez décider si vous (a) écrivez la même valeur sur chaque id_shop, (b) interdisez l’édition (champ disabled), (c) écrivez une valeur globale et laissez un fallback. Ne pas trancher = bugs silencieux.

Pour éviter une ambiguïté fréquente, documentez la règle dans l’UI (aide de champ) et dans le code (constantes/enum), par exemple :

  • « Ce champ est spécifique à la boutique » (strict shop)
  • « Ce champ est commun à toutes les boutiques » (global)
  • « En mode toutes boutiques, la valeur sera appliquée à chaque boutique » (bulk write)

Sur la validation/sécurité, la règle reste basique mais non négociable : vous encodez selon le contexte (HTML attribute, HTML text, JS, URL) et vous ne faites pas confiance aux données venant du BO (un compte admin compromis, un XSS stocké, etc.).

“The primary defense against XSS is context-sensitive output encoding.” — OWASP Cheat Sheet Series, Cross Site Scripting Prevention Cheat Sheet (consulté en 2026) : https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html

Pour durcir proprement : appliquez la checklist CSP/encodage (et testez vos templates) en vous appuyant sur XSS : checklist de durcissement PrestaShop, CSP et encodage contexte. Et si votre module expose ces champs dans des grilles BO, gardez un œil sur la structure CRUD moderne (listings, actions, vues), car les champs additionnels finissent souvent là aussi : PrestaShop module CRUD : personnaliser les listings, actions et vues détail.

Bon réflexe “qualité” : testez au moins un cas « sale » côté BO (copier-coller depuis Word, guillemets typographiques, caractères spéciaux) pour vérifier :

  • validation (longueur, jeu de caractères),
  • persistance (pas de troncation silencieuse),
  • rendu FO (encodage correct, pas de HTML injecté).

Migration depuis un existant (overrides/tables custom) : reprise de données, API, cache, observabilité

La migration vers des Extra Properties « natives » doit être traitée comme une mini-migration applicative, pas comme un patch de module. Si votre module utilisait un override de Product (ou des hooks d’ObjectModel) pour porter une propriété multistore, le plan minimal : (1) geler l’écriture, (2) ajouter le nouveau stockage, (3) backfill, (4) basculer la lecture, (5) basculer l’écriture, (6) supprimer l’override. Sur des shops à fort CA, vous faites ça en blue/green, avec rollback clair (cf. Migration PrestaShop : audit technique et plan incrémental blue/green et Migration PrestaShop 9 : sécurité, tests et plan de rollback).

Pour la reprise de données, ne cherchez pas l’élégance : cherchez la traçabilité. Exemple de backfill (à exécuter hors trafic si possible), en pseudo-SQL : vous mappez (id_product, id_shop) depuis votre ancienne table et vous INSERT ... ON DUPLICATE KEY UPDATE. Ensuite vous comparez des métriques simples : nombre de lignes par shop, taux de NULL, échantillonnage de 100 produits par boutique. Si vous ne documentez pas ces chiffres, vous ne verrez pas les trous (et en multistore, les trous sont fréquents : shops créés après coup, duplication partielle, imports incomplets).

Mini plan de migration “sans surprise” (exploitable en prod) :

  • Étape A — lecture double (feature flag) : votre code lit d’abord le nouveau stockage, sinon retombe sur l’ancien. Vous logguez le taux de fallback (combien de fois on a dû lire l’ancien).
  • Étape B — backfill : vous remplissez le nouveau stockage, puis vous refaites tourner l’étape A jusqu’à obtenir ~0% de fallback.
  • Étape C — écriture double (courte période) : pendant 24/48h, vous écrivez dans les deux stockages (utile si vous avez plusieurs frontaux ou des jobs asynchrones).
  • Étape D — cutover : lecture/écriture uniquement sur Extra Properties.
  • Étape E — suppression : nettoyage de l’ancien schéma et de l’override.

Ce pattern « lecture double → écriture double → cutover » évite le piège classique : basculer d’un coup et découvrir après coup qu’un shop secondaire n’avait pas la donnée.

Exposition API : deux surfaces existent dans l’écosystème PrestaShop.

Point d’attention API multiboutique : si une propriété est shop-scopée, votre contrat d’API doit l’assumer. Deux approches propres :

  • passer explicitement id_shop (ou un header/contexte) dans les endpoints,
  • ou exposer une collection de valeurs par boutique (plus verbeux, mais sans ambiguïté).

Enfin, ne sous-estimez pas l’impact perf/caching : deux champs multiboutique ajoutés à la fiche produit peuvent suffire à dégrader un listing si vous introduisez des requêtes supplémentaires. Si votre stack a Redis, utilisez-le pour amortir les lectures « read-heavy » (cache par (id_product, id_shop) avec invalidation sur save), mais uniquement si vous savez mesurer avant/après (TTFB, temps SQL, hit rate). Pour l’implémentation cache, recoupez avec Redis PrestaShop : configurer le cache sur VPS ou serveur dédié et, côté observabilité, instrumentez les erreurs (logs PHP/MySQL/JS) : PrestaShop monitoring d’erreurs : logs PHP, MySQL, JavaScript et alertes e-mail. Sans ça, une régression « invisible » sur une boutique secondaire vous explosera en prod le jour où le trafic monte.

Dernier conseil très opérationnel : quand vous déployez un module qui introduit des Extra Properties multiboutique, surveillez pendant quelques jours des métriques par boutique, pas uniquement globales (un shop peu fréquenté peut masquer un bug de contexte). Un simple tableau de bord (temps SQL, taux d’erreurs BO, pages produit les plus consultées) suffit souvent à repérer les incohérences de lecture/écriture avant qu’elles ne se transforment en incidents.


À lire aussi