WordPress
29 juillet 2026

Créer ses propres blocs PrettyBlocks : le page builder qui ne casse pas votre DA

Hero banner for 'Nos gammes' showing three category tiles: Hommes, Enfants, and Femmes in a fashion catalog layout (online shop).

Il existe une catégorie de sujets PrestaShop dont presque personne ne parle : comment on construit un vrai catalogue de blocs réutilisables pour un page builder. On trouve beaucoup de contenus sur « installer PrettyBlocks », très peu sur « concevoir ses blocs maison et les faire vivre sur la durée ». C’est dommage, parce que c’est exactement là que se joue la différence entre un site que le client peut piloter seul et un site où chaque nouvelle section repasse par un devis.

Voici comment on procède chez nous, avec des exemples tirés de mintyblock, notre module maison qui embarque aujourd’hui 26 blocs sur un PrestaShop 9. Les extraits sont fonctionnels mais neutralisés : contenus par défaut, couleurs et libellés ont été remplacés par des valeurs génériques, ce qui ne change rien à la mécanique.

Pourquoi PrettyBlocks plutôt qu’un page builder classique

Le réflexe habituel sur un projet e-commerce, c’est d’installer un builder générique type « drag and drop universel ». Le client obtient une liberté totale et, six mois plus tard, la home ressemble à un patchwork : cinq tailles de titres, trois nuances de bleu, des marges au pixel près qui sautent en mobile. Le coût caché n’est pas dans la mise en place, il est dans le rattrapage.

PrettyBlocks (module open source de PrestaSafe, on est en 3.2.0, avec une V4 annoncée sur laquelle on revient en fin d’article) prend le problème dans l’autre sens. Il ne propose pas un éditeur de HTML libre, mais un éditeur de compositions : le développeur déclare un catalogue de blocs, chacun avec ses champs de configuration, et le client assemble ces blocs zone par zone, page par page. C’est une logique proche de celle qu’on retrouve dans les CMS headless, à ceci près qu’on reste intégralement dans PrestaShop, sans brique supplémentaire à héberger.

Les trois bénéfices concrets qu’on constate en production :

La flexibilité sans la complexité côté éditeur. Le client ne voit pas une palette de 200 réglages CSS. Il voit « Image CTA », « Grille éditoriale », « Carrousel produits », avec cinq à dix champs par bloc, tous nommés en langage métier. Le back-office reste lisible pour quelqu’un qui n’est pas développeur.

La flexibilité sans la complexité côté front. Un bloc PrettyBlocks, c’est un template Smarty et du CSS écrits par nous, avec nos conventions, notre système de tokens, notre lazy loading. Il n’y a pas de couche d’abstraction qui génère du markup imprévisible. C’est la même raison qui nous fait préférer Twig à React sur nos projets : ce qui part en production est ce qu’on a écrit, donc ce qu’on peut auditer et optimiser.

La direction artistique reste iso sur tout le site. C’est l’argument le plus sous-estimé. Comme les blocs sont dessinés en amont, un titre de section a la même typo et le même rythme vertical qu’il soit sur la home, sur une landing ou sur une page catégorie. Le client peut tout réorganiser sans jamais sortir du système graphique, parce que les seuls réglages exposés sont ceux qu’on a jugés sûrs (un choix d’alignement, une couleur parmi celles de la palette, une variante de carte). C’est le même raisonnement que celui qui pousse PrestaShop à distribuer le fichier Figma officiel de Hummingbird v2 : un système de composants documenté vaut mieux qu’une liberté totale mal encadrée.

En résumé : on déplace le curseur de « le client peut tout faire » vers « le client peut tout composer« . Dans 95 % des demandes réelles, ça suffit largement.

Anatomie d’un bloc

Un bloc PrettyBlocks tient en deux fichiers : une classe PHP qui le déclare, un template Smarty qui le rend.

La classe implémente BlockInterface et expose une seule méthode, registerBlocks(), qui retourne un tableau de configuration. Voici notre bloc « Image CTA », volontairement simple, en version intégrale :

<?php

declare(strict_types=1);

use PrestaSafe\PrettyBlocks\Interfaces\BlockInterface;

class MintyImageCtaBlock implements BlockInterface
{
    private Module $module;

    public function __construct(Module $module)
    {
        $this->module = $module;
    }

