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.