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.
A Monolith Is A Deployment Boundary
"Monolith" describes how an application is deployed, not how many classes, repositories, or PHP processes it has. One deployable application may run many web workers, queue workers, scheduled commands, and horizontally scaled containers. It may use a database, Redis, object storage, and a CDN while remaining a monolith.
A large repository is not automatically a monolith either. One repository can contain several independently deployed applications. Conversely, separate repositories can still form a distributed monolith when every release must be coordinated and the applications cannot function independently.
Use precise questions:
- Can this part be deployed independently?
- Does it own its runtime configuration and release lifecycle?
- Can it fail without taking the whole product down?
- Does it own its data, or does another deployable unit edit the same tables?
Those answers reveal the architecture more reliably than folder names.
Enforce Boundaries, Do Not Only Document Them
A diagram that says Courses must not depend on Billing\Infrastructure is useful, but code review and automated checks should reinforce it. Namespace dependency rules, static-analysis extensions, architecture tests, and package boundaries can fail CI when forbidden imports appear.
A simple dependency direction might be:
Courses\Presentation -> Courses\Application -> Courses\Domain
|
v
Courses\Infrastructure
Courses\Application -> Billing\PublicApi
Courses\Application -X-> Billing\Infrastructure
The exact arrows depend on the codebase. The important point is that each module has a deliberately small public surface. Internal namespaces should not become unofficial APIs merely because Composer can autoload them.
Framework conveniences can weaken boundaries. A global service container, active-record model, facade, or static helper can let any module reach any other module without an explicit constructor dependency. These tools are not inherently wrong, but architecture rules should make cross-module access visible.
Transactions Are A Monolith Advantage
When modules share one relational database and one process, a carefully owned workflow can use one ACID transaction. Creating an order, reserving stock, and recording an outbox event can succeed or roll back together. That is much simpler than coordinating three network services.
Do not use this advantage to erase ownership. A checkout application service may coordinate public module operations inside a transaction, while each module still owns its rules and tables. Random cross-module SQL is not modular simply because it runs atomically.
For asynchronous module communication, an outbox table can record events in the same transaction as the business change. A worker publishes them later. This preserves the monolith's transactional strength while avoiding unreliable "commit, then publish" ordering.
Synchronous Calls And Internal Events
Use a synchronous module call when the caller needs an immediate answer to complete its operation, such as checking whether an account may start a trial. Keep the called API small and avoid returning another module's internal entity.
Use an internal event when the initiating module should not know every reaction. OrderPlaced might trigger email, analytics, and fulfillment handlers. In-process events are still part of one deployment: a slow or failing synchronous handler can block the original request unless dispatch is moved to a queue.
Events should contain stable facts and identifiers, not live ORM objects. Consumers should tolerate repeated delivery when queued processing is possible.
A Monolith Can Scale Horizontally
A PHP monolith can run behind a load balancer across many instances. To make that safe, avoid storing important request state only in one process or one server's local filesystem.
Typical requirements include:
- shared or appropriately replicated database access
- sessions stored in a shared system or represented by self-contained tokens
- uploaded files in shared object storage rather than one instance's disk
- cache keys and invalidation that work across instances
- queue workers that make jobs idempotent
- migrations compatible with old and new code during rolling deployment
- health checks that cover critical dependencies without overloading them
Scaling the deployable unit together may use more resources than scaling one service, but it is operationally understandable. Measure the bottleneck before splitting architecture. Database queries, external calls, cache misses, and oversized responses are often more important than process boundaries.
Release And Failure Tradeoffs
One deployment means one release can contain coordinated changes across modules and one rollback can restore them together. It also means a regression in one module can block or roll back the whole release.
Reduce that risk with focused tests, feature flags, backward-compatible migrations, canary or staged deployment, and module ownership. A modular monolith should let developers identify which tests and maintainers are relevant without pretending the deployment units are independent.
Runtime failures also share resources. A memory leak in a queue worker, an expensive reporting query, or a flood of image processing can affect unrelated features. Separate worker pools, database users, queues, rate limits, and resource quotas can isolate workloads while preserving one codebase and release artifact.
Extract A Service Only With Evidence
Good module boundaries make extraction possible, but extraction should solve a concrete problem. Evidence may include:
- one capability needs a different release cadence
- a team requires independent operational ownership
- workload characteristics need separate scaling or isolation
- security or regulatory rules require a stronger boundary
- a capability has a stable API and clearly owned data
- failures in that area must not consume the main application's resources
Before extraction, identify every synchronous caller, table access, event, scheduled job, and reporting dependency. If other modules freely join its tables or instantiate its internal classes, the module is not ready to become a service.
Extraction introduces network failure, authentication, versioned contracts, eventual consistency, deployment automation, observability, and local-development cost. A clean module is an option to extract, not an obligation.
Warning Signs Of A Distributed Monolith
Splitting code into deployable services does not create independence when:
- services share table ownership
- a feature requires coordinated releases across repositories
- one request performs a long chain of synchronous calls
- contracts change without versioning or compatibility periods
- every developer must run the entire estate for one small change
- no team can diagnose a request across service boundaries
In that situation, returning to a modular monolith can be an architectural improvement. Fewer network boundaries can restore transactional consistency and simplify delivery while internal module rules preserve ownership.
Evolving An Existing Monolith
Do not reorganize an entire mature application in one migration. Choose a business capability with active change or clear pain. Name its ownership, move new work behind an application API, and stop new direct access to its tables. Then migrate old callers gradually.
Useful steps are:
- inventory namespaces, tables, jobs, routes, and external integrations
- identify a capability owner and its public operations
- add tests around current behavior
- introduce a narrow module API
- redirect callers away from internal models and SQL
- enforce the new dependency rule in CI
- repeat where the benefit justifies the work
This approach improves architecture while continuing to ship product changes. A perfect folder tree achieved through a risky rewrite is not the goal.
What You Should Be Able To Do
After this lesson, you should be able to define a monolith by its deployment boundary and explain why one application can still run many scaled processes. You should be able to distinguish a basic monolith, a modular monolith, and a distributed monolith.
You should also be able to assign module and table ownership, choose a synchronous API or internal event, enforce dependency direction, preserve transactional advantages, prepare a monolith for horizontal scaling, and demand concrete operational evidence before extracting a service.
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.
Practice: Audit Module Dependencies
Review a modular monolith with Users, Orders, Billing, and Reporting modules.
Task
Classify each dependency as allowed, questionable, or forbidden, and explain why:
Orders\ApplicationcallsBilling\PublicApi\PaymentStatusOrders\DomainimportsBilling\Infrastructure\EloquentPaymentReportingreads a documented read-only projection maintained from module eventsUsersupdates theorderstable directly during account deletionBillingpublishesPaymentReceivedcontaining IDs and scalar valuesOrderspublishes an event containing a live ORMOrderobject
Then propose one automated namespace rule and one database-ownership rule that would prevent recurrence.
Show solution
- Calling
Billing\PublicApi\PaymentStatusis allowed when Orders needs an immediate answer. The dependency is explicit and points at Billing's supported surface. - Importing
Billing\Infrastructure\EloquentPaymentfrom an Orders domain class is forbidden. It reverses the domain dependency and exposes Billing storage details. - Reading a documented reporting projection is allowed when that projection has an owner, update contract, and accepted freshness. Reporting should not treat private operational tables as its API.
- Updating the
orderstable from Users is forbidden. Users should request deletion or anonymization through an Orders application operation or publish an account event Orders handles. - Publishing
PaymentReceivedwith stable IDs and scalar facts is allowed. Consumers do not depend on Billing's runtime objects. - Publishing a live ORM object is forbidden. It leaks persistence behavior, can serialize unpredictably, and gives consumers access to internal relationships.
An automated rule can permit cross-module imports only from namespaces ending in PublicApi or Contracts. A database rule can assign every table one owning module and reject migrations or application queries from another module unless they target an approved read model.
Practice: Scale A Monolith Safely
A PHP shop application currently runs on one server. Sessions and uploaded product images are stored on local disk, scheduled tasks run from cron, and checkout jobs are not idempotent.
Task
Design the minimum changes needed to run three application instances behind a load balancer. Address:
- session storage
- uploaded files
- cache consistency
- scheduled jobs
- queue delivery and retries
- database migrations during rolling deployment
- health checks and observability
State which changes are architecture requirements and which are operational safeguards. Do not propose microservices unless one of the stated problems actually requires an independent deployment boundary.
Show solution
Store sessions in a shared session system or adopt an appropriate self-contained session design; otherwise a request routed to another instance loses login state. Move uploads to shared object storage and serve them through stable URLs, because local files exist on only one instance.
Use a shared cache or design instance-local entries so stale values cannot violate correctness. Namespace cache keys by environment and define invalidation across instances.
Run scheduled commands under a distributed lock or on one designated scheduler so three instances do not execute the same task. Queue jobs need unique operation identifiers and idempotent handlers because brokers may redeliver after a timeout or worker crash.
Database migrations must support both the old and new application versions during a rolling release. Add columns and dual-read or dual-write where necessary before removing old structures in a later deployment.
Health checks should distinguish process liveness from readiness to accept traffic. Centralized logs, metrics, error reporting, and request correlation make failures visible across instances.
Shared sessions, shared uploads, and safe job ownership are architectural requirements for correctness. Staged deployment, detailed telemetry, and graceful health handling are operational safeguards. None of these requirements by itself justifies splitting the application into microservices.