— Article — № 077

077 —Joomla

Decoupling a Joomla template: a staged extraction playbook

A 12-year-old custom Joomla template had grown into a second CMS welded onto the first. Here is the staged extraction that pulled it back apart.

Overhead still life on bone linen: inked Joomla template blueprint, tracing overlay, manila tab, brass plate, red wax seal.
Hero · staged still№ 077

A Dutch agency we work with sent us a Loom on a Sunday night. Twelve minutes of the lead developer scrolling through /templates/agency_custom_2014/index.php on a Joomla 3.10 install that was about to be lifted onto Joomla 5. Every other line was a $db->setQuery(), a hardcoded module ID, a plugin call wrapped in a try/catch that swallowed errors silently. The template had stopped being a template six years ago. It was, in effect, a second CMS welded onto the first.

That is the situation this playbook is for. You have a custom Joomla template that started clean and ended up doing half the framework's job. The site works. The client likes the design. You cannot touch the template without breaking something three pages away, and you cannot migrate anywhere (not to Joomla 5, not to a headless backend, not even to a saner local dev loop) until the coupling comes out.

We have run this Joomla template decoupling three times in the last eighteen months on legacy sites. The shape is always the same: inventory, drain the database calls, convert hardcoded modules to positions, isolate the render. What follows is the staged version, with the failure modes that cost us hours the first time around so they do not cost you any.

What gets fused into a Joomla template over twelve years

Before the work, the diagnosis. A custom Joomla template should be a thin shell: a doctype, a <head>, a handful of <jdoc:include> position tags, a footer. Anything else is accreted coupling. In the agency build we opened, we found:

  • Direct JFactory::getDbo() calls in index.php, pulling featured articles by category ID.
  • Hardcoded module IDs: echo JModuleHelper::renderModule(JModuleHelper::getModuleById('36')); repeated nine times across the template.
  • An inline <script> block that called a Joomla session URL to refresh a sidebar widget every thirty seconds.
  • A /html/com_content/article/default.php override that fetched sibling articles by walking the category tree itself rather than using JComponentHelper.
  • Plugin output baked into the layout because someone needed a map widget ten minutes before a press deadline in 2017.

None of this is unusual. It is what happens when a template lives long enough to outlast three developers. The point of decoupling is not to judge the previous build. It is to give the next person a surface they can work on without trembling.

Inventory before you touch a line

Grep across the template tree first. Three passes, three categories of coupling.

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 the output into a spreadsheet. Each row: file, line, type of coupling, replacement strategy, stage. This is dull. Do it anyway. Every site we skipped this step on cost us double the time later, because we kept finding ninth-hour surprises in html/mod_random_image/ overrides nobody remembered existed.

While you are at it, run a count against the database. A coupled template usually depends on specific row IDs that someone has forgotten about.

SELECT id, title, position, published
FROM `#__modules`
WHERE id IN (36, 41, 47, 58, 62, 71)
ORDER BY position, ordering;

If any of those IDs return zero rows, the template has been silently rendering nothing in production for years. That is fine. Write it down. You will turn it into a real position assignment in stage three.

Stage one: asset extraction

Pull /css, /js, and /images out of the template directory and into a build pipeline. We use Vite for this, but the tool does not matter. The point is not modernization. The point is that assets stop being entangled with PHP. Once they are a build artifact with hashed filenames, the template's index.php only references the manifest, and the next stage can move freely.

One trap: inline <style> blocks in index.php that reference Joomla template parameters. A line like body { background: <?php echo $this->params->get('bgcolor'); ?> } cannot move to a static asset without losing the parameter. The fix is to write a single :root block from PHP that exposes those values as CSS custom properties, and let the rest of the styling live in the build output.

<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>

Stage two: data boundary

Every JFactory::getDbo() call gets wrapped behind an explicit fetcher. We create /templates/agency_custom_2014/lib/Data.php with named methods, one per data shape the template actually needs:

<?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() ?: [];
    }
}

Then index.php does $featured = \Agency\Template\Data::featuredArticles(3); and renders the loop with the result. The template now has one place where the data interface lives. Tomorrow, when this site moves off Joomla, you replace the method body and the markup stays put. Today, you have not broken anything.

The non-obvious bit: the old code almost certainly leaked state via $app or $document inside what looked like data fetches. Audit for Factory::getDocument()->addScript() and similar calls inside loops. Those are global side effects pretending to be reads. Move them to an explicit render-prep step at the top of index.php, after the data layer returns and before the first byte of HTML is emitted.

Stage three: module and plugin decoupling

Hardcoded module IDs are the second-largest source of pain. <jdoc:include type="modules" name="position-7" /> is fine. JModuleHelper::renderModule(JModuleHelper::getModuleById('36')) is not, because the template now depends on a row ID in #__modules that nobody can change without grepping the codebase.

Convert each call to a named template position. In the backend, assign the existing modules to those positions per page. The visual output is identical; the coupling moves from PHP into the configuration layer, which is where it belongs.

For embedded plugins, the rule we follow: if the plugin renders content (a map widget, a contact form), it becomes a module. If it transforms content (a content plugin that rewrites article HTML), leave it alone, but document it. Mixing the two inside the template is how you got here.

// Before, in index.php
$plugin = JPluginHelper::getPlugin('content','agency_map');
$params = new JRegistry($plugin->params);
echo MapRenderer::render($params->get('coords'), $params->get('zoom'));

// After, in index.php
<jdoc:include type="modules" name="map-position" />

Stage four: render isolation

Once the data layer is single-source and modules are position-driven, index.php should look almost boring. A doctype, a <head>, named positions, a footer. Anything that does not fit goes into a partial under /templates/agency_custom_2014/partials/ and gets pulled in with a flat include __DIR__.'/partials/header.php';. No framework calls inside the partials. Pass them what they need as arguments.

The reason this matters: at this point you can stand the template up inside a stripped-down renderer. We write a tiny CLI script that mocks Factory, calls the data layer with fixture data, and writes the output of index.php to disk. If the resulting HTML diffs cleanly against the live site across a sample of fifty URLs, the extraction is real. If it does not, the diff tells you exactly which coupling point you missed.

Verifying the parity

Two checks before you call the work done.

HTML diff. Render fifty URLs on staging (old template, full Joomla) and on the extracted version. We pipe both through a standards-compliant parser and emit canonical output, then diff. Strip session-dependent attributes first. The diff should be empty or trivially explainable.

Asset graph. Open Chrome DevTools Coverage on five representative pages. The set of loaded CSS and JS files, plus their byte counts within ten percent, should match between old and new. If a script is loading on the new build but not the old one, you have introduced a regression. If one is loading on the old build but not the new, you may have removed a side effect somebody depends on. Find out before you ship.

What the decoupling buys you

A Joomla template that has been through this process can be lifted onto Joomla 5 without rewriting the template tree, ported to Twig partials in a weekend, used as a reference render while you migrate the data layer to a new backend, or finally versioned per file with confidence (because each file now has one job). It also stops being the file every developer is afraid to open on a Friday afternoon.

When we built Pier we kept hitting the inventory stage on this kind of work: logging into FTP, grepping templates, cross-referencing module IDs in MySQL, switching windows constantly. The way we ended up handling it was to fuse the FTP browse with a MySQL editor in one window, so the template tree and the #__modules table sit side by side and the grep is one keystroke. Every file change is captured in the version history, which made the parity-diff stage above noticeably less stressful.

If you have a coupled Joomla template you are afraid to touch, spend twenty minutes today running the three grep commands above against your /templates/ directory and the SQL query against #__modules. The spreadsheet that comes out is the entire plan. Everything after that is mechanical.

— Questions —

How long does a full Joomla template decoupling take?

For a moderately tangled twelve-year-old template, plan two weeks of focused work: a day on inventory, three to four days per stage, and two days on parity testing across a representative URL set.

Should we upgrade Joomla before or after decoupling?

After. Decouple on the existing Joomla version so the live site is your reference render. Upgrading first changes too many variables at once and you lose the parity baseline.

What about overrides in /html?

Treat them the same way as index.php. Inventory each override, push data fetching behind the single Data layer, leave the markup alone until parity is confirmed against the live site.

Is decoupling worth it if we are leaving Joomla anyway?

Yes. A decoupled template is portable to Twig, Blade, or a headless renderer. A coupled one forces a full rewrite, which doubles the migration cost and removes the side-by-side parity check.