Loose coupling and extension

Modules stay independent. A module configures another module by calling that module's own configuration functions, a declarative registration. Dependencies run one way.

Dependencies point one way

The calling module knows the module it configures. That module knows nothing about the caller. The Shop module knows about the folder module and registers a folder type with it. The folder module has no knowledge of shops, products, or any application that uses it. It stores structure only.

This keeps a general module reusable. The folder module can organise any table's rows because it never depends on the tables it organises.

Configure by calling the other module's functions

A module configures another by calling that module's own configuration functions. This is declarative registration: the calling module states what it wants, and the other module records it. The Shop module registers a folder type by calling MemberFolderConfig::Type:

// in Shop/index.php
MemberFolderConfig::Type('Shop', 'Product', function() {
    MemberFolderTypeConfig::Content(
        'Shop', 'Product',
        'ShopProductFolder::Count',   // fn(int $folderId): int
        'ShopProductFolder::Render'   // fn(int $folderId, ...): void
    );
});

The Shop module hands the folder module two callbacks: one to count its products in a folder, one to render them. The folder module calls those back when it draws a folder page. It never needs to know they belong to a shop.

Modules extend other modules

The same mechanism lets one module add functionality to another. The extended module publishes what can be added on its config class; extending modules call it. The admin system's member list is a worked example. A billing module can add its own column to that list by calling MemberAdminConfig::Column, passing a title and two callbacks: one that fetches the value for a member, one that renders the cell.

// in Billing/index.php
MemberAdminConfig::Column(
    'Plan',
    'Billing::PlanForMember',   // data callback: fn(int $memberId): mixed
    'BillingAdmin::PlanCell'    // cell callback: renders the cell
);

The member-list table now shows a Plan column, yet MemberAdmin knows nothing about billing, plans, or the module that registered the column. It just records the entry and calls the callbacks back when it draws the table. The dependency is one way: billing knows the admin, the admin does not know billing.

The Api module is extended the same way. Rather than owning a fixed list of endpoints, it lets each module register its own endpoints and errors through its config, so the API surface grows as modules are added, with no change to the Api module itself.

Where this lives

A registration call like this belongs in the calling module's index.php, after it has included the module it configures. The Shop module includes MemberFolder/index.php at the top, then calls MemberFolderConfig::Type during load.

Each module publishes its configuration API on a <Module>Config class. See Config classes for how a module exposes one and what belongs on it.

No cross-module joins

The same independence applies to the database. A module never JOINs a table from another module. It joins only its own tables. When two modules' data must be brought together, the join happens in plain PHP above the database layer, not in SQL.

The reason is the same coupling you avoid everywhere else, made concrete. A join names two modules' tables in one statement and assumes they share a database. As a site grows you may split modules across separate databases, or even separate servers, so that each can scale on its own. A cross-module join would then be impossible to run. Reconciling in code keeps that door open: the modules stay separable because nothing in the database wires them together.

The pattern that replaces the join is a bulk id lookup. Gather the referenced ids from one module's rows, load the matching rows from the other module in a single query, then cross-reference the two lists in PHP. It is worked through step by step on The database layer, and stated as a rule on Database layer conventions.

Next: Submodules, providers and vendors.