    public function registerBlocks(): array
    {
        return [
            'name' => $this->module->l('Image CTA', 'MintyImageCtaBlock'),
            'description' => $this->module->l('Display a large image card with text and call to action.', 'MintyImageCtaBlock'),
            'code' => 'mintyblock_image_cta',
            'tab' => 'general',
            'icon' => 'PhotoIcon',
            'need_reload' => false,
            'insert_default_values' => true,
            'templates' => [
                'default' => 'module:mintyblock/views/templates/prettyblocks/image-cta.tpl',
            ],
            'config' => [
                'fields' => [
                    'eyebrow' => [
                        'type' => 'text',
                        'label' => $this->module->l('Eyebrow', 'MintyImageCtaBlock'),
                        'default' => $this->module->l('Notre sélection', 'MintyImageCtaBlock'),
                    ],
                    'title' => [
                        'type' => 'textarea',
                        'label' => $this->module->l('Title', 'MintyImageCtaBlock'),
                        'default' => $this->module->l('Découvrez la nouvelle collection', 'MintyImageCtaBlock'),
                    ],
                    'image' => [
                        'type' => 'fileupload',
                        'label' => $this->module->l('Image', 'MintyImageCtaBlock'),
                        'path' => '$/img/mintyblock/image-cta/',
                        'default' => ['url' => ''],
                    ],
                    'button_label' => [
                        'type' => 'text',
                        'label' => $this->module->l('Button label', 'MintyImageCtaBlock'),
                        'default' => $this->module->l('En savoir plus', 'MintyImageCtaBlock'),
                    ],
                    'button_url' => [
                        'type' => 'text',
                        'label' => $this->module->l('Button URL', 'MintyImageCtaBlock'),
                        'default' => '#',
                    ],
                ],
            ],
        ];
    }
}

Quelques points de vigilance à ce stade :

  • code est l’identifiant technique du bloc. Il sert de clé partout, y compris pour construire le nom du hook de rendu (on y revient plus bas). On ne le change jamais après une mise en production, sous peine de faire disparaître les blocs déjà posés par le client.
  • insert_default_values à true fait que le bloc arrive pré-rempli quand on le glisse dans une zone. C’est un détail, mais ça change complètement la perception de l’outil : le client voit immédiatement quelque chose de propre plutôt qu’un squelette vide.
  • Les default sont traduits via $this->module->l(), sinon le contenu par défaut est figé dans une seule langue.

Les types de champs qu’on utilise au quotidien : text, textarea, editor (WYSIWYG, à réserver aux vrais besoins rédactionnels), fileupload, select, checkbox, color, et selector pour piocher dans une collection PrestaShop.

Côté template, on récupère la configuration dans $block.settings :

{if isset($block.settings)}
    {assign var=settings value=$block.settings}
    <section class="mintyblock-image-cta{if isset($settings.default.container) && $settings.default.container} container{/if}">
        {if isset($settings.title) && $settings.title}
            <h2 class="mintyblock-image-cta__title mintyblock-section-title">
                {if isset($settings.eyebrow) && $settings.eyebrow}
                    <span class="minty-section-eyebrow">{$settings.eyebrow|escape:'html':'UTF-8'}</span>
                {/if}
                {$settings.title|escape:'html':'UTF-8'|nl2br nofilter}
            </h2>
        {/if}

        {if isset($settings.image.url) && $settings.image.url}
            <img class="mintyblock-image-cta__image"
                 src="{$settings.image.url|escape:'html':'UTF-8'}"
                 alt="{$settings.card_title|escape:'html':'UTF-8'}"
                 loading="lazy">
        {/if}
    </section>
{/if}

Trois habitudes qu’on s’impose sur tous les templates de blocs :

  1. Tout est conditionné. Un champ vide ne doit jamais produire un <h2> orphelin ou une image cassée. Le bloc doit rester présentable quelle que soit la façon dont le client le remplit.
  2. Tout est échappé. Ce sont des contenus saisis en back-office, donc |escape:'html':'UTF-8' systématique. Le nofilter qu’on voit sur le titre n’arrive qu’après l’échappement, uniquement pour laisser passer le nl2br.
  3. Le nommage est préfixé et en BEM (mintyblock-image-cta__title). Avec 26 blocs qui cohabitent sur une même page, c’est ce qui empêche les collisions CSS. Les classes utilitaires partagées (mintyblock-section-title, minty-section-eyebrow) sont ce qui garantit que tous les blocs respirent pareil : c’est là que se joue concrètement l’homogénéité de la DA.

