On a tous connu ce moment. Un besoin tout bête : afficher une petite donnée dans un template. Le réflexe presque automatique ? Créer un Block qui étend celui du core, écraser une méthode, ajouter une preference dans le di.xml, et croiser les doigts pour qu’aucune autre extension n’ait eu exactement la même idée. Trois mois plus tard, personne ne comprend plus pourquoi le prix s’affiche deux fois sur la fiche produit, et le fichier .phtml ressemble à un script PHP complet avec des if imbriqués sur cinq niveaux.
Bonne nouvelle : depuis Magento 2.2, il existe une réponse propre à ce problème. Elle s’appelle le ViewModel, et une fois qu’on l’a adoptée, on ne revient plus en arrière. Dans cet article, on va voir concrètement comment s’en servir, avec un cas réel que vous croiserez tôt ou tard : enrichir le dataLayer d’un module Google Tag Manager tiers sans toucher à son code.
Un ViewModel, c’est quoi (et ce que ce n’est pas)
Un ViewModel est une simple classe PHP qui implémente Magento\Framework\View\Element\Block\ArgumentInterface. Cette interface est vide : elle ne sert qu’à marquer votre classe comme « injectable dans un template via le layout ». C’est tout. Pas d’héritage à rallonge, pas de preference qui écrase le core, pas de méthode magique à deviner.
Son rôle : fournir des données et de la logique de présentation à un template, en restant totalement découplé du Block. Là où un Block custom vous force à hériter d’une classe du core (et donc à entrer en conflit avec la moindre extension qui fait pareil), le ViewModel se contente d’être injecté à côté. Le Block reste le Block natif de Magento, vous n’y touchez pas.
Trois avantages concrets qui changent la vie : votre logique devient testable unitairement (c’est une classe autonome avec des dépendances injectées), réutilisable (on va y revenir, c’est le point clé) et lisible (le .phtml redevient de l’affichage, pas un programme).
Notre cas concret : enrichir un dataLayer GTM sans réécrire le module
Voici une situation classique en agence. Le client utilise un module Google Tag Manager tiers qui pousse déjà les données de base dans le dataLayer (identifiant produit, prix, catégorie). Le service marketing veut maintenant tracker deux informations supplémentaires sur la fiche produit : la marque et la disponibilité en stock.
La tentation, c’est d’ouvrir le template du module tiers et de le bricoler. Mauvaise idée : à la prochaine mise à jour Composer, votre modification saute. On ne veut pas réécrire le code GTM du module. On veut juste ajouter de la donnée à côté. Et le dataLayer de GTM est justement fait pour ça : on peut le pousser en plusieurs fois, chaque push venant compléter le précédent.
C’est exactement le terrain de jeu d’un ViewModel : on calcule proprement nos données custom, et on les injecte via notre propre petit template, sans jamais toucher au module tiers.
Le ViewModel : regroupé par fonctionnalité, pas par bloc
Première règle importante, et c’est là qu’on voit les bons devs : un ViewModel se regroupe par fonctionnalité, pas par bloc. Un même ViewModel peut très bien servir plusieurs blocs. Ici, notre logique « données produit pour le dataLayer » est une fonctionnalité cohérente ; peu importe qu’elle finisse utilisée par un ou trois templates différents.
Voici notre classe :
<?php
declare(strict_types=1);
namespace MageExpert\Analytics\ViewModel\Datalayer;
use Magento\Framework\Registry;
use Magento\Framework\Serialize\Serializer\Json;
use Magento\Framework\View\Element\Block\ArgumentInterface;
use Magento\CatalogInventory\Api\StockRegistryInterface;
class Product implements ArgumentInterface
{
public function __construct(
private readonly Registry $registry,
private readonly StockRegistryInterface $stockRegistry,
private readonly Json $json
) {
}
/**
* @return array<string, mixed>
*/
public function getData(): array
{
$product = $this->registry->registry('current_product');
// Règle de gestion : hors d'une fiche produit, on ne pousse rien.
if ($product === null) {
return [];
}
return [
'event' => 'productMeta',
'sku' => $product->getSku(),
'brand' => (string) $product->getAttributeText('manufacturer'),
'inStock' => $this->isInStock((int) $product->getId()),
];
}
public function getDataLayerJson(): string
{
return $this->json->serialize($this->getData());
}
private function isInStock(int $productId): bool
{
return (bool) $this->stockRegistry
->getStockStatus($productId)
->getStockStatus();
}
}
Rien de sorcier : on récupère le produit courant, on applique une règle de gestion (si on n’est pas sur une fiche produit, on ne pousse rien du tout), et on assemble le tableau de données. On sérialise ensuite en JSON via le service Json de Magento — jamais avec un json_encode() sauvage.
ViewModel et base de données : oui, mais en connaissance de cause
Vous avez remarqué le StockRegistryInterface ? Il fait un appel à la base de données pour connaître le statut de stock. Et c’est parfaitement légitime.
On lit parfois qu’« un ViewModel ne doit jamais toucher la base ». C’est un dogme qui vous limite très vite. Un ViewModel a le droit d’avoir des règles de gestion et d’interroger la BDD — via un service contract ou un repository, comme ici, pas en tapant directement dans un ResourceModel. Le vrai point d’attention n’est pas l’interdiction, c’est de ne jamais oublier ce que fait le ViewModel quand on l’utilise.
Concrètement : ce getStockStatus() s’exécute à chaque rendu du template. Sur une fiche produit, le Full Page Cache joue son rôle, donc l’appel n’a lieu qu’une fois par génération de cache — parfait. Mais si vous appeliez ce même ViewModel dans une boucle (une liste de 40 produits), vous déclencheriez 40 requêtes. Et si vos données étaient spécifiques au client (groupe client, panier), le rendre côté serveur casserait le cache : il faudrait alors passer par les sections customer-data (private content). La règle n’est donc pas « pas de BDD », mais « sachez ce que votre ViewModel coûte, et où il s’exécute ».
Le brancher dans le layout (et nommer ses arguments comme un humain)
On déclare notre template et on lui injecte le ViewModel dans catalog_product_view.xml :
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<body>
<referenceContainer name="content">
<block class="Magento\Framework\View\Element\Template"
name="magexpert.gtm.product"
template="MageExpert_Analytics::gtm/product.phtml">
<arguments>
<argument name="product_data_layer" xsi:type="object">
MageExpert\Analytics\ViewModel\Datalayer\Product
</argument>
</arguments>
</block>
</referenceContainer>
</body>
</page>
Un mot sur le nommage, parce que c’est un détail qui trahit tout de suite un code soigné. On voit trop souvent des arguments nommés view_model ou viewModel. C’est crado. Un XML se lit comme du PHP : en le parcourant, on doit comprendre ce qu’il fait sans ouvrir la classe. product_data_layer dit exactement ce que c’est. Le jour où votre template contiendra deux ViewModels, vous serez bien content d’avoir des noms parlants plutôt que view_model et view_model_2.
Petit rappel utile : Magento transforme product_data_layer en getter getProductDataLayer() dans le template. Nom explicite d’un côté, appel propre de l’autre.
Une même fonctionnalité, plusieurs blocs
Puisqu’on parlait de réutilisation : imaginons qu’on veuille aussi exploiter ces données produit pour générer un bloc de données structurées (JSON-LD). Pas besoin d’écrire un second ViewModel. On réutilise exactement le même :
xml
<block class="Magento\Framework\View\Element\Template"
name="magexpert.jsonld.product"
template="MageExpert_Analytics::seo/product-jsonld.phtml">
<arguments>
<argument name="product_data_layer" xsi:type="object">
MageExpert\Analytics\ViewModel\Datalayer\Product
</argument>
</arguments>
</block>
Comme le layout résout les objets en instances partagées, c’est littéralement la même classe qui alimente les deux blocs. La logique est écrite une seule fois, testée une seule fois, maintenue une seule fois. C’est ça, « regroupé par fonctionnalité ».
Le template : juste de l’affichage
Le .phtml retrouve enfin son vrai rôle. Aucune logique métier, aucun appel à un repository : il affiche, point.
<?php
/** @var \Magento\Framework\View\Element\Template $block */
/** @var \MageExpert\Analytics\ViewModel\Datalayer\Product $viewModel */
$viewModel = $block->getProductDataLayer();
$data = $viewModel->getData();
if (empty($data)) {
return;
}
?>
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push(<?= /* @noEscape */ $viewModel->getDataLayerJson() ?>);
</script>
On récupère le ViewModel via son getter, on vérifie qu’on a bien des données (notre règle de gestion « hors fiche produit »), et on pousse notre objet dans le dataLayer. Le module GTM tiers a poussé ses données ; nous, on complète les nôtres juste après, sans jamais l’avoir modifié. Objectif atteint. Le commentaire @noEscape est volontaire : la sortie de Json::serialize() est déjà un JSON sûr, on ne veut surtout pas l’échapper à nouveau.
Où ranger tout ça : le dossier ViewModel/
Dernier point d’hygiène, souvent négligé. Tous vos ViewModels vivent dans un dossier ViewModel/ à la racine du module. Et rien ne vous empêche d’y créer une arborescence dès que le nombre grandit — c’est même recommandé :
app/code/MageExpert/Analytics/
├── ViewModel/
│ └── Datalayer/
│ ├── Product.php
│ └── Customer.php
├── view/frontend/
│ ├── layout/
│ │ └── catalog_product_view.xml
│ └── templates/
│ ├── gtm/
│ │ └── product.phtml
│ └── seo/
│ └── product-jsonld.phtml
└── etc/
└── module.xml
Un sous-dossier par domaine fonctionnel (Datalayer, Catalog, Checkout…) et vous vous y retrouvez même six mois plus tard. Un ViewModel bien rangé, c’est un ViewModel qu’on réutilise au lieu d’en réécrire un troisième qui fait la même chose.
ViewModel, Block ou Helper : lequel choisir ?
La question revient sans arrêt en revue de code, alors clarifions. Ces trois outils ne jouent pas dans la même catégorie, et les confondre est la source de la moitié des surcharges bancales qu’on croise en audit.
Le Block est le chef d’orchestre du rendu : il porte le template, gère le cache (getCacheKeyInfo, getIdentities) et orchestre l’affichage. Vous en avez besoin quand vous créez un nouveau composant graphique de A à Z. Mais si votre seul objectif est d’ajouter une donnée à un template existant, hériter d’un Block du core juste pour ça, c’est prendre un marteau-piqueur pour planter une punaise — et ouvrir la porte aux conflits de preference.
Le ViewModel est le fournisseur de données et de logique de présentation. Il ne connaît rien au cache ni au template : il répond à des questions (« ce produit est-il en stock ? », « quelle est sa marque ? »). C’est votre outil par défaut dès qu’il s’agit d’alimenter un .phtml avec de la logique un peu réfléchie.
Le Helper (AbstractHelper), lui, appartient de plus en plus au passé. Longtemps utilisé comme fourre-tout accessible partout, il souffre de deux défauts : on y met tout et n’importe quoi, et il est appelable depuis n’importe où, ce qui casse le découplage. Pour de la logique liée à l’affichage, le ViewModel le remplace avantageusement. Gardez le Helper pour de la configuration transverse réellement partagée entre plusieurs couches, et encore, préférez un service dédié quand vous le pouvez.
La règle mentale à retenir : besoin d’afficher une donnée dans un template → ViewModel. Neuf fois sur dix, c’est la bonne réponse, et vous vous épargnez une surcharge de plus.
Conclusion
Le ViewModel, ce n’est pas juste une syntaxe à la mode : c’est une façon de séparer proprement le « quoi afficher » du « comment le calculer ». On regroupe par fonctionnalité, on assume les appels à la base quand ils sont pertinents (en gardant la tête froide sur leur coût), on nomme ses arguments comme des humains, et on range tout dans un dossier ViewModel/ bien structuré.
Résultat : plus de surcharge fragile, des templates lisibles, du code testable, et des modules tiers qu’on enrichit au lieu de les casser. La prochaine fois que vous vous surprenez à écrire class MonBlock extends \Magento\...\Block\Product\View juste pour afficher une donnée — respirez, et écrivez un ViewModel à la place. Votre vous du futur vous dira merci. 🙂
