077 —Joomla
Joomla-template ontkoppelen: een gefaseerd extractieplan
Een 12 jaar oude Joomla-template, vastgeklonken aan het CMS, moest eruit zonder de live site om te leggen. Dit is hoe we het bestand voor bestand uit elkaar trokken.
Een Nederlands bureau waar we mee werken stuurde op een zondagavond een Loom. Twaalf minuten waarin de lead developer door /templates/agency_custom_2014/index.php scrolde op een Joomla 3.10-installatie die binnenkort naar Joomla 5 zou worden gehesen. Om de regel stond er een $db->setQuery(), een hardcoded module-ID, of een plugin-aanroep verpakt in een try/catch die fouten geruisloos opslokte. De template was zes jaar geleden opgehouden een template te zijn. Het was in feite een tweede CMS, vastgelast op het eerste.
Dat is de situatie waar dit plan voor is. Je hebt een custom Joomla-template die schoon begon en die nu de helft van het werk van het framework doet. De site werkt. De klant vindt het ontwerp mooi. Je kunt de template niet aanraken zonder drie pagina's verderop iets te slopen, en je kunt nergens naartoe migreren (niet naar Joomla 5, niet naar een headless backend, zelfs niet naar een fatsoenlijke lokale dev-loop) totdat die koppeling eruit is.
We hebben deze Joomla-template-ontkoppeling in de afgelopen achttien maanden drie keer gedaan op verouderde sites. De vorm is steeds dezelfde: inventariseren, de database-aanroepen afvoeren, hardcoded modules omzetten naar posities, de render isoleren. Hieronder de gefaseerde versie, mét de faalmodi die ons de eerste keer uren hebben gekost, zodat ze jou geen tijd kosten.
Wat er in twaalf jaar in een Joomla-template versmelt
Eerst de diagnose, dan het werk. Een custom Joomla-template hoort een dunne schil te zijn: een doctype, een <head>, een handvol <jdoc:include>-positietags, een footer. Al het andere is aangekoekte koppeling. In de build van het bureau die we openden vonden we:
- Directe
JFactory::getDbo()-aanroepen inindex.php, die featured artikelen ophaalden op category-ID. - Hardcoded module-ID's:
echo JModuleHelper::renderModule(JModuleHelper::getModuleById('36'));, negen keer herhaald door de template heen. - Een inline
<script>-blok dat elke dertig seconden een Joomla session-URL aanriep om een sidebar-widget te verversen. - Een
/html/com_content/article/default.php-override die zelf door de category-tree liep om gerelateerde artikelen op te halen in plaats van JComponentHelper te gebruiken. - Plugin-output ingebakken in de layout omdat iemand in 2017 tien minuten voor een persdeadline nog snel een kaartwidget nodig had.
Niets hiervan is bijzonder. Dit is wat er gebeurt als een template lang genoeg leeft om drie developers te overleven. Het punt van ontkoppelen is niet om de vorige build de maat te nemen. Het is om de volgende persoon een oppervlak te geven waar zonder bibberen aan gewerkt kan worden.
Inventariseer voordat je één regel aanraakt
Grep eerst door de hele template-tree. Drie passes, drie categorieën koppeling.
grep -rn "JFactory\|getDbo\|setQuery\|loadResult\|loadObjectList" templates/agency_custom_2014/
grep -rn "renderModule\|loadModule\|loadposition\|getModuleById" templates/agency_custom_2014/
grep -rn "JPluginHelper\|JComponentHelper\|JModelLegacy" templates/agency_custom_2014/
Pipe de output naar een spreadsheet. Per rij: bestand, regel, type koppeling, vervangingsstrategie, fase. Saai werk. Doe het toch. Op elke site waar we deze stap oversloegen kostte het ons later dubbel zoveel tijd, omdat we op het laatste moment toch nog overrides in html/mod_random_image/ tegenkwamen waarvan niemand zich het bestaan herinnerde.
Terwijl je toch bezig bent: doe ook even een telling tegen de database. Een gekoppelde template hangt meestal aan specifieke row-ID's die iemand allang vergeten is.
SELECT id, title, position, published
FROM `#__modules`
WHERE id IN (36, 41, 47, 58, 62, 71)
ORDER BY position, ordering;
Als een van die ID's nul rijen oplevert, rendert de template in productie al jaren geruisloos niets. Dat is geen ramp. Schrijf het op. In fase drie zet je het om in een echte positie-toewijzing.
Fase een: asset-extractie
Trek /css, /js en /images uit de template-directory en zet ze in een build pipeline. Wij gebruiken Vite hiervoor, maar de tool maakt niet uit. Het gaat niet om modernisering. Het gaat erom dat assets niet langer verstrengeld zijn met PHP. Zodra ze een build-artifact zijn met gehashte bestandsnamen, verwijst index.php alleen nog naar het manifest en kan de volgende fase vrij bewegen.
Eén valkuil: inline <style>-blokken in index.php die naar Joomla template-parameters verwijzen. Een regel als body { background: <?php echo $this->params->get('bgcolor'); ?> } kan niet zomaar naar een statisch bestand verhuizen zonder dat de parameter verdwijnt. De oplossing: schrijf vanuit PHP één :root-blok dat die waarden als CSS custom properties blootstelt en laat de rest van de styling in de build-output zitten.
<style>:root {
--bg: <?php echo htmlspecialchars($this->params->get('bgcolor', '#ffffff'), ENT_QUOTES); ?>;
--accent: <?php echo htmlspecialchars($this->params->get('accent', '#d97706'), ENT_QUOTES); ?>;
}</style>
Fase twee: de data-grens
Iedere JFactory::getDbo()-aanroep wordt achter een expliciete fetcher gezet. We maken /templates/agency_custom_2014/lib/Data.php aan met benoemde methodes, één per datavorm die de template daadwerkelijk nodig heeft:
<?php
namespace Agency\Template;
use Joomla\CMS\Factory;
class Data
{
public static function featuredArticles(int $limit = 3): array
{
$db = Factory::getDbo();
$q = $db->getQuery(true)
->select($db->quoteName(['id','title','introtext','catid','created']))
->from($db->quoteName('#__content'))
->where('state = 1 AND featured = 1')
->order('created DESC')
->setLimit($limit);
$db->setQuery($q);
return $db->loadObjectList() ?: [];
}
}
Vervolgens doet index.php simpelweg $featured = \Agency\Template\Data::featuredArticles(3); en rendert de loop met het resultaat. De template heeft nu één plek waar het data-interface leeft. Als deze site morgen van Joomla af gaat, vervang je de body van de methode en blijft de markup staan. Vandaag heb je nog niets gesloopt.
Het niet-voor-de-hand-liggende stukje: de oude code lekte vrijwel zeker state via $app of $document binnen wat eruit zag als data-fetches. Audit op Factory::getDocument()->addScript() en vergelijkbare aanroepen binnen loops. Dat zijn globale side effects die zich voordoen als reads. Verplaats ze naar een expliciete render-prep-stap bovenin index.php, nadat de datalaag terugkomt en voordat de eerste byte HTML wordt uitgestuurd.
Fase drie: modules en plugins loswrikken
Hardcoded module-ID's zijn de op een na grootste pijnbron. <jdoc:include type="modules" name="position-7" /> is prima. JModuleHelper::renderModule(JModuleHelper::getModuleById('36')) niet, want de template hangt nu aan een row-ID in #__modules die niemand kan wijzigen zonder de codebase door te greppen.
Zet iedere aanroep om naar een benoemde template-positie. Wijs in de backend de bestaande modules per pagina aan die posities toe. De visuele output is identiek; de koppeling verhuist van PHP naar de configuratielaag, en daar hoort hij thuis.
Voor ingebedde plugins houden we deze regel aan: rendert de plugin content (een kaartwidget, een contactformulier), dan wordt het een module. Transformeert de plugin content (een content-plugin die artikel-HTML herschrijft), laat hem dan staan, maar documenteer hem. Die twee binnen de template door elkaar mixen is hoe je hier bent gekomen.
// Voor, in index.php
$plugin = JPluginHelper::getPlugin('content','agency_map');
$params = new JRegistry($plugin->params);
echo MapRenderer::render($params->get('coords'), $params->get('zoom'));
// Na, in index.php
<jdoc:include type="modules" name="map-position" />
Fase vier: render-isolatie
Zodra de datalaag één bron heeft en modules positie-gedreven zijn, hoort index.php er bijna saai uit te zien. Een doctype, een <head>, benoemde posities, een footer. Wat daar niet in past gaat in een partial onder /templates/agency_custom_2014/partials/ en wordt opgehaald met een platte include __DIR__.'/partials/header.php';. Geen framework-aanroepen binnen de partials. Geef ze als argumenten mee wat ze nodig hebben.
Waarom dit ertoe doet: op dit punt kun je de template optillen en in een uitgeklede renderer zetten. We schrijven een klein CLI-scriptje dat Factory mockt, de datalaag aanroept met fixture-data en de output van index.php naar disk schrijft. Als de resulterende HTML over een steekproef van vijftig URL's schoon diff't tegen de live site, is de extractie echt. Zo niet, dan vertelt de diff je precies welk koppelpunt je gemist hebt.
De pariteit verifiëren
Twee checks voordat je het werk afgerond noemt.
HTML-diff. Render vijftig URL's op staging (oude template, volledige Joomla) en op de geëxtraheerde versie. We pipen beide door een standards-compliant parser en sturen canonieke output uit, en diffen die. Strip eerst session-afhankelijke attributen. De diff hoort leeg te zijn of triviaal verklaarbaar.
Asset-graaf. Open Chrome DevTools Coverage op vijf representatieve pagina's. De set ingeladen CSS- en JS-bestanden, plus hun byte-aantallen binnen tien procent, hoort tussen oud en nieuw te matchen. Als er een script op de nieuwe build laadt maar niet op de oude, heb je een regressie geïntroduceerd. Laadt er een op de oude maar niet op de nieuwe, dan heb je mogelijk een side effect verwijderd waar iemand op leunt. Zoek het uit voordat je live gaat.
Wat de ontkoppeling je oplevert
Een Joomla-template die deze cyclus heeft doorlopen kan zonder herschrijfwerk naar Joomla 5 worden gehesen, in een weekend naar Twig-partials worden geport, dienen als referentie-render terwijl je de datalaag naar een nieuwe backend migreert, of eindelijk per bestand met vertrouwen worden geversioneerd (omdat elk bestand nu één taak heeft). Het stopt ook met het bestand te zijn waar elke developer op vrijdagmiddag bang voor is.
Toen we Pier bouwden liepen we bij dit soort werk steeds vast op de inventarisatiefase: inloggen op FTP, templates greppen, module-ID's kruisrefereren in MySQL, eindeloos vensters wisselen. We hebben het uiteindelijk zo opgelost dat de FTP-browser en een MySQL-editor in één venster samenkomen, zodat de template-tree en de #__modules-tabel naast elkaar staan en de grep één toetsaanslag is. Elke wijziging belandt in de version history, wat de pariteits-diff hierboven merkbaar minder stressvol maakte.
Heb je een gekoppelde Joomla-template waar je niet aan durft te komen: trek vandaag twintig minuten uit om de drie grep-commando's hierboven los te laten op je /templates/-directory en de SQL-query op #__modules. De spreadsheet die eruit rolt ís het plan. Alles daarna is monnikenwerk.
— Vragen —
Hoe lang duurt een volledige Joomla-template-ontkoppeling?
Voor een matig verstrengelde template van twaalf jaar oud reken je op twee weken gefocust werk: een dag voor de inventarisatie, drie tot vier dagen per fase, en twee dagen voor pariteitstests over een representatieve set URL's.
Moeten we Joomla upgraden vóór of na de ontkoppeling?
Erna. Ontkoppel op de bestaande Joomla-versie, zodat de live site je referentie-render is. Eerst upgraden verandert te veel variabelen tegelijk en je verliest je pariteits-baseline.
En de overrides in /html dan?
Behandel ze net als index.php. Inventariseer elke override, duw het ophalen van data achter de ene Data-laag, en laat de markup met rust totdat de pariteit met de live site bevestigd is.