Le loading="lazy" sur l’image n’est pas anecdotique non plus : un bloc éditorial est presque toujours de l’image lourde, et c’est exactement le genre de détail qui pèse sur vos Core Web Vitals. Le poser une fois dans le template, c’est le garantir pour toutes les instances du bloc, sur toutes les pages, sans jamais compter sur la vigilance de l’éditeur. Même logique pour les attributs aria-label et la sémantique des titres : l’accessibilité se traite dans le bloc, pas dans le contenu.

À noter : PrettyBlocks injecte aussi ses propres réglages transverses dans $block.settings.default (conteneur, couleur de fond, pleine largeur). On les branche sur nos classes plutôt que de les réimplémenter.

Le repeater, pour les blocs à N éléments

Beaucoup de sections sont en réalité une liste : des cartes, des logos partenaires, des questions/réponses. PrettyBlocks gère ça avec la clé repeater, en parallèle de config. Extrait de notre bloc « Grille éditoriale » :

'repeater' => [
    'name' => $this->module->l('Editorial card', 'MintyEditorialGridBlock'),
    'nameFrom' => 'title',
    'groups' => [
        'image' => [
            'type' => 'fileupload',
            'label' => $this->module->l('Image', 'MintyEditorialGridBlock'),
            'path' => '$/img/mintyblock/editorial-grid/',
            'default' => ['url' => ''],
        ],
        'background_color' => [
            'type' => 'color',
            'label' => $this->module->l('Background color', 'MintyEditorialGridBlock'),
            'default' => '#2563eb',
        ],
        'title' => [
            'type' => 'textarea',
            'label' => $this->module->l('Card title', 'MintyEditorialGridBlock'),
            'default' => $this->module->l('Des produits conçus pour durer', 'MintyEditorialGridBlock'),
        ],
        'variant' => [
            'type' => 'select',
            'label' => $this->module->l('Card size', 'MintyEditorialGridBlock'),
            'default' => 'large',
            'choices' => ['large' => 'large', 'small' => 'small'],
        ],
    ],
],

Le nameFrom est un petit confort qui compte : dans l’éditeur, chaque élément répété s’affiche avec la valeur de son champ title plutôt qu’un générique « Item 3 ». Sur un bloc à huit cartes, le client s’y retrouve tout de suite.

Côté template, les éléments arrivent dans $block.states :

{if isset($block.states) && $block.states|@count > 0}
    <div class="mintyblock-editorial-grid__cards">
        {foreach from=$block.states item=item}
            <article class="mintyblock-editorial-grid__card mintyblock-editorial-grid__card--{$item.variant|escape:'html':'UTF-8'}"
                     style="--editorial-card-bg: {$item.background_color|escape:'html':'UTF-8'}">
                <h3>{$item.title|escape:'html':'UTF-8'}</h3>
            </article>
        {/foreach}
    </div>
{/if}

Le select qui pilote une variante et la couleur passée en variable CSS custom sont typiques de notre approche : on ouvre au client un choix fini (large ou small) et une couleur, pas un champ CSS libre. La grille, les ratios et les breakpoints restent sous notre contrôle.

Injecter des données dynamiques : le hook beforeRendering

Un bloc purement éditorial se contente de ses champs. Mais dès qu’il faut afficher des produits, des stocks ou des prix, il faut du PHP au moment du rendu. PrettyBlocks expose pour ça un hook nommé d’après le code du bloc, en camel case : le bloc mintyblock_featured_products déclenche beforeRenderingMintyblockFeaturedProducts.

C’est un hook comme un autre, avec les mêmes règles d’hygiène que d’habitude (on a listé les mauvaises pratiques courantes sur les hooks PrestaShop dans un autre article) : il s’exécute sur chaque affichage de la page, donc pas de requête non bornée, pas d’appel externe non caché.

Le hook reçoit le bloc et retourne un tableau qui vient enrichir les variables du template :

public function hookBeforeRenderingMintyblockFeaturedProducts(array $params): array
{
    $settings = $params['block']['settings'] ?? [];
    $products = [];

    for ($i = 1; $i <= 8; ++$i) {
        $idProduct = $this->extractProductId($settings['product_' . $i] ?? null);
        if ($idProduct <= 0) {
            continue;
        }

        $product = $this->assembleProductById($idProduct);
        if ($product !== null) {
            $products[] = $product;
        }
    }

    return [
        'products' => $this->addProductRatings($products),
    ];
}

Les champs product_1 à product_8 sont des selector déclarés dans la classe du bloc :

$fields['product_' . $i] = [
    'type' => 'selector',
    'label' => $this->module->l('Product', 'MintyFeaturedProductsBlock') . ' ' . $i,
    'collection' => 'Product',
    'default' => $emptyDefault,
    'selector' => '{id} - {name}',
];

Le client choisit ses produits dans une liste avec autocomplétion, et le hook se charge de reconstruire un objet produit complet (prix formaté, image de couverture, disponibilité, et dans notre cas les notes d’avis clients). Le template, lui, reste bête : il boucle sur $products.

C’est le point d’équilibre qu’on cherche. Toute la logique métier est dans du PHP typé, testable, versionné. L’éditeur ne manipule que des intentions.

Deux détails qui nous ont coûté du temps sur ce type de blocs :

  • Il faut passer need_reload à true sur les blocs alimentés par un hook, sinon l’éditeur affiche un aperçu figé qui ne reflète pas le changement de réglage.
  • Sur un champ select, PrettyBlocks stocke la valeur et non la clé. Si votre hook fait ensuite un Module::getInstanceByName() avec ce qui a été stocké, la valeur doit être le nom technique du module, pas un libellé lisible. On a appris ça en migrant un bloc dont les choix étaient des labels.

Un module maison, pas un bloc par besoin

Le vrai sujet, au delà de la syntaxe, c’est l’organisation. Chez nous, tous les blocs d’un projet vivent dans un seul module mintyblock (si la structure d’un module PrestaShop ne vous parle pas encore, on a écrit un guide pour en créer un de zéro) :

modules/mintyblock/
├── mintyblock.php                  # enregistrement + hooks de rendu
├── classes/block/                  # une classe par bloc
│   ├── MintyImageCtaBlock.php
│   ├── MintyEditorialGridBlock.php
│   └── ...
└── views/
    ├── templates/prettyblocks/     # un .tpl par bloc
    ├── css/mintyblock.css
    └── js/mintyblock.js

L’enregistrement se fait via le hook ActionRegisterBlock, qui instancie chaque classe et les passe à HelperBuilder :

public function hookActionRegisterBlock()
{
    if (!class_exists('HelperBuilder')
        || !interface_exists(PrestaSafe\PrettyBlocks\Interfaces\BlockInterface::class)) {
        return [];
    }

    foreach ($this->getPrettyBlocks() as $blockClass => $blockFile) {
        if (!class_exists($blockClass)) {
            require_once $blockFile;
        }
    }

    return HelperBuilder::renderBlocks(array_map(
        fn (string $blockClass) => new $blockClass($this),
        array_keys($this->getPrettyBlocks())
    ));
}

Le garde-fou en tête de méthode n’est pas décoratif : si PrettyBlocks est désinstallé ou en cours de mise à jour, le module doit se taire proprement plutôt que de faire tomber le back-office.

Ce que cette organisation apporte :

  • Un seul point d’entrée. Ajouter un bloc, c’est une classe plus un template, sans toucher au thème ni créer un énième module.
  • Une CSS et une JS mutualisées pour tous les blocs, donc pas de multiplication de requêtes ni de duplication de styles.
  • Une base réutilisable d’un projet à l’autre. Un bloc réassurance ou « logos de paiement » est à 90 % le même partout. On repart du catalogue existant et on n’écrit vraiment que les blocs propres au client.

Le piège de production qu’on n’avait pas vu venir

Un point qui ne figure dans aucune documentation et qui peut vous coûter une demi-journée. Le type fileupload prend un chemin d’upload, et le réflexe est de le pointer vers le module lui-même :

'path' => '$/modules/mintyblock/views/img/image-cta/',

Ça fonctionne parfaitement en local. En production sur une infra conteneurisée (dans notre cas Kubernetes avec des volumes dédiés), ce chemin peut être un point de montage qui masque l’arborescence livrée par l’image Docker : l’upload réussit, mais nginx renvoie un 404 sur l’image. Les visuels posés par le client n’apparaissent jamais.

C’est exactement l’erreur n°2 de notre article sur les pièges d’une migration PrestaShop vers Kubernetes : un PVC monté sur un dossier qui contenait déjà des fichiers issus de l’image masque silencieusement leur contenu. On l’avait documenté pour les assets de modules, et PrettyBlocks nous en a servi une variante à laquelle on ne s’attendait pas, parce qu’ici le dossier est vide au départ et ne se remplit qu’en production, à la première image déposée par le client. L’échec ne se voit donc ni au build, ni à la recette technique, seulement le jour où quelqu’un publie une page.

La solution retenue : cibler le volume /img que PrestaShop persiste et sert déjà pour les images produits, partagé en lecture/écriture entre les conteneurs.

'path' => '$/img/mintyblock/image-cta/',

Avec une contrepartie : /img est de la donnée d’exécution, non versionnée, et PrettyBlocks refuse d’écrire si le dossier cible n’existe pas. Il faut donc créer ces répertoires au runtime. On le fait à l’installation, à chaque upgrade du module, et défensivement au moment où l’éditeur enregistre les blocs, en dérivant la liste des dossiers directement des champs déclarés :

/**
 * Walk a block config tree and return every `fileupload` field `path`
 * (e.g. `$/img/mintyblock/cards/`). Derives the directory list from the blocks
 * themselves so it can never drift from the fields' declared upload targets.
 */
private function collectUploadPaths($config, array &$paths = []): array

La liste ne peut ainsi jamais diverger de la réalité des blocs : ajouter un bloc avec un nouveau dossier d’upload ne demande aucune action supplémentaire.

Et la V4 dans tout ça ?

Un mot sur la suite, parce qu’elle pourrait rebattre les cartes. Guillaume Batier, à l’origine de PrettyBlocks chez PrestaSafe, a annoncé une V4 accompagnée d’une refonte de fond. Le dépôt GitHub officiel se contente pour l’instant d’un laconique « PrettyBlocks V4 arrive très prochainement », mais un premier aperçu vidéo circule depuis, et la fonctionnalité mise en avant est ambitieuse : générer des blocs depuis un chat intégré au module, directement exploitables sur n’importe quelle page de la boutique.

Sur le papier, ça contredit tout ce qui précède. Si l’IA produit les blocs à la demande, à quoi bon un catalogue dessiné en amont ?

En pratique, on pense que ça déplace le travail plutôt que de le supprimer. Un bloc généré reste un bloc à intégrer dans un système : il doit hériter des tokens du thème, respecter les conventions de nommage, passer la revue accessibilité et performance, et surtout ne pas introduire une trente-cinquième variante de carte produit. La question « qui décide de ce qui entre dans le catalogue » ne disparaît pas, elle devient plus pressante. C’est le même débat que pour la génération de code en général : le goulot d’étranglement n’a jamais été la vitesse d’écriture, mais la cohérence de l’ensemble.

Ce qu’on attend surtout de cette V4, c’est le gain au démarrage : passer de zéro à un bloc structuré en quelques secondes, puis reprendre la main pour l’aligner sur la DA. Et c’est précisément là qu’un catalogue maison garde tout son sens, parce qu’il donne à l’IA un modèle à imiter plutôt qu’une page blanche.

Ce que ça change au quotidien

Une fois le catalogue en place, la répartition des rôles devient nette. Le client compose ses pages, teste une nouvelle mise en avant, prépare une opération commerciale sans nous solliciter. Nous, on intervient quand il faut un bloc qui n’existe pas encore, ce qui se compte en jours de développement et pas en semaines de reprise de mise en page.

Et le site garde son identité. C’est ce qui nous a fait retenir PrettyBlocks plutôt qu’un builder plus permissif : la contrainte n’est pas subie, elle est le produit.

Si vous êtes sur un projet PrestaShop 1.7 ou déjà passé en 9 et que vous hésitez encore, le bon test est celui-ci : listez les dix sections qui composent réellement vos pages. Si vous pouvez les décrire avec cinq à dix champs chacune, vous n’avez pas besoin d’un éditeur de HTML libre. Vous avez besoin de dix blocs bien faits.


Vous avez un projet PrestaShop et vous vous demandez si vos pages peuvent être reprises en main par vos équipes ? Parlons-en.

Nous vous recommandons aussi