objects namespaces and application architecture
Monoliths and Modular Monoliths
A monolith is an application deployed as one unit. A modular monolith is still deployed as one unit, but its code is organised into clear internal modules with deliberate boundaries.
Many PHP applications are monoliths: a Laravel app, Symfony app, WordPress plugin platform, custom CMS, ecommerce site, or internal admin system may all run as a single deployable application. That is not automatically bad. A well-built monolith can be simple to deploy, easy to debug, and productive for a small team.
The problems start when the monolith has no internal structure. If every controller can call every model, every model can write to every table, and every service can reach into unrelated features, small changes become risky.
Monolith Does Not Mean Mess
A monolith can still have good layers, tests, naming, and boundaries.
For example, a simple application might have controllers, application services, domain objects, repositories, and infrastructure in one codebase:
src/
Controller/
Application/
Domain/
Infrastructure/
That structure is enough for many projects. The application is deployed together, shares one database, and runs in one process, but the code can still be understandable.
The benefit is operational simplicity. There is one deployment, one set of logs to start with, one local development environment, and fewer network boundaries. For many teams, especially early in a product, this is the right tradeoff.
What Makes A Modular Monolith Different
A modular monolith groups code by business capability, not only by technical layer.
Instead of one large Service folder for the whole app, the code is grouped into modules such as Billing, Courses, Users, and Reporting.
src/
Billing/
Application/
Domain/
Infrastructure/
Courses/
Application/
Domain/
Infrastructure/
Users/
Application/
Domain/
Infrastructure/
Each module owns a part of the business. Billing code should not casually edit course progress records. Course code should not reach into billing tables directly. If one module needs something from another module, it should use an explicit public API, application service, event, or shared contract.
Namespaces Show Ownership
In PHP, namespaces are one way to make module ownership visible.
<?php
declare(strict_types=1);
namespace App\Billing\Domain;
final readonly class InvoiceId
{
public function __construct(
public int $value,
) {
if ($value <= 0) {
throw new \InvalidArgumentException('Invoice ID must be positive.');
}
}
}
The namespace tells you this class belongs to the billing domain. That does not enforce every rule by itself, but it helps developers see the intended boundary.
A module can expose a small public service:
<?php
declare(strict_types=1);
namespace App\Billing\Application;
final class BillingStatus
{
/** @var array<int, bool> */
private array $paidInvoices = [];
public function markPaid(int $userId): void
{
$this->paidInvoices[$userId] = true;
}
public function userHasPaidInvoice(int $userId): bool
{
return $this->paidInvoices[$userId] ?? false;
}
}
Another module should depend on a clear method such as userHasPaidInvoice(), not on billing's private tables or internal classes.
Bad Module Boundaries
This kind of code is a warning sign:
<?php
declare(strict_types=1);
namespace App\Courses\Application;
use PDO;
final class UnlockCourse
{
public function __construct(
private PDO $pdo,
) {
}
public function handle(int $userId, int $courseId): void
{
$statement = $this->pdo->prepare(
'SELECT COUNT(*) FROM invoices WHERE user_id = ? AND paid_at IS NOT NULL'
);
$statement->execute([$userId]);
if ((int) $statement->fetchColumn() === 0) {
throw new \RuntimeException('User has not paid.');
}
echo 'Course ' . $courseId . ' unlocked for user ' . $userId . PHP_EOL;
}
}
UnlockCourse belongs to the courses module, but it knows billing table names. If billing changes its storage, course unlocking can break. The dependency is hidden in SQL instead of expressed through a module boundary.
A better shape is:
<?php
declare(strict_types=1);
namespace App\Courses\Application;
use App\Billing\Application\BillingStatus;
final class UnlockCourse
{
public function __construct(
private BillingStatus $billingStatus,
) {
}
public function handle(int $userId, int $courseId): void
{
if (!$this->billingStatus->userHasPaidInvoice($userId)) {
throw new \RuntimeException('User has not paid.');
}
echo 'Course ' . $courseId . ' unlocked for user ' . $userId . PHP_EOL;
}
}
The courses module still depends on billing, but the dependency is explicit and higher-level. That is easier to review than a cross-module SQL query.
Shared Code
Modular monoliths often need shared code. Examples include Money, EmailAddress, Clock, authentication identity, logging contracts, and event interfaces.
Shared code should be stable and genuinely shared. A common mistake is creating a large Shared or Common module where unrelated logic collects because nobody knows where it belongs.
Good shared code usually has few dependencies and does not know about specific features:
<?php
declare(strict_types=1);
namespace App\Shared\Domain;
final readonly class Money
{
public function __construct(
public int $amountPence,
public string $currency,
) {
if ($amountPence < 0) {
throw new \InvalidArgumentException('Amount cannot be negative.');
}
}
}
Money can reasonably be shared by billing, checkout, refunds, and reporting. A class called BillingCourseUserHelper does not belong in shared code because it mixes feature concerns.
Database Boundaries
A monolith often uses one database. A modular monolith may still use one database, but the ownership of tables should be clear.
For example:
- billing owns
invoices,payments, andrefunds - courses owns
courses,lessons, andcourse_progress - users owns
users,profiles, andpassword_resets
One shared database does not mean every module should query every table. Direct cross-module queries create coupling that is hard to see until a migration breaks another feature.
In real projects, teams may allow carefully reviewed read models or reporting queries that cross boundaries. The point is to make that a deliberate decision, not an accident.
Testing And Deployment
A monolith is deployed as one unit, so a broken module can still block the whole release. Modular boundaries help by making tests more focused.
You can test billing rules mostly inside billing, course progress mostly inside courses, and a small number of integration flows across modules. That is usually cheaper than splitting into separate services too early.
Modular monoliths are also useful when a team might later split part of the system into a separate service. Good module boundaries make that possible. They do not force it.
When A Modular Monolith Is Useful
Consider a modular monolith when:
- the application has several business areas with different rules
- teams or developers often work on different areas
- unrelated changes keep breaking each other
- large folders such as
Services,Models, orHelpersare becoming difficult to navigate - the business may later need clearer ownership or separate deployments
Avoid over-structuring a small application before the domain is understood. Modules should follow real business concepts, not guesses.
What You Should Be Able To Do
After this lesson, you should be able to explain that a monolith is one deployable application and that a modular monolith is one deployable application with stronger internal boundaries. You should be able to spot cross-module coupling, use namespaces to show ownership, and keep shared code genuinely shared.
For junior work, this matters because many job codebases are monoliths. The valuable skill is not dismissing them. It is improving them safely by placing code in the right module and avoiding hidden dependencies.
Practice
Practice: Sketch Module Boundaries
You are working on a course platform with users, courses, and billing. Sketch a modular monolith structure for it.
Task
Write a short design note that includes:
- three modules and what each owns
- the tables or records each module owns
- one example of an allowed dependency between modules
- one example of a dependency that should be avoided
- one shared value object that can reasonably live outside the modules
Then create a small PHP example showing one module calling another module through a public application service instead of querying its tables directly.
Use strict types in the PHP example.
Check Your Work
Your answer should make it clear:
- which code belongs to each module
- which module owns each piece of data
- why the allowed dependency is safer than a hidden database query
Show solution
Usersowns accounts, profiles, authentication state, and password reset records.Coursesowns courses, lessons, enrollments, and lesson progress.Billingowns invoices, payments, refunds, and billing status.
An allowed dependency might be Courses asking Billing whether a user has paid before unlocking a paid course. A dependency to avoid would be Courses querying the invoices table directly, because that couples course logic to billing storage.
EmailAddress or Money could live in shared domain code if several modules genuinely use it.
<?php
declare(strict_types=1);
namespace App\Billing\Application {
final class BillingStatus
{
/** @var array<int, bool> */
private array $paidUsers = [];
public function markPaid(int $userId): void
{
$this->paidUsers[$userId] = true;
}
public function userCanAccessPaidCourses(int $userId): bool
{
return $this->paidUsers[$userId] ?? false;
}
}
}
namespace App\Courses\Application {
use App\Billing\Application\BillingStatus;
final class UnlockCourse
{
public function __construct(
private BillingStatus $billingStatus,
) {
}
public function handle(int $userId, int $courseId): string
{
if (!$this->billingStatus->userCanAccessPaidCourses($userId)) {
return 'User must pay before accessing this course.';
}
return 'Course ' . $courseId . ' unlocked for user ' . $userId . '.';
}
}
}
namespace {
use App\Billing\Application\BillingStatus;
use App\Courses\Application\UnlockCourse;
$billingStatus = new BillingStatus();
$unlockCourse = new UnlockCourse($billingStatus);
echo $unlockCourse->handle(10, 55) . PHP_EOL;
$billingStatus->markPaid(10);
echo $unlockCourse->handle(10, 55) . PHP_EOL;
// Prints:
// User must pay before accessing this course.
// Course 55 unlocked for user 10.
}
The courses module depends on BillingStatus, which is an explicit billing application service. It does not depend on billing table names, SQL queries, or invoice internals. That makes the dependency visible and easier to change later.