Theme Framework How the LeaseLeads theme frontend works

Modules

The author-placeable content block — its ACF field group, its view, and its behaviour, end to end.

On this page 7

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 registrationnot complexity and not whether it ships JS. A Module with no JavaScript is still a Module.

Note

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.

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

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

Note

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:

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

php
// 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. .intro is the JS selector; .animate opts 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-gallery carries the carousel config as JSON. The instance parses it into options on construction. See data-* 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.
Important

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.

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

  1. 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.
  2. The HasX list. Each HasX trait teaches the Module to discover and lazy-load one Component type in its subtree — HasContentStack for the content stacks, HasSplideGallery for the carousel, and so on. A Module that renders a Component but forgets its HasX will leave that Component dead. See Sub-components & HasX.
  3. export { Final as Intro }. The export alias must equal the registered name — the loader resolves the class via exports['Intro']. A mismatch throws at load.
Note

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:

php
// 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):

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

Note

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.