Een eigen frontend-module in Hyvä bouw je in uren, niet dagen. Geen Knockout, geen RequireJS, geen UI Components die je layout opblazen.
Custom Magento frontend bouwen in Hyvä: de developer workflow
Een eigen frontend-module in Hyvä bouw je in uren, niet dagen. Geen Knockout, geen RequireJS, geen UI Components die je layout opblazen. Wat je krijgt: een .phtml-template, Tailwind-classes, een snufje Alpine.js en een view model voor je data. Dat is het.
Wij bouwen bij klanten wekelijks custom frontend-modules in Hyvä. In dit artikel lopen we de complete workflow door — van module-skeleton tot live in de browser. Geen theorie, wel de exacte bestanden, paden en commando's die je nodig hebt.
Waarom de Hyvä-frontend fundamenteel anders is
In Luma schrijf je een feature in vier lagen: layout XML, een UI Component-config in XML, een Knockout-template (.html) en een JavaScript-module via RequireJS. Vier bestanden, drie talen, één debug-hel.
In Hyvä schrijf je dezelfde feature in twee lagen: layout XML en een .phtml met inline Tailwind en Alpine. De rendering gebeurt server-side in PHP. JavaScript is progressive enhancement, geen vereiste.
Het verschil in praktijk:
- Geen build-stap voor logica. Alpine draait direct in de browser, geen RequireJS-bundling.
- Geen Knockout-bindings. Je template is gewoon HTML met PHP en
x-data. - Eén CSS-pipeline. Tailwind via PurgeCSS, alleen de classes die je gebruikt komen in de output.
Het resultaat zie je terug in de cijfers. We hebben dat uitgebreid gemeten in onze Hyvä vs Luma performance-vergelijking: minder JavaScript, snellere Time to Interactive, betere Core Web Vitals.
Stap 1: de module-skeleton
Een frontend-feature hoort thuis in een eigen module. Niet in het theme, tenzij het puur cosmetisch is. Begin in app/code/Vendor/Feature/.
Twee bestanden maken de module:
// app/code/Coding/Productlabel/etc/module.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="Coding_Productlabel"/>
</config>
// app/code/Coding/Productlabel/registration.php
<?php
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(
ComponentRegistrar::MODULE,
'Coding_Productlabel',
__DIR__
);
Daarna activeren:
bin/magento module:enable Coding_Productlabel
bin/magento setup:upgrade
De module bestaat nu. Tijd voor de frontend.
Stap 2: layout XML — waar je block landt
Layout XML bepaalt waar je template rendert. Niets meer. Geen styling, geen data-config zoals bij Luma UI Components.
Plaats het bestand in view/frontend/layout/. De bestandsnaam matcht de handle van de pagina waar je op aanhaakt. Voor de productpagina is dat catalog_product_view.xml.
<!-- app/code/Coding/Productlabel/view/frontend/layout/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="product.info.main">
<block class="Magento\Framework\View\Element\Template"
name="coding.product.label"
template="Coding_Productlabel::label.phtml"
before="product.info.price">
<arguments>
<argument name="view_model"
xsi:type="object">Coding\Productlabel\ViewModel\Label</argument>
</arguments>
</block>
</referenceContainer>
</body>
</page>
Drie dingen om op te letten:
referenceContainer name— kies een bestaand container op de pagina. Gebruikbin/magento dev:template-hints:enableof de browser-inspector om container-namen te vinden.before/after— positioneert je block ten opzichte van siblings.view_modelargument — hier injecteer je je data-laag. Geen logica in het block, geen logica in de template. Dit is de Hyvä-manier.
Stap 3: het view model — je data-laag
Het view model is waar je business-logica leeft. Geen database-queries in je .phtml. Geen ObjectManager. Een schone class met dependency injection.
<?php
// app/code/Coding/Productlabel/ViewModel/Label.php
declare(strict_types=1);
namespace Coding\Productlabel\ViewModel;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Framework\Registry;
use Magento\Framework\View\Element\Block\ArgumentInterface;
class Label implements ArgumentInterface
{
public function __construct(
private readonly Registry $registry
) {
}
public function getProduct(): ?ProductInterface
{
return $this->registry->registry('current_product');
}
public function getLabelText(): ?string
{
$product = $this->getProduct();
if ($product === null) {
return null;
}
$created = (int) strtotime((string) $product->getCreatedAt());
$isNew = $created > strtotime('-30 days');
return $isNew ? 'Nieuw' : null;
}
}
Belangrijke punten:
implements ArgumentInterface— verplicht, anders weigert Magento het object als view model in layout XML.- Constructor injection — geen
ObjectManager, geen factory-misbruik. Dependencies komen binnen via de constructor. readonly-properties — PHP 8.1+, en Hyvä-projecten draaien op Magento 2.4.6+ met PHP 8.1 of hoger.
Het view model is testbaar in isolatie. Dat is precies de winst: je .phtml blijft dom, je logica is unit-testbaar.
Stap 4: de .phtml met Tailwind en Alpine
Nu de template. Het view model komt binnen via $block->getViewModel() of via een getter die de layout-config aanlevert.
<?php
/** @var \Magento\Framework\View\Element\Template $block */
/** @var \Coding\Productlabel\ViewModel\Label $viewModel */
/** @var \Hyva\Theme\Model\ViewModelRegistry $viewModels */
$viewModel = $block->getViewModel();
$label = $viewModel->getLabelText();
?>
<?php if ($label): ?>
<div x-data="{ visible: true }"
x-show="visible"
class="inline-flex items-center gap-1 rounded-full
bg-emerald-600 px-3 py-1 text-sm font-semibold text-white">
<span><?= $escaper->escapeHtml($label) ?></span>
<button type="button"
@click="visible = false"
class="ml-1 text-white/80 hover:text-white"
aria-label="Sluiten">
×
</button>
</div>
<?php endif; ?>
Wat hier gebeurt:
- Tailwind-classes inline. Geen aparte CSS-file. PurgeCSS scant je
.phtmlen houdt alleen deze classes over in de productie-build. - Alpine via
x-data. Devisible-state leeft volledig client-side. Geen RequireJS-module nodig. $escaper->escapeHtml(). Altijd escapen. Hyvä levert$escaperstandaard in elke template. Vergeet dit niet — het is je eerste verdedigingslinie tegen XSS.
ViewModelRegistry: view models ophalen ín de template
Soms heb je een view model nodig dat niet via layout XML is geïnjecteerd. Hyvä lost dit op met de ViewModelRegistry. Je haalt elk view model on-demand op:
<?php
$labelViewModel = $viewModels->require(\Coding\Productlabel\ViewModel\Label::class);
$label = $labelViewModel->getLabelText();
?>
Dit is handig wanneer je in een gedeelde template meerdere view models nodig hebt zonder de layout XML vol te zetten met arguments. De $viewModels-variabele is in Hyvä-templates standaard beschikbaar.
Stap 5: Tailwind-tooling en de CSS-build
Hyvä's CSS leeft in een eigen child-theme onder app/design/frontend/Vendor/theme/web/tailwind/. Daar staat je tailwind.config.js en je package.json.
De config bepaalt welke bestanden PurgeCSS scant. Standaard staat dit al goed, maar als je module-templates buiten het theme leven, voeg je het pad toe:
// tailwind.config.js
module.exports = {
content: [
'../../../../../**/*.phtml',
'../../../../../../../../app/code/**/view/frontend/**/*.phtml',
],
theme: {
extend: {
colors: {
brand: '#0d9488',
},
},
},
plugins: [
require('@tailwindcss/forms'),
],
};
De build-commando's:
cd app/design/frontend/Coding/theme/web/tailwind
npm ci
npm run watch # development: rebuild bij elke wijziging
npm run build # productie: geminificeerde, gepurgede CSS
npm run watch is je beste vriend tijdens development. Elke keer dat je een Tailwind-class toevoegt in een .phtml, regenereert de CSS automatisch. Geen setup:static-content:deploy nodig voor CSS-wijzigingen in development.
Stap 6: ESLint en code quality
Alpine-componenten kunnen groeien. Zodra je inline x-data-objecten te groot worden, verplaats je ze naar een los JavaScript-bestand. Dan wil je linting.
Een minimale .eslintrc voor Alpine-projecten:
{
"env": { "browser": true, "es2021": true },
"extends": ["eslint:recommended"],
"parserOptions": { "ecmaVersion": "latest", "sourceType": "module" },
"globals": { "Alpine": "readonly" }
}
Voor grotere Alpine-componenten gebruik je Alpine.data() in een apart bestand in plaats van inline x-data:
// web/js/product-label.js
document.addEventListener('alpine:init', () => {
Alpine.data('productLabel', () => ({
visible: true,
dismiss() {
this.visible = false;
localStorage.setItem('label_dismissed', '1');
},
}));
});
In de template wordt dat dan x-data="productLabel". Schoon, lintbaar, en testbaar. Wij hanteren als vuistregel: meer dan vijf regels logica in x-data? Verplaatsen naar Alpine.data().
Stap 7: local dev en de feedback-loop
De snelste feedback-loop tijdens Hyvä-development:
- Template hints aan —
bin/magento dev:template-hints:enabletoont welke.phtmlwaar rendert. - Cache slim flushen — bij layout XML-wijzigingen:
bin/magento cache:clean layout full_page. Bij alleen.phtml-wijzigingen hoeft de cache niet eens geflusht in developer mode. - Tailwind watch draaien — in een aparte terminal, zodat CSS live meegaat.
- Developer mode —
bin/magento deploy:mode:set developer. Geen static-content deploy meer nodig, templates worden direct gelezen.
Een typische dev-sessie ziet er zo uit:
bin/magento deploy:mode:set developer
bin/magento dev:template-hints:enable
cd app/design/frontend/Coding/theme/web/tailwind && npm run watch
Daarna pas je .phtml aan, ververst de browser, en ziet direct resultaat. Geen build-wachttijd voor logica, geen RequireJS-cache die je voor de gek houdt.
Wanneer is Hyvä níet de juiste keuze? Als je shop diep verweven zit met betaalde Luma-extensies die geen Hyvä-compatibiliteit hebben, weeg je de migratiekosten zorgvuldig af. We zijn daar eerlijk over — soms is een gefaseerde aanpak slimmer. Lees hoe dat werkt in Hyvä installeren op een bestaande Magento-shop.
De complete bestandsstructuur
Zo ziet de afgeronde module eruit:
app/code/Coding/Productlabel/
├── etc/module.xml
├── registration.php
├── ViewModel/Label.php
└── view/frontend/
├── layout/catalog_product_view.xml
└── templates/label.phtml
Vier bestanden plus registratie. Vergelijk dat met de Luma-equivalent: dezelfde feature kost daar al snel het dubbele aantal bestanden plus een Knockout-template en een RequireJS-module. Die reductie is precies waarom de Hyvä checkout zoveel beter converteert dan Luma — minder complexiteit, minder bugs, snellere pagina's.
Wil je dit door senioren laten bouwen?
Custom Hyvä-frontend is ons dagelijks werk. Geen junioren die op je project leren, geen projectmanagers tussen jou en de developer. Bekijk onze aanpak op de Hyvä-pagina en onze bredere Magento-expertise. Wij zijn Hyvä Certified — dat betekent dat onze developers het framework van binnenuit kennen.
Heb je een concrete feature die je in Hyvä wilt laten bouwen, of twijfel je over de migratie? Neem contact op en we kijken samen naar de scope. Eerlijk advies, ook als Hyvä niet het antwoord is.
Veelgestelde vragen
Heb ik een aparte module nodig voor elke Hyvä-frontend-feature?
Niet altijd. Puur cosmetische aanpassingen (kleuren, spacing) horen in je child-theme. Maar zodra een feature een view model, eigen layout XML of herbruikbare logica heeft, maak je een eigen module. Dat houdt je theme schoon en je feature herbruikbaar over meerdere projecten.
Kan ik Knockout-componenten uit Luma hergebruiken in Hyvä?
Nee. Hyvä gebruikt Alpine.js en geen Knockout of RequireJS. Bestaande Luma-.html-templates en JavaScript-modules werken niet. Je herschrijft de frontend-laag. De backend-logica (models, repositories, API's) blijft bruikbaar — die zit niet in de frontend en hoeft niet aangeraakt te worden.
Waarom werkt mijn Tailwind-class niet in productie?
Vrijwel altijd PurgeCSS. Tailwind verwijdert in de productie-build alle classes die het niet letterlijk in een gescand bestand terugvindt. Dynamisch samengestelde classnames ('bg-' + kleur) overleven dit niet. Gebruik volledige classnames of een safelist in tailwind.config.js.
Moet ik setup:static-content:deploy draaien tijdens development?
Nee, niet in developer mode. Templates en static files worden dan direct gelezen. Voor CSS gebruik je npm run watch in de Tailwind-map. Static-content deploy heb je alleen nodig bij een productie-build met deploy:mode:set production.
Wat is het verschil tussen een view model en een block in Hyvä?
Een block is de container die in layout XML wordt geplaatst en de template rendert. Een view model is de data- en logica-laag die je in dat block injecteert. Hyvä raadt aan om generieke blocks (Magento\Framework\View\Element\Template) te combineren met dedicated view models, in plaats van custom block-classes. Dat maakt je logica testbaar en je code beter herbruikbaar.

Geschreven door Ruthger Idema
15+ jaar ervaring in e-commerce development. Gespecialiseerd in Magento, Shopify en Laravel maatwerk.
Meer over ons team →