stave.dev

Submodules

Modules can nest infinitely. URL routing, assets, and frames cascade down the tree automatically.

What Are Submodules?

A submodule is a full module that lives inside another module's submodule/ directory. It has its own controller, views, assets, and models — but inherits its parent's frame and routing context. This lets you break large features into organized pieces without cluttering the top-level modules/ directory.

/ttrpg/characters/view/5
Ttrpg module
Characters submodule
view('5')

Directory Structure

Submodules live in a submodule/ directory inside their parent:

modules/Ttrpg/
Ttrpg.php
view/
assets/
submodule/
Characters/
Characters.php
view/
assets/
model/
Hexmap/
Hexmap.php
view/
assets/

Each submodule is a self-contained module with the same structure as a top-level module — controller, views, assets, and models are all supported.

Namespace Convention

A submodule's namespace is its parent module name, not its own. This is how the framework's autoloader resolves submodule classes:

<?php
namespace Zero\Module\Ttrpg;

class Characters extends \Zero\Core\Module {

    public function index() {
        $this->respond($this->viewPath . 'index.php');
    }

    public function view($id) {
        $this->data = ['character_id' => $id];
        $this->respond($this->viewPath . 'view.php');
    }
}
Namespace Must Be the Parent

The namespace for Characters is Zero\Module\Ttrpg, not Zero\Module\Characters. This is required for the autoloader to find the class when routing delegates to the submodule.

Compare this to the parent module:

<?php
namespace Zero\Module;

class Ttrpg extends \Zero\Core\Module {

    public function index() {
        $this->respond($this->viewPath . 'index.php');
    }

    // No characters() method needed - the framework
    // automatically delegates to the submodule
}

URL Routing Delegation

When a URL comes in, the framework first tries to call the matching method on the parent module. If the method doesn't exist, it checks for a matching submodule via Module::__call():

  1. URL /ttrpg/characters/view/5 arrives
  2. Framework loads Ttrpg module and calls characters('view', '5')
  3. Ttrpg has no characters() method — __call() fires
  4. Framework finds submodule/Characters/Characters.php
  5. Routing shifts: Characters::view('5') is called
URL Resolution
/ttrpg Ttrpg::index()
/ttrpg/characters Characters::index() (submodule)
/ttrpg/characters/view/5 Characters::view('5') (submodule)
/ttrpg/hexmap/dm Hexmap::dm() (submodule)
Method Precedence

If the parent module has a characters() method, it takes precedence over the submodule. The submodule is only checked when no matching method exists on the parent.

Asset Loading for Submodules

Submodule assets are fully supported by the asset system. They are symlinked under the parent module's asset directory:

modules/Ttrpg/submodule/Hexmap/assets/
/assets/ttrpg/hexmap/

Auto-loading follows the same convention, scoped to the submodule. For the URL /ttrpg/hexmap/dm, the asset loader checks /assets/ttrpg/hexmap/css/ for:

File Loaded Because
hexmap.css Matches the submodule name (loads for all Hexmap endpoints)
dm.css Matches the endpoint name (loads only for /ttrpg/hexmap/dm)

The same $this->getStylesheets() and $this->getScripts() calls work in submodule frames — the methods automatically resolve to the correct submodule asset path.

Non-Convention Assets in Submodules

If a submodule needs assets that don't follow the naming convention (like main.css or a vendor library), hardcode them in the submodule's frame/head.php alongside the $this->getStylesheets() call.

Frame Inheritance

Submodules inherit their parent's frame templates (head, header, footer, sideNav) by default. The framework walks up the directory tree looking for frame files:

  1. Check submodule: modules/Ttrpg/submodule/Characters/frame/
  2. Check parent module: modules/Ttrpg/frame/
  3. Fall back to app frame: app/frontend/frame/

This means a submodule gets the parent's header and navigation for free. Override any individual frame file by creating it in the submodule's own frame/ directory — only that file is overridden, the rest continue to inherit.

Infinite Nesting

Submodules can contain their own submodules, creating an arbitrarily deep hierarchy:

modules/Ttrpg/
submodule/
Characters/
submodule/
Inventory/
Inventory.php

The URL /ttrpg/characters/inventory/view/42 would resolve to Inventory::view('42') through two levels of delegation. Each level follows the same namespace and routing rules.

When to Use Submodules

Submodules work best when features are logically children of a parent (Characters belong to a TTRPG app). If two modules are peers that share nothing, keep them as top-level modules instead.