A modular monolith is one application, deployed as one thing, but built from modules with edges as strict as if they were separate services. My PHP framework works this way: it's around a hundred and thirty small modules, and a site is little more than the list of modules it includes.
Getting here took a lot of experience. Even defining what a module is, what it should contain and how modules should talk to each other, took several versions of the framework to settle. These are the tactics and the structure I use now.
What is a module?
There are plenty of different ways to define a module and its boundaries. The one I've settled on is that a module owns everything about one concern:
- Its own database tables. No other module touches them.
- A set of functions that other modules can use once they include it.
- Its own user interface for that concern: the pages, panels and forms.
- Routes, if it has pages, which the application places wherever it wants them.
- Hooks, so that other modules can configure it and plug their own parts into it.
The routes are a good example of a module providing something without deciding where it goes. A module's routes are a block that the application mounts at a path of its choosing, here inside a block that only exists for a logged-in member:
MemberRoute::LoggedIn( function() {
WebRoute::Dir( 'popular', function() {
UiPaging::Route( function( $page ) {
ClipMemberPage::Library( ClipProjectDatabase::SORT_POPULAR, $page );
});
});
WebRoute::Dir( 'featured', function() {
UiPaging::Route( function( $page ) {
ClipMemberPage::Library( ClipProjectDatabase::SORT_FEATURED, $page );
});
});
});
Hooks
The hooks are what keep the modules loosely coupled. A module never reaches into another to change it. Instead the module being extended offers configuration functions, and other modules call them to add their own parts.
The member module is a good example. In the admin system there's a table of members, and any module that includes the member admin module can add its own columns to it: how many orders a member has made, for a shopping module, or, in the Frenzi taxi app, whether they're a passenger or a driver and how many rides they've booked. The member admin module offers a Column function, with one callback to fetch the data and one to show a cell:
class MemberAdminConfig {
static function Column( string $title, Callable $dataCallback, Callable $cellCallback ): void {
self::$columns[] = ['title' => $title, 'dataCallback' => $dataCallback, 'cellCallback' => $cellCallback];
}
...
public static array $columns = [];
}
The member settings module uses it to add a count of each member's settings, with a button through to them:
MemberAdminConfig::Column(
'Settings',
function(array $memberIds) {
return MemberSettingAdminDatabase::GetCountsByMemberIDs($memberIds);
},
function(int $memberId, int $count) {
$settingsUrl = Filter::Link(Admin::Link('/member/setting/list'), ['memberId' => $memberId]);
UiContent::Button($settingsUrl, "View ($count)");
}
);
The data callback takes every member ID on the page at once, so each extra column costs one query for the whole page, not one per row. The member admin module knows nothing about settings, orders or taxis, and the dependency only points one way: the settings module depends on the member admin module, never the reverse.
The rules
A few strict rules make all of this fit together.
Every module has an index.php, which is all you include to use it. It requires every module it depends on, directly, at the top, then every source file of its own. It doesn't run any code itself or make any database queries. The one thing it can do is call configuration functions on other modules, to add its own parts to them:
<?php
// MemberSettingAdmin module - includes all source files and UI components
// Required modules
require_once $MODULE_PATH . '/Loader/index.php';
require_once $MODULE_PATH.'/Admin/index.php';
require_once $MODULE_PATH.'/MemberAdmin/index.php';
require_once $MODULE_PATH.'/Ui/index.php';
require_once $MODULE_PATH.'/Filter/index.php';
require_once $MODULE_PATH.'/MemberSetting/index.php';
...
Loader::Require(__DIR__.'/database/MemberSettingAdminDatabase.php');
Loader::Require(__DIR__.'/src/MemberSettingAdminUi.php');
Loader::Require(__DIR__.'/src/MemberSettingAdminPageUi.php');
Loader::Require(__DIR__.'/src/MemberSettingAdminSectionUi.php');
// Register the section with MemberAdminConfig
MemberAdminConfig::Section('Setting', 'setting', 'MemberSettingAdminUi::Section');
// Add Settings column to member list
MemberAdminConfig::Column( ... );
Modules are included in a strict tree. There are no circular dependencies, and that has held up fine for larger projects. When including a module would force a dependency that shouldn't be there, that's a sign the code belongs in a different module, usually the one that owns the dependency. Hooks are what make this possible, because they let a lower module be extended by a higher one without knowing it exists.
A module with tables declares its database version, by handing the database module its name, its install directory and the latest version number. More on that below.
Anatomy of a module
Every module has the same layout, so once you've found your way around one, you can find your way around any of them. Here's the member module:
Member/
index.php requires its dependencies and its own files, declares its database version
install/ 0001.php ... 0006.php, one script for each database version
data/ MemberData, MemberAuthData, MemberCodeData ... immutable data objects
database/ MemberDatabase, MemberAuthDatabase ... every query
config/ MemberConfig, MemberCodeConfig ... hooks for other modules
class/ Member, MemberRoute, MemberSession, MemberUrl ... everything else
error/ MemberNotLoggedInError, MemberCodeExpiredError ... one class per error
src/ MemberLoginUi, MemberRegisterUi ... user interface
README.md
Every class name starts with the module's name, and each class is in its own file, named after it. So a class's name says which module it belongs to and which directory it's in.
A small zoo
There's only a small number of kinds of class, and each kind has its own directory and its own strict rules. The two that matter most are the data objects and the database classes.
Data objects are strictly immutable data. They're checked when they're made, and they never change after that:
final class MemberData
{
function __construct(
readonly int $id = 0,
readonly CalendarDateTimeData $added = new CalendarDateTimeData(),
readonly bool $deleted = false,
readonly ?CalendarDateTimeData $deletedTimestamp = null
) {
Security::ID($id, true);
Security::TrueFalse($deleted);
}
}
Database classes are the only place that can access the database. Every query a module makes is in its database/ directory, and nowhere else, so when a table changes there's exactly one place to look. They take IDs and data objects in, and hand data objects back:
final class LocationAddressDatabase
{
static function Get(int $addressId): LocationAddressData {
Security::ID($addressId);
$addressRow = Database::SelectRow('location_address', 'LocationAddressID', $addressId);
return LocationAddressDatabase::fromRow($addressRow);
}
...
private static function fromRow(stdClass $row): LocationAddressData { ... }
}
The rest are just as narrow. Config classes hold a module's hooks, error classes are one per kind of failure, and the user interface classes in src/ are built from the shared user interface module, never from raw HTML.
Database organisation
Install scripts
Each module's tables are made and changed by numbered scripts in its install/ directory, one for each version. The first makes the module's first table:
<?php
Database::Execute("
CREATE TABLE member(
`MemberID` BIGINT NOT NULL AUTO_INCREMENT,
`MemberAdded` BIGINT NOT NULL,
`MemberDeleted` TINYINT(1) NOT NULL DEFAULT 0,
`MemberDeletedTimestamp` BIGINT NULL,
CONSTRAINT PRIMARY_KEY PRIMARY KEY (MemberID)
);
");
The module's index.php says which version it's at:
Database::Install(
'Member',
__DIR__ . '/install',
6
);
The database module keeps a record of the version each module's tables are at. When a module is behind, it takes a lock, so that only one request does the upgrade, and runs each missing script in order, recording the new version after each one.
This works well with many people on the team. Each developer adds the next numbered script for the module they're working on, and the database upgrades itself when the new version goes online, on every developer's machine as well as on the live site. There's no separate migration step to forget.
No joins across modules
A module owns its tables, and that includes not being joined to. No query in one module may join a table that belongs to another. That's a strong rule, and it's very good for keeping the modules decoupled.
What replaces the join is a bulk query. A module that needs records from another asks for all of them at once, given a set of IDs, and the module that owns the table does the query:
/** Bulk lookup addresses by IDs. Returns array mapping address ID => LocationAddressData */
static function GetListByIDs(array $addressIds): array {
Security::ArrayOfID($addressIds);
if (empty($addressIds)) { return []; }
$q = Database::Query("
SELECT * FROM location_address
WHERE LocationAddressID IN (:addressIds)
");
$q->BindIntArray(':addressIds', $addressIds);
$rows = $q->FetchAll();
$result = [];
foreach ($rows as $row) {
$addressId = Security::ID($row->LocationAddressID);
$result[$addressId] = LocationAddressDatabase::fromRow($row);
}
return $result;
}
A page of taxi rides, say, gets its rides from the ride module, collects the address IDs from all of them, and asks the location module for those addresses in one go. That's two queries for the whole page, the same as a join would have been, without either module knowing how the other stores anything.
Ready to be a service
The reason for all this is what happens if a module stops being a module in the same application, and becomes a service of its own. Because no other module joins its tables, it isn't entangled with any of them. The bulk queries are already exactly the calls a service would need to offer, and they already ask for everything at once, which is what you want once each call goes over the network. So part of designing a module is always thinking about that: if this module were a service tomorrow, would the move be easy?
Most of the time it never happens, and that's the point of a modular monolith. You get the separation of services, with the simplicity of one application to deploy, and one database to back up.
Why it works
- A strict set of kinds of class, each with a well-defined place, layout and set of rules, so there's one obvious place for everything.
- The same shape for every module, so anyone can find their way around any of them.
- Modules own their own part of the database, with no joins across modules, so they stay decoupled.
- Bulk queries instead of joins, which cost no more queries and keep every module ready to become a service.
- Hooks instead of reaching in, so modules are extended from outside, and dependencies only point one way.
- Numbered install scripts, so the database upgrades itself, and a whole team can change it at once.
- An
index.phpthat only includes and configures, so including a module never has side effects of its own.
See also
- Maintaining a PHP framework for 25 years - how the framework, and its idea of a module, got here
- A queue on any table in PHP and MySQL - another of the framework's modules
- Too much code to read - managing AI agents when they write more code than you can read