A Module is a top-level, author-placeable content
block: a PHP App\Modules\* class, an ACF field group, a colocated view, and a
colocated asset. This walkthrough builds a small Notice module — a dismissible
banner — from nothing, following the same shape as the reference Intro module.
Every step below is one of the module's five moving parts. Miss the registration (step 2) and the field group never appears; miss the loader entry (step 6) and the markup renders but the behaviour never boots.
1. Create the module class
A module class extends BaseModule and declares its name plus its ACF tabs. content()
and options() build the editor fields; advanced() (spacing + advanced settings) is
inherited. Field groups are assembled with the Presets service, which returns a
configured App\Fields\* builder.
<?php
// app/Modules/Notice.php
namespace LeaseLeads\Theme\App\Modules;
use LeaseLeads\Theme\App\Fields\Background;
use LeaseLeads\Theme\App\Fields\ContentBuilder;
use LeaseLeads\Theme\App\Services\Fields\Presets;
use StoutLogic\AcfBuilder\FieldsBuilder;
use StoutLogic\AcfBuilder\GroupBuilder;
/**
* Renders the Notice module: a dismissible banner with editor-authored content.
*/
class Notice extends BaseModule
{
public function name(): string
{
return 'Notice';
}
public function content(GroupBuilder $builder, FieldsBuilder $fieldsBuilder): void
{
$builder->addFields([
Presets::get('content', ContentBuilder::class, [
ContentBuilder::TEXT,
ContentBuilder::BUTTONS,
]),
]);
}
public function options(GroupBuilder $builder, FieldsBuilder $fieldsBuilder): void
{
$builder->addFields([
Presets::get('background', Background::class),
]);
}
}
The name() return value is the module's identity everywhere else: its ACF group key
(sanitize_key('Notice') → notice) and its view filename (step 4).
2. Register it in config/modules.php
The module does nothing until it is listed. Add the use and drop the class into the
modules array — the register order is the order it appears in the editor's block
picker.
use LeaseLeads\Theme\App\Modules\Notice;
return [
'modules' => [
// …
Notice::class,
],
// …
];
3. The ACF field group is now defined
There is no separate step — the field group is the content() / options() /
advanced() methods from step 1. BaseModule::fields() assembles them into the three
top-level tabs (Content, Options, Advanced) and ACF registers the group against the
locations declared in config/modules.php. The three Field presets you reached for —
ContentBuilder, Background, and the inherited Spacing — each emit their own
CSS custom properties and utility classes
onto the element, which the view prints and the SCSS consumes.
4. Author the view
The view lives at resources/public/views/modules/<name>.php — here notice.php, matching
name(). Destructure the $args the module passes, backfill defaults with
Settings::parseArgs, then render. Build up nested content with
View::component rather than inline markup, and print
each Field's classes and CSS variables onto the root element.
<?php
// resources/public/views/modules/notice.php
/** @var array $args */
use LeaseLeads\Theme\App\Fields\AdvancedSettings;
use LeaseLeads\Theme\App\Fields\Background;
use LeaseLeads\Theme\App\Fields\ContentBuilder;
use LeaseLeads\Theme\App\Fields\Spacing;
use LeaseLeads\Theme\App\Helpers\Settings;
use LeaseLeads\Theme\App\Helpers\View;
use LeaseLeads\Theme\App\Modules\Notice;
/**
* @var Notice $module
* @var array $content
* @var array $options
* @var array $advanced
*/
[
'module' => $module,
'content' => $content,
'options' => $options,
'advanced' => $advanced,
] = $args;
$content = Settings::parseArgs($content, [
'content' => ContentBuilder::defaults(),
]);
$options = Settings::parseArgs($options, [
'background' => Background::defaults(),
]);
$advanced = Settings::parseArgs($advanced, [
'advanced' => AdvancedSettings::defaults(),
'spacing' => Spacing::defaults(),
]);
?>
<section
id="<?= esc_attr($advanced['advanced']['css_id']); ?>"
class="module notice animate <?= Background::backgroundClasses($options['background']); ?> <?= Spacing::spacingClasses($advanced['spacing']); ?> <?= esc_attr($advanced['advanced']['css_class']); ?>"
style="<?= Background::backgroundVars($options['background']); ?>">
<div class="container">
<?php View::component('content-stack', ['content' => $content['content']]); ?>
<button class="notice__dismiss" type="button" aria-label="Dismiss">×</button>
</div>
</section>
The animate class opts the section into the scroll-reveal contract, and container
gives it the grid. Both are picked up automatically once the matching traits are on
the asset (step 5).
5. Colocate the asset
The behaviour and styles live beside nothing else, under
resources/public/assets/modules/Notice/. The JS is an
Instantiable with a static selector, an async
init(), and a Traits list stamping on the capabilities
it needs. It imports its own SCSS for the side-effect.
// resources/public/assets/modules/Notice/Notice.js
import "./Notice.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 {
AnimatesOnScroll,
HasBackground,
HasContentStack,
HasSpacing,
} from '../../utilities/Traits/AutoInitializableComponent.js'
/**
* Notice module: a dismissible banner with editor-authored content.
*/
class Notice extends Instantiable {
static selector = '.notice'
static dismissSelector = '.notice__dismiss'
async init() {
this.#bindEvents()
await this.autoInitializeComponents()
await super.init()
}
#bindEvents() {
this.element
.querySelector(Notice.dismissSelector)
?.addEventListener('click', () => this.element.classList.add('is-dismissed'))
}
}
const Final = new Traits(Notice, [
AutoInitializes,
FadesIn,
HasSpacing,
HasBackground,
HasContentStack,
AnimatesOnScroll,
])
export { Final as Notice }
Note the moving parts, all conventions the framework depends on:
AutoInitializesmakes the host self-init off its ownbootedevent — a host (unlike a sub-component) controls its own lifecycle.HasContentStack,HasBackground,HasSpacing,AnimatesOnScrollare theHasXtraits for the behavioural components this markup can contain.autoInitializeComponents()walks them. Omit aHasXand that component stays dead.#bindEventstoggles a state class (is-dismissed) — JS drives state, SCSS owns the look.export { Final as Notice }— the alias must equal the loadernamefrom step 6, becauseAssetLoaderresolves the class viaexports['Notice'].
The colocated Notice.scss keys off the rhythm system and the state class:
// resources/public/assets/modules/Notice/Notice.scss
@use 'lib/rhythm' as rhythm;
.notice {
@include rhythm.default(sm, sm, none, none);
@include rhythm.apply();
position: relative;
&.is-dismissed {
display: none;
}
}
6. Register the selector + loader
Nothing loads until a Global orchestrator knows the module exists. Add a convert(...)
entry to Deferred — assets/global/Deferred/Deferred.js
— so the chunk is fetched and instantiated on first interaction, but only if a .notice
is present on the page (presence-gated lazy loading).
// resources/public/assets/global/Deferred/Deferred.js
processor
// …
.convert({
name: 'Notice',
selector: '.notice',
loader: () => import('../../modules/Notice/Notice.js'),
})
The name here becomes the class's moniker (Globals.set('Notice', …)), so it must
match the export { Final as Notice } alias from step 5.
Register above-the-fold modules in Crucial (assets/global/Crucial/Crucial.js)
instead — it runs on the critical path. There, first-module-on-the-page candidates use
a style-only entry (name: null) to ship the CSS while withholding the instantiation
that would trigger reflow. Deferred is the right home for everything else, Notice
included.
Related
- Modules — the type in full: what belongs in the class vs the view.
- The lifecycle — how Crucial and Deferred boot what you registered.
- Traits — the mixin list and the
export { Final as X }rule. - Fields → CSS custom properties — how the field presets reach your SCSS.