A technique from “A Day of Code” - turning a plain data list into a designed component. If you’ve ever built a Joomla site for a conference, a class, or any event with a roster of presenters, you’ve probably reached for a Subform custom field. It’s the natural tool for the job: a repeatable little group of fields - name, position, headshot, bio - that content editors can duplicate as many times as they need, one entry per speaker.
The problem is what Joomla gives you out of the box. Left untouched, a subform field renders every speaker as a single line in a bulleted list:
• Laura Gordon, Joomla Volunteer, [bio text runs on forever]
• Olivier Buisard, Joomla Volunteer, [bio text runs on forever]
• Robin Clapp, Joomla Volunteer, [bio text runs on forever]
Functional, but nobody wants that on a speakers page. The fix isn’t a plugin or a page builder - it’s a template override: two small PHP files that intercept how Joomla renders the subform and hand it a proper layout instead.
The Building Blocks
Before overriding anything, the subform needs somewhere to pull data from. In this project, a Speakers field group holds four subfields, each with a specific machine name that the override code will later reference directly:
| Field | Type | Machine Name |
|---|---|---|
| Name | Text | speaker-name |
| Position | Text | speaker-position |
| Image | Media | speaker-image |
| Biography | Editor | Speaker-bio |
These four are bundled into a repeatable Subform field named Speakers, attached to a Speakers article category so editors can add as many presenters as the event needs - Laura, Olivier, Robin, or a hundred more - just by clicking a green “+” button.
That’s the raw material. The override is what turns it into something worth putting on a page.
Step 1: Cloning the Override Files
Joomla won’t let you edit its core layout files directly (nor should you want to), so the first move is to generate an override copy through the template manager:
- In the Joomla Admin, go to System → Site Templates → Cassiopeia → Details and Files.
- Open the Create Overrides tab.
- Under Plugins, select the fields folder, then subform. Joomla drops a copy at /templates/cassiopeia/html/plg_fields_subform/subform.php.
- Back on Create Overrides, this time under Layouts, select com_fields → field. This generates /templates/cassiopeia/html/layouts/com_fields/field/render.php.
Here’s the part that matters: editing subform.php directly would change every subform on the entire site - not what you want if you also have, say, a Sessions subform that needs its own look. So instead:
- Create a new file, speakers.php, in /templates/cassiopeia/html/layouts/com_fields/field/.
- Copy the contents of the default subform.php into it.
- Delete the original subform.php - leaving it in place is a trap for future you.
- Create a second new file in the same folder: speakers_speaker.php.
Once both files exist, go back to the Speakers custom field, open its Options tab, and check the Layout dropdown. If everything’s wired correctly, speakers now shows up as a selectable layout alongside the default. Select it and save. Nothing will look different yet - the file is still just a copy - but the plumbing is now in place.
Step 2: The Chef and the Plate
Think of the two files as a restaurant kitchen:
- speakers.php is the chef - it gathers the raw ingredients (the subform data) and decides what goes on the plate.
- speakers_speaker.php is the plate itself - it takes what the chef hands over and presents it.
speakers.php doesn’t need to know anything about names or bios generically; its job is to pull out four specific fields - speaker-image, speaker-name, speaker-position, speaker-bio - for each row, and pass them along.
First, update the imports at the top of the file:
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Layout\LayoutHelper;
use Joomla\Component\Fields\Administrator\Helper\FieldsHelper;
The default file only imports FieldsHelper. Adding Text and LayoutHelper unlocks the ability to display a translated field label and hand off rendering to a separate layout file.
Next, set up the field variables:
if (!array_key_exists('field', $displayData)) {
return;
}
$field = $displayData['field'];
$label = Text::_($field->label);
$showLabel = $field->params->get('showlabel');
$suffix = trim($field->params->get('suffix', ''));
$labelClass = $field->params->get('label_render_class');
$valueClass = $field->params->get('value_render_class');
$value = $field->value;
if ($value == '') {
return;
}
This gives the layout access to the field’s label, whether that label should even be shown, any admin-defined CSS classes, and support for a layout “suffix” - a hook for alternate speaker layouts down the road.
Then comes the real surgery. The default subform layout loops over every row and, inside that, loops over every subfield, mashing them into a comma-separated <li>. That whole generic block gets removed and replaced with something that treats each row as a structured record rather than a string of text:
$result = '';
foreach ($field->subform_rows as $subform_row) {
// Extract subfields by name
$fields = [];
foreach ($subform_row as $subfield) {
$fields[$subfield->fieldname] = trim(
FieldsHelper::render(
'com_content_article',
'field.' . trim($subfield->params->get('layout', 'render')),
[
'field' => $subfield,
]
)
);
}
// Skip row if all fields are empty
if (empty(array_filter($fields))) {
continue;
}
// Extract speaker data by field name
$image = $fields['speaker-image'] ?? '';
$name = $fields['speaker-name'] ?? '';
$position = $fields['speaker-position'] ?? '';
$bio = $fields['speaker-bio'] ?? '';
// Render the layout by calling the speakers_speaker.php file
$result .= LayoutHelper::render(
'com_fields.field.speakers_speaker' . $suffix,
[
'image' => $image,
'name' => $name,
'position' => $position,
'bio' => $bio,
]
);
}
?>
<?php if (trim($result) != '') : ?>
<?php if ($showLabel == 1) : ?>
<h2 class="field-label my-3 <?php echo $labelClass; ?>">
<?php echo htmlentities($label, ENT_QUOTES | ENT_IGNORE, 'UTF-8'); ?>
</h2>
<?php endif; ?>
<div class="speakers-container <?php echo $valueClass; ?>">
<?php echo $result; ?>
</div>
<?php endif; ?>
Two design decisions are worth calling out here. First, rows with no data get silently skipped - no blank speaker cards cluttering the page. Second, speakers.php never builds HTML for an individual speaker itself; it hands $image, $name, $position, and $bio off to speakers_speaker.php via LayoutHelper::render(). That separation - data wrangling in one file, presentation in another - is what makes the whole thing maintainable. Want a different card design later? Only the second file changes.
Step 3: Plating the Speaker Card
speakers_speaker.php receives exactly four values through $displayData and turns them into a single, styled card:
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
/** @var Joomla\CMS\WebAsset\WebAssetManager $wa */
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->addInlineStyle('
.speaker-image img {
width: 400px;
height: auto;
border-radius: 4px;
}
.speaker-divider:last-child {
display: none;
}
');
$image = $displayData['image'] ?? '';
$name = $displayData['name'] ?? '';
$position = $displayData['position'] ?? '';
$bio = $displayData['bio'] ?? '';
?>
<div class="speaker-row d-flex flex-wrap">
<div class="speaker-layout d-flex gap-4 align-items-start mb-3">
<?php if ($image) : ?>
<figure class="speaker-image m-0">
<?php echo $image; ?>
<?php if ($name) : ?>
<figcaption class="speaker-image-caption text-center small mt-1"><?php echo $name ?></figcaption>
<?php endif; ?>
</figure>
<?php endif; ?>
<div class="speaker-details d-flex flex-column gap-1">
<?php if ($name) : ?>
<div class="speaker-name fw-bold fs-5"><?php echo $name ?></div>
<?php endif; ?>
<?php if ($position) : ?>
<div class="speaker-position fst-italic text-secondary mb-2"><?php echo $position ?></div>
<?php endif; ?>
<?php if ($bio) : ?>
<div class="speaker-bio"><?php echo $bio ?></div>
<?php endif; ?>
</div>
</div>
</div>
<hr class="speaker-divider">
Notice this file does its own light styling work too - an inline stylesheet controls the headshot size and hides the trailing divider after the last card, so the list doesn’t end with an orphaned horizontal rule.
The Payoff
Before the override, a subform field could only ever produce this:
<ul>
<li>Name1, Position1, Bio1</li>
<li>Name2, Position2, Bio2</li>
<li>Name3, Position3, Bio3</li>
</ul>
After it, the same content - entered by an editor through the exact same “+” button in the article - renders as:
<div class="speakers-container">
[Speaker Card 1: headshot, name, title, bio]
[Speaker Card 2: headshot, name, title, bio]
[Speaker Card 3: headshot, name, title, bio]
</div>
Nothing about the editorial workflow changed. What changed is that Joomla now has a selectable layout option - speakers sits right alongside default in that dropdown - meaning the override is opt-in, reusable, and safely isolated to this one field. Every other subform on the site keeps behaving exactly as it did before.
That’s the real lesson underneath the code: a Joomla override doesn’t have to be an all-or-nothing rewrite of core behavior. Scoped correctly - one named layout file, one clearly defined job - it’s a surgical way to make structured content look like it was designed, not just entered into a form.

Comments