The engine’s extension mechanism. Modules come from two sources, bundled inside the binary or installed as Composer packages, and both produce the same manifest and registrar metadata. Metadata is compiled ahead of time in production and reflected at boot in debug, behind one interface the registry consumes. Enabled modules pass through two lifecycle phases, register and boot, and the bindings they register are assembled into the container’s catalogues. Everything else a module contributes is pulled: a component asks every module for contributions at the moment it needs them, rather than modules pushing during boot.
Core features of the panel are modules, per ADR-0013, so the panel cannot run without a module system. It is not an optional extension point added once the engine works.
Several components are already written against it and cannot be finished without it. Events leaves reading listener attributes off a class to the module system, per RFC-0009. HTTP leaves modules contributing middleware or routes to it, per RFC-0010. Views leaves collecting template sources and slot definitions to it, per RFC-0011. The container leaves assembling its catalogues to it, per RFC-0001.
Modules also have to be discovered cheaply. The panel runs as a worker, per ADR-0009, so discovery happens once at boot and is paid back over every request the worker serves, while a developer editing a module needs their change visible without a rebuild.
| Term | Meaning |
|---|---|
| Module | A unit of functionality registering its own bindings and contributions, bundled or external. |
| Bundled module | A first-party module shipped inside the binary, with its manifest declared in code. |
| External module | A Composer package of type tgp-module, installed into the modules directory. |
| Ident | A module’s identifier, derived from its package name for an external module and declared for a bundled one. |
| Manifest | The resolved metadata describing one module. |
| Registrar | A module’s entry class, and the reflected metadata describing which of its methods do what. |
| Capability | Something a module declares it does, checked by whatever enforces it. |
| Source | Where manifests and registrar metadata come from, compiled or reflected. |
| Collector | What a component hands to modules to contribute to, for one type of contribution. |
| Collection | A component asking every enabled module to contribute, at a moment it chooses. |
| Panel context | The part of the panel something belongs to: an account, a server or the platform. |
| Component | Responsibility |
|---|---|
ModuleManifest |
Immutable metadata for one module. |
ModuleRegistrar |
Immutable reflected metadata about a module’s registrar class. |
ModuleManifestBuilder, ModuleRegistrarBuilder |
Build the two from a Composer package and by reflection. |
ModuleSource |
Contract providing every manifest and registrar, however they were produced. |
CachedModuleSource, LiveModuleSource |
The compiled and the reflecting implementations. |
ModuleRegistry |
Immutable. Holds the known modules, answers lookups, and drives collection. |
EngineBuilder |
Mutable. What a module registers bindings and resolvers through. |
Capability |
Enum of the capabilities a module may declare. |
Register, Boot, Collect, Unscoped |
Attributes marking what a registrar’s methods do. |
Manifest, Registrar |
Resolvable attributes injecting a module’s metadata. |
Collector, CollectorHandler |
The contracts a component implements to collect from modules. |
PanelContext |
Enum naming the part of the panel something belongs to. |
ModuleException |
Marker contract implemented by every exception the component throws. |
A module is bundled or external, and nothing downstream of discovery can tell which:
| Bundled | External | |
|---|---|---|
| Ships | Inside the binary, in the panel’s own codebase and autoloader | In the modules directory, with its own vendor/ |
| Metadata declared | In code | In extra.tgp in the package’s composer.json |
| Discovered from | The panel itself | The Composer lock file in the modules directory |
| Core flag | Set | Unset |
External modules are Composer packages installed with Composer driven as a library, per
ADR-0012, which also settles the separate
Composer project, the provide block generated from the panel’s own lock file, and extra.tgp as where metadata
Composer’s schema does not carry lives.
Both produce the same ModuleManifest and ModuleRegistrar. The registry holds one set and never asks where a
module came from. First-party features therefore ship as bundled modules and are ordinary modules in every other
respect, per ADR-0013.
ModuleManifest is immutable and holds one module’s resolved metadata: its ident, vendor, version, name,
description, declared capabilities, core flag, registrar class name, icon and definition. For an external module it
is built from the Composer package, with the standard Composer fields read from the package itself and the rest read
from extra.tgp. A bundled module constructs its manifest directly.
ModuleRegistrar is immutable and holds what reflection found on the registrar class: which method carries
#[Register], which carries #[Boot], and for each #[Collect] method the collector type it accepts, whether it
carries #[Unscoped], and whether it takes a PanelContext parameter. It holds metadata about the class, never an
instance of it.
Both are hydrated from an array, so both survive being written out and read back.
Each is injectable by ident through a resolvable attribute, per ADR-0002:
public function __construct(
#[Manifest('backups')] private ModuleManifest $manifest,
) {}
A module declares capabilities in its manifest, and Capability is the enum of those recognised. The record names
CrossRoutes, ExtendSchema, ModifyUi and DaemonAccess; the set grows with the components that enforce them.
A capability is checked at runtime by whatever enforces it, never by the module system. Declaring one is not being
granted it: routing decides what CrossRoutes permits, and the module system only carries the declaration.
Reflection is the cost this design manages. ModuleSource is the seam:
interface ModuleSource
{
public function manifests(): array;
public function registrars(): array;
}
| Implementation | Used in | Behaviour |
|---|---|---|
CachedModuleSource |
Production | Reads bundled metadata from a file built into the binary, and external metadata from a file written during discovery. Reflects nothing. |
LiveModuleSource |
Debug | Reflects every module at boot, bundled from the panel’s own codebase and external from the modules directory. |
Whatever boots the panel chooses the implementation and hands it to the registry, which does not know which it was given.
LiveModuleSource is what makes module development bearable: a change to module code is visible on the next
request, with no rebuild and no discovery run.
Module metadata written for CachedModuleSource goes to the compiled root, per RFC-0008,
as PHP the engine writes for PHP to include, so it is opcached rather than decoded on every cold boot. It is not
cached content and does not go through a filesystem.
Bundled registrar metadata is reflected during the build and written into the binary. No build tooling exists yet,
so nothing produces that file today, and LiveModuleSource is the only implementation a developer can run.
A module class may carry attributes from libraries the panel knows nothing about. Reflection therefore matches attributes by name and instantiates none of them, per ADR-0014, so a third-party attribute’s constructor never runs because a module was discovered.
ModuleRegistry is immutable, built from a ModuleSource when the panel boots.
| Lookup | Returns |
|---|---|
manifest(string $ident) |
One module’s manifest. |
registrar(string $ident) |
One module’s registrar metadata. |
manifests() |
Every manifest. |
idents() |
Every ident. |
isEnabled(), isDisabled(), has() |
Whether a module is enabled, disabled, or known at all. |
Which modules are enabled comes from ModulesEnabled, read through the configuration registry between its two
seals, per RFC-0004. The module system never scans a directory to work out
what is enabled, and never repeats the rule that a module configuration file’s presence is what enables it:
configuration is the single source of that.
A module present in the source but not in the enabled list is known and disabled. A module in the enabled list with no manifest is logged and skipped, because an installation whose configuration names a module that is no longer installed should still boot.
The registry’s own lifetime follows the mode that produced it, per RFC-0007:
| Mode | Lifetime | Effect |
|---|---|---|
| Production | Process |
Built once at worker boot from compiled metadata, and shared by every request. |
| Debug | Cycle |
Rebuilt for each cycle, so edited module code takes effect immediately. |
The class is the same in both. Only what constructs it differs.
Two phases, in order, across every enabled module:
#[Register] method is called with an
EngineBuilder, which is where it registers bindings and resolvers.#[Boot] method is called
with no arguments.Every module registers before any module boots, so a module’s boot may depend on another module having registered. A module with no method for a phase is skipped for it, without error.
Registrars are instantiated directly during register, because the container does not exist yet: building it is what the phase produces. By boot the container exists, and the registrars instantiated during register are the same objects.
EngineBuilder is the mutable half of the container’s registries, per
ADR-0003, presented as one surface to a
module:
| Method | Effect |
|---|---|
bind(string $abstract): BindingBuilder |
Registers a binding, returning the builder from RFC-0001. |
resolver(string $resolvable, string $resolver, bool $default = false) |
Registers a resolver against a resolvable attribute. |
A builder is scoped to a module for the duration of that module’s register call, so every binding registered through it records the ident that registered it. Recording the owner is this design’s part; what an owner means at resolution is module scopes.
After the register phase the builder is consumed, producing the BindingCatalogue and ResolverCatalogue the
container is constructed with, and is unavailable afterwards. This is the assembly
RFC-0001 leaves to the module lifecycle.
An alias may name another alias. Assembly flattens every chain, so each alias names the abstract that owns the binding directly and resolution stays a single hop.
It is done here because a catalogue is assembled once and never changes afterwards, while an alias is normalised before the instance cache is consulted on every resolution, per RFC-0006. Flattening at assembly pays for the walk once, at boot, rather than on every resolution for a structure that cannot change.
A chain returning to an alias already seen cannot be flattened, and fails the assembly, naming the aliases in the cycle. A cycle is therefore a boot failure that says what is wrong, rather than a resolution that exhausts the stack.
Everything a module contributes beyond bindings is pulled rather than pushed. A component asks for contributions when it wants them, which may be at boot, on first use, or never.
A component defines its own collector type and a handler that drives collection for it:
interface Collector {}
interface CollectorHandler
{
public function collects(): string;
public function create(ModuleManifest $manifest, ?PanelContext $context, bool $scoped): Collector;
public function process(Collector $collector, ModuleManifest $manifest, ?PanelContext $context, bool $scoped): void;
public function finalise(): void;
}
The registry drives the flow, and the component decides when it runs:
ModuleRegistry::collect(CollectorHandler $handler, ?PanelContext $context).#[Collect] method accepting that collector type, and skips the
module when there is none.create() produces a fresh collector for that module.process() receives the populated collector.finalise() runs once.A module receives a fresh collector each time, so nothing a module contributes can reach or overwrite what another contributed. The handler is the only thing that sees them all.
#[Collect]
public function routes(RouteCollector $routes): void
{
$routes->get('/backups', ListBackups::class);
}
The collector type is taken from the method’s type hint. A #[Collect] method may also declare a PanelContext
parameter, in which case it participates only when collection runs for that context.
By default a module’s #[Collect] method contributes within its own module’s boundary, and the $scoped flag
passed to the handler says so. #[Unscoped] marks a method as contributing outside it.
Whether that is permitted is the handler’s decision, never the module system’s. The handler receives both the flag
and the module’s manifest, so it has the declaration and the capabilities to decide with. Permissions refuse
unscoped contributions outright; routing may allow one from a module declaring CrossRoutes.
#[Unscoped] here means outside the module’s own contribution boundary. It is unrelated to the container’s module
scopes, which are a separate axis.
There is no cacheable collector abstraction. Different components cache differently: a route collector can cache a form that skips collection entirely on later requests, while another gains nothing from caching at all. A component caches around collection however suits it.
PanelContext names the part of the panel something belongs to: Account, Server or Platform. It is used to
scope collection, and routing uses it to decide a route’s URL prefix.
Server is a subcontext of Account for routing and a value in its own right for collection.
It is defined in the engine’s shared values rather than inside collection, alongside the client address value object. Collection is where it was first written down, but routing needs it too, which is the promotion RFC-0010 records as unsettled. Two consumers make it shared, so it is not owned by either.
Every exception implements ModuleException.
| Exception | Thrown when |
|---|---|
ModuleSourceException |
Metadata is missing or unreadable. Its message says what to do: run discovery, or check the installation. |
UnknownModuleException |
A manifest or registrar is asked for by an ident that is not known. |
An enabled module with no manifest is a warning rather than an exception, and the module is skipped.
The decisions this design rests on are recorded separately, with the alternatives each one rejected:
Collection as a third lifecycle phase, with modules pushing every contribution during boot. A component would then receive contributions whether or not it is ever used, and would have nowhere to put the decision of when to collect. Pulling lets a component collect on first use, or never.
A cacheable collector abstraction. Components cache collected data in different shapes, and some gain nothing from caching, so an abstraction would have to cover cases that do not resemble each other.
Following alias chains at runtime, walking from one alias to the next on each lookup. The walk would repeat on every resolution, in the path taken before the instance cache is consulted, for a structure fixed at boot. A cycle would also be found by exhausting the stack rather than when the catalogue was assembled.
No other alternatives were weighed.
Nothing breaks. No module exists, and the modules directory in the repository is empty.
The container is constructed with its catalogues today and gains the step that builds them, which is what RFC-0001 left to this design rather than a change to it.
ModuleSource seam with its compiled and reflecting
implementations, bootstrap selecting one, and the registry not knowing which it was given. A later edit on
2026-09-13 replaced the subissue names with issue links and changed nothing else.extra.tgp, the
manifest and registrar and their builders, hydration from an array, the capability enum with the four it names,
the four registrar attributes, the prebuilt bundled file, and the #[Manifest] and #[Registrar] attributes with
their resolvers. It places external metadata in a JSON file in the cache directory; issue #73 supersedes that.EngineBuilder with its two methods and its scoping to a module during each
registration call, its consumption into the immutable container, registrars instantiated directly during register
because the container does not exist, and a missing method for a phase being skipped silently.PanelContext
parameter filtering participation, #[Unscoped] with the decision left to the handler, permissions and routes as
the two worked examples, PanelContext and its three cases with Server as a subcontext of Account in routing,
and caching left to each component with no cacheable collector abstraction.Unscoped already meaning
something different for a #[Collect] method.PanelContext being defined in the engine’s shared values rather than inside collection; the registry’s two
construction modes expressed as process and cycle lifetimes; EngineBuilder being the module-facing surface over
the container’s two registries; alias chains being flattened at assembly, with a cycle failing the assembly and
naming the aliases in it; and the ModuleException marker with ModuleSourceException and
UnknownModuleException: first written down on 2026-09-16.