A Module is the one building block an author places on a page. Everything else —
Components, Layouts, Templates — exists to serve Modules or to frame them. A Module is
registered in config/modules.php, backed by an ACF field group, rendered by a view,
and (when it has behaviour) driven by a colocated asset. It owns a region of the page.
The defining axis is authorial placeability + ACF registration — not complexity and not whether it ships JS. A Module with no JavaScript is still a Module.
Why Modules are the unit of authoring. The editor composes a page by stacking Modules; the theme composes a Module out of Components. Keeping "what an author places" as one clearly-named category is what lets the whole loading and lifecycle story stay presence-gated: one selector per Module, discovered on the page, loaded only if present.
Every Module travels as up to four facets, colocated by the shared taxonomy:
| Facet | Where | Job |
|---|---|---|
| PHP class | app/Modules/Intro.php |
Declares the ACF fields and renders the view |
| View | resources/public/views/modules/intro.php |
Emits markup + Field styles + data-* |
| Asset | resources/public/assets/modules/Intro/Intro.js + .scss |
Behaviour and style |
| Registration | config/modules.php + a loader entry |
Makes it selectable and loadable |
We'll author Intro end to end.
1. The PHP class
A Module class extends BaseModule and lives under App\Modules. It declares a
name and registers its fields; BaseModule handles the rest.
namespace LeaseLeads\Theme\App\Modules;
use LeaseLeads\Theme\App\Fields\Background;
use LeaseLeads\Theme\App\Fields\Border;
use LeaseLeads\Theme\App\Fields\ContentBuilder;
use LeaseLeads\Theme\App\Services\Fields\Presets;
use StoutLogic\AcfBuilder\FieldsBuilder;
use StoutLogic\AcfBuilder\GroupBuilder;
class Intro extends BaseModule
{
public function name(): string
{
return 'Intro';
}
public function content(GroupBuilder $builder, FieldsBuilder $fieldsBuilder): void
{
$staticContent = Presets::get('content', ContentBuilder::class, [
ContentBuilder::TEXT,
ContentBuilder::BUTTONS,
]);
$builder
->addRepeater('cards')
->addText('name')
// …static + hover groups…
->endRepeater();
}
public function options(GroupBuilder $builder, FieldsBuilder $fieldsBuilder): void
{
$builder->addFields([
Presets::get('border', Border::class)->setWidth(50),
Presets::get('background', Background::class)->setWidth(50),
]);
}
}
You override two hooks — content() and options() — and never touch the group
scaffolding. BaseModule::fields() wraps your callbacks in the standard three-tab
structure:
// app/Modules/BaseModule.php
public function fields(): FieldsBuilder
{
$builder = new FieldsBuilder(sanitize_key($this->name()));
$content = $builder->addTab('Content')->addGroup('content');
$this->content($content, $builder);
$content->endGroup();
$options = $builder->addTab('Options')->addGroup('options');
$this->options($options, $builder);
$options->endGroup();
$advanced = $builder->addTab('Advanced')->addGroup('advanced');
$this->advanced($advanced, $builder);
$advanced->endGroup();
return $builder;
}
Three tabs, always: Content (what the author writes), Options (how it looks),
Advanced (CSS id/class + spacing, provided by the base advanced() and rarely
overridden). Your fields nest under content and options groups, which is exactly how
the view reads them back.
Compose fields from Presets, don't hand-roll them. Presets::get('background', Background::class) returns a preconfigured Field
group. A Field owns both the editor UI and the CSS custom properties it later emits,
so reusing presets is what keeps the editor consistent and the styling contract intact.
Rendering
BaseModule::render() resolves the view by the sanitized name and hands it the raw
sub-field data:
public function render(): void
{
View::module(sanitize_title($this->name()), $this->rawData());
}
public function rawData(): array
{
return [
'module' => $this,
'content' => get_sub_field('content'),
'options' => get_sub_field('options'),
'advanced' => get_sub_field('advanced'),
'instance_id' => get_sub_field('instance_id'),
];
}
View::module('intro', …) is a thin wrapper over get_template_part with output
buffering — it resolves to resources/public/views/modules/intro.php. The name is the
single source of truth: it keys the ACF group, the view file, and the loader selector.
2. The view
The view turns raw field data into markup. Three things happen here, in order: fill defaults, emit the Field styles, render sub-Components.
// resources/public/views/modules/intro.php
[
'module' => $module,
'content' => $content,
'options' => $options,
'advanced' => $advanced,
] = $args;
$content = Settings::parseArgs($content, ['cards' => []]);
$options = Settings::parseArgs($options, [
'background' => Background::defaults(),
'border' => Border::defaults(),
]);
$splide = json_encode(['perPage' => 3, 'focus' => 'center', /* … */]);
?>
<section
id="<?= esc_attr($advanced['advanced']['css_id']); ?>"
class="module intro animate <?= Background::backgroundClasses($options['background']); ?>"
style="
<?= Background::backgroundVars($options['background']); ?>
<?= Border::cssVars($options['border']); ?>
">
<div class="intro__items splide-gallery" data-splide-gallery="<?= esc_attr($splide); ?>">
<?php foreach ($content['cards'] as $card) : ?>
<div class="intro__card">
<?php View::component('border', $options['border']); ?>
<?php View::component('content-stack', ['content' => $card['static']['content']]); ?>
</div>
<?php endforeach; ?>
</div>
</section>
Read the markup as three seams to the frontend:
- State-carrying classes.
.introis the JS selector;.animateopts the section into the scroll-fade contract; the Field helpers (Background::backgroundClasses,Border::cssVars) drop the editor's style choices in as utility classes and CSS custom properties. The SCSS reads those variables — no PHP ever sets a pixel. data-*options.data-splide-gallerycarries the carousel config as JSON. The instance parses it intooptionson construction. Seedata-*options.View::component()calls. Everything reusable inside the Module —border,content-stack,navigation— is a Component, never inlined. The Module composes; it doesn't reimplement.
The class on the outer element is the loader's selector. The view writes
class="… intro …" and the asset declares static selector = '.intro'. If they
drift, the Module renders but never boots. Treat that class as a contract.
3. The asset
A behavioural Module extends Instantiable, lists
its capabilities as Traits, and exports the flattened
class under its registered name.
// resources/public/assets/modules/Intro/Intro.js
import "./Intro.scss"
import { Instantiable } from '../../utilities/Instantiable/Instantiable.js'
import { Traits } from '../../utilities/Traits/Traits.js'
import { AutoInitializes } from '../../utilities/Traits/AutoInitializes.js'
import { FadesIn } from '../../utilities/Traits/FadesIn.js'
import {
HasBackground, HasSplideGallery, HasContentStack,
HasNavigation, HasButtons, AnimatesOnScroll,
} from '../../utilities/Traits/AutoInitializableComponent.js'
class Intro extends Instantiable {
static selector = '.intro'
async init() {
await this.carousel.init()
await this.autoInitializeComponents()
await super.init()
}
}
const Final = new Traits(Intro, [
AutoInitializes,
FadesIn,
HasBackground,
HasSplideGallery,
HasContentStack,
HasNavigation,
HasButtons,
AnimatesOnScroll,
])
export { Final as Intro }
Three things earn their place here:
import "./Intro.scss"— the only way the Module's styles reach the bundle. Vite code-splits the import, so the SCSS ships only when the Intro chunk loads.- The
HasXlist. EachHasXtrait teaches the Module to discover and lazy-load one Component type in its subtree —HasContentStackfor the content stacks,HasSplideGalleryfor the carousel, and so on. A Module that renders a Component but forgets itsHasXwill leave that Component dead. See Sub-components &HasX. export { Final as Intro }. The export alias must equal the registered name — the loader resolves the class viaexports['Intro']. A mismatch throws at load.
A Module is the sole controller of its subtree. It mixes AutoInitializes and
self-inits off its own booted event; its Components never do. That's why init()
can order work by hand — await this.carousel.init() before
autoInitializeComponents() — and trust that nothing inside jumped ahead.
4. Registration
Two registrations make a Module real.
Backend — add the class to config/modules.php so ACF builds its field group and
the editor offers it:
// config/modules.php
'modules' => [
// …
Intro::class,
],
The same file decides where Modules render on the default template — 'the_content' => 'append' appends the module loop after the post content, gated to page +
page_template == 'default'.
Frontend — add a loader entry so the asset downloads when its selector is on the page. Most Modules register in Deferred (loaded on first interaction):
// resources/public/assets/global/Deferred/Deferred.js
processor.convert({
name: 'Intro',
selector: '.intro',
loader: () => import('../../modules/Intro/Intro.js'),
})
name, selector, and the export { … as Intro } alias must agree. The processor only
runs the loader if .intro matches an element — nothing ships for a page without an
Intro.
Crucial vs Deferred is a heuristic, not a category. A few Modules (Masthead, PageHeader, Message) register in Crucial instead, because they're almost always the first Module on the page and are treated as above-the-fold. They are ordinary Modules in the editor — only their loader entry differs. See Two-phase loading.
When it's not a Module
- Rendered inside another Module, never placed by an author → it's a Component.
- Emitted by the page shell and configured only in global settings → it's a Layout.
- Decides which Modules a page can hold → it's a Template.
Related
- Components — the reusable pieces a Module renders.
- Traits — how a Module declares its capabilities as a flat list.
- Sub-components &
HasX— how a Module discovers what's inside it. - Fields → CSS custom properties — how editor style choices reach the view.
- Two-phase loading — Crucial, Deferred, and the loader entry.