Theme Framework How the LeaseLeads theme frontend works

Add a Module end to end

Create a placeable content block from nothing — PHP class, registration, field group, view, and colocated asset.

On this page 7

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.

Note

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

php
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
<?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">&times;</button>
  </div>

</section>
Tip

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.

js
// 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:

  • AutoInitializes makes the host self-init off its own booted event — a host (unlike a sub-component) controls its own lifecycle.
  • HasContentStack, HasBackground, HasSpacing, AnimatesOnScroll are the HasX traits for the behavioural components this markup can contain. autoInitializeComponents() walks them. Omit a HasX and that component stays dead.
  • #bindEvents toggles a state class (is-dismissed) — JS drives state, SCSS owns the look.
  • export { Final as Notice } — the alias must equal the loader name from step 6, because AssetLoader resolves the class via exports['Notice'].

The colocated Notice.scss keys off the rhythm system and the state class:

scss
// 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 Deferredassets/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).

js
// 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.

Important

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.