I walked a 10-year-old PHP monolith into a modular architecture without downtime — here’s the exact strategy, tools, and hard lessons I learned along the way.
I once opened a PHP file called functions.php that was 14,000 lines long. No classes. No namespaces. Global variables everywhere, database queries inline with HTML, and business logic tightly coupled to the presentation layer. The application had been running in production for over a decade, processing thousands of transactions daily, and nobody — not even the original authors — fully understood what it did. That file was the entire application. If you’ve ever stared down a legacy PHP monolith and felt the unique mix of dread and responsibility that comes with it, this guide is for you. Refactoring legacy PHP code to a modular architecture is one of the most impactful things you can do for a codebase’s long-term health — if you do it right.
TL;DR
- Never rewrite from scratch — use the Strangler Fig pattern to incrementally replace monolith pieces without breaking production.
- Identify seams (natural boundaries in the code) first; these become your initial module boundaries.
- Enforce module isolation with PSR-4 autoloading, dependency injection, and strict no-globals policies before touching any business logic.
Why Refactoring Legacy PHP Monoliths Matters
A PHP monolith isn’t just a technical debt problem — it’s a velocity killer. In my experience, teams maintaining large legacy PHP applications spend 40–60% of their sprint time on accidental complexity: merge conflicts in shared files, mysterious side effects from global state, and the paralysis of not knowing what will break when you change a function.
Modular architecture solves this by creating bounded contexts — independently deployable (or at least independently testable) units with clearly defined interfaces. You don’t need microservices to get these benefits. A well-structured monolith with proper module boundaries is vastly superior to a poorly structured one, and it’s a far safer destination than a premature microservices split.
The PHP ecosystem has matured enormously. PHP 8.2+ gives us fibers, enums, readonly properties, and first-class callables [SOURCE: https://www.php.net/releases/8.2/en.php]. Composer and PSR standards mean there’s no excuse for the spaghetti architectures of 2008 in 2026 codebases.
Prerequisites
Before starting this refactor, make sure you have:
- PHP 8.0+ (ideally 8.2+); the refactor is possible on older versions but much harder
- Composer installed and initialized in the project
- A working test suite (even a small one — if you have zero tests, write characterization tests before touching anything)
- Access to the CI/CD pipeline to add automated checks
- Familiarity with PSR-4, PSR-11 (Container Interface), and PSR-12 [SOURCE: https://www.php-fig.org/psr/]
Important: Do not start this refactor without at least basic test coverage. I learned this the hard way when a “safe” extraction broke a payment calculation that had no test. A production incident followed. Even shallow characterization tests written against the existing behavior give you a safety net.
Step-by-Step: How I Refactor a PHP Monolith to Modular Architecture
Step 1 — Map the Monolith with Static Analysis
Before moving a single line of code, I map the existing system. The tool I use is PHPStan (level 0 first, to understand the scope) combined with deptrac for dependency visualization.
# Install PHPStan
composer require --dev phpstan/phpstan
# Run baseline analysis
./vendor/bin/phpstan analyse src/ --level=0 --generate-baseline
# Install deptrac for dependency mapping
composer require --dev qossmic/deptrac-shim
./vendor/bin/deptrac analyse
On a recent project, this revealed that what looked like a “billing module” was actually spread across 23 files in 7 directories with 140 incoming dependencies from unrelated parts of the app. That map became the refactoring roadmap.
Step 2 — Set Up PSR-4 Autoloading and Module Directory Structure
Even before splitting any logic, I restructure the directory layout and configure Composer autoloading. This is low-risk and sets up the scaffolding:
// composer.json
{
"autoload": {
"psr-4": {
"App\\": "src/",
"App\\Billing\\": "modules/Billing/src/",
"App\\Catalog\\": "modules/Catalog/src/",
"App\\Auth\\": "modules/Auth/src/"
}
}
}
composer dump-autoload
My target directory structure looks like this:
modules/
Billing/
src/
Domain/
Application/
Infrastructure/
tests/
composer.json
Catalog/
src/
tests/
composer.json
Auth/
src/
tests/
composer.json
src/
(remaining monolith code, shrinking over time)
Each module gets its own composer.json so it can eventually be extracted into a standalone package with zero friction.
Step 3 — Apply the Strangler Fig Pattern
The Strangler Fig pattern (popularized by Martin Fowler) is the safest way to refactor a live system. The idea: new code grows around the old code, intercepting requests one feature at a time, until the old code is fully “strangled” and can be deleted.
In PHP, I implement this with a simple routing dispatcher:
// public/index.php
$router = new Router();
// New modular handler
$router->addRoute('POST', '/billing/invoice',
\App\Billing\Application\CreateInvoiceHandler::class
);
// Legacy fallback — old monolith still handles everything else
$router->setFallback(function() {
require_once __DIR__ . '/../legacy/index.php';
});
$router->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);
This means production traffic continues flowing through the monolith for all unrefactored routes, while the new module handles its specific routes. No big bang. No feature flags that need to be maintained forever. Just a shrinking legacy fallback.
Step 4 — Eliminate Global State First
Global variables and superglobal abuse ($_SESSION, $_GET scattered through business logic) are the most toxic pattern in legacy PHP. Before extracting any module, I eliminate global state within its boundaries.
// ❌ Legacy pattern
function getUser() {
global $db;
$id = $_SESSION['user_id'];
return $db->query("SELECT * FROM users WHERE id = $id");
}
// ✅ Modular pattern
class UserRepository {
public function __construct(
private readonly PDO $connection
) {}
public function findById(UserId $id): User {
$stmt = $this->connection->prepare(
'SELECT * FROM users WHERE id = :id'
);
$stmt->execute(['id' => $id->value()]);
return User::fromRow($stmt->fetch(PDO::FETCH_ASSOC));
}
}
I use a PSR-11 compatible DI container (I prefer php-di/php-di 7.x) to wire dependencies. This alone — before any architectural changes — makes code dramatically more testable.
Step 5 — Extract Domain Logic Behind Interfaces
The final step in each module extraction is isolating domain logic from infrastructure concerns. I define interfaces at the module boundary and implement them in the infrastructure layer:
// modules/Billing/src/Domain/InvoiceRepositoryInterface.php
namespace App\Billing\Domain;
interface InvoiceRepositoryInterface {
public function save(Invoice $invoice): void;
public function findById(InvoiceId $id): ?Invoice;
/** @return Invoice[] */
public function findByCustomer(CustomerId $customerId): array;
}
// modules/Billing/src/Infrastructure/MySQLInvoiceRepository.php
namespace App\Billing\Infrastructure;
class MySQLInvoiceRepository implements InvoiceRepositoryInterface {
// PDO implementation here
}
The domain layer has zero knowledge of MySQL, Redis, or any specific storage technology. This is what makes modules truly portable and independently testable.
Real-World Tips I Use in Production
Write characterization tests before touching anything. I use a technique where I capture the actual HTTP response of key endpoints and assert against them. This isn’t beautiful testing — it’s a safety net:
public function test_invoice_endpoint_returns_expected_structure(): void {
$response = $this->get('/billing/invoice/42');
$this->assertJsonStructure(['id', 'total', 'line_items'], $response);
}
Use deptrac to enforce module boundaries in CI. After setting up module directories, I configure deptrac rules to prevent cross-module direct class dependencies:
# deptrac.yaml
layers:
- name: Billing
collectors:
- type: directory
value: modules/Billing/src/.*
ruleset:
Billing:
- Shared
Any violation fails the CI pipeline. This prevents the “modular monolith that’s actually still a big ball of mud” anti-pattern.
Strangling takes longer than you think. On a 10-year-old codebase, I’ve found that a realistic migration velocity is 2–3 modules per quarter when the team is also delivering features. Plan for 12–18 months for a substantial monolith.
[INTERNAL LINK: related article on PHP 8 upgrade strategies for legacy codebases]
Common Errors and How I Fixed Them
Error: Class 'App\Billing\Domain\Invoice' not found after Composer autoload setup. This almost always means the namespace in the file doesn’t match the directory path exactly. PHP namespaces and directory names are case-sensitive on Linux. I got burned when the directory was billing/ but the namespace was App\Billing\ — it worked on my macOS dev machine (case-insensitive filesystem) and failed in production (Linux). Fix: enforce lowercase directory names and match namespaces exactly, then run composer dump-autoload -o to regenerate the optimized classmap.
Error: Circular dependencies when extracting shared utilities. When extracting the Billing module, I discovered it depended on a TaxCalculator class that also depended on a Pricing class that depended on Billing. Breaking cycles requires introducing a Shared module for utilities that multiple modules need, with a strict rule that the Shared module can never depend on domain modules. deptrac enforced this rule automatically once I configured it.
Error: Session data inaccessible inside new module handlers. When new module routes bypassed the legacy bootstrap file, $_SESSION wasn’t initialized. Rather than reaching back into legacy globals, I created a SessionService adapter that wraps PHP’s native session and is injected via DI. This also made session handling testable for the first time.
FAQ
Q: What is the best strategy for refactoring a PHP monolith without breaking production? A: The Strangler Fig pattern is the safest approach I’ve used. Instead of rewriting the entire application at once, you intercept specific routes or features and redirect them to new modular code, while the legacy monolith continues handling everything else. This means production is never fully down, and you can ship incremental improvements rather than betting on a big-bang migration that almost always overruns its timeline.
Q: How do you identify module boundaries in a legacy PHP application? A: I look for natural “seams” in the codebase — places where coupling is already low, or where different teams conceptually own different areas. Static analysis tools like deptrac and PHPStan help visualize existing dependency clusters. Domain-Driven Design’s concept of bounded contexts is helpful here: ask “what does this code talk about?” rather than “where is it located?”. If a set of files talks about billing entities — invoices, payments, subscriptions — that’s a natural module boundary regardless of the current directory structure.
Q: How long does it take to refactor a large PHP monolith to modular architecture? A: In my experience, a realistic estimate for a 10-year-old application with 200k+ lines of code is 12–24 months of incremental work alongside normal feature development. Attempting to do it as a dedicated project in isolation from feature work almost always fails — the monolith keeps getting new features during the refactor, and you end up chasing a moving target. Integrate the refactor into your normal workflow at a pace of 1–2 modules per sprint.
Q: Should I migrate a PHP monolith to microservices instead of modular architecture? A: Almost never as the first step. Microservices introduce distributed systems complexity — network latency, eventual consistency, service discovery, distributed tracing — that a team struggling with a monolith is almost never ready for. A well-structured modular monolith gives you 80% of the organizational benefits with 20% of the operational cost. Once you have clean module boundaries, extracting a service becomes a genuine option rather than a desperate migration. Start with modules, evaluate microservices only if you have specific scaling or team isolation requirements that modules can’t solve.
Q: What PHP tools are most useful for refactoring legacy code to a modular architecture? A: My core toolkit is: PHPStan for static analysis (start at level 0, increase gradually), deptrac for enforcing architectural boundaries in CI, php-di/php-di 7.x for dependency injection, PHPUnit for testing, and Rector for automated code modernization (it can automatically upgrade PHP 5-style code to PHP 8 syntax). Composer’s autoloading is the foundation everything else sits on. I’d also recommend Psalm as an alternative or complement to PHPStan for type-level analysis on complex legacy codebases.
Conclusion
Refactoring a legacy PHP monolith is a marathon, not a sprint — but every module you extract is a permanent improvement. Start with the Strangler Fig, map your seams with static analysis, enforce boundaries in CI from day one, and resist the urge to rewrite everything at once. The codebase you’re working with may not be beautiful, but it’s survived a decade in production. Treat it with respect, change it incrementally, and you’ll end up with something genuinely maintainable.
If this guide helped you, share it with your team or drop a comment about the specific challenges you’re hitting — every legacy PHP codebase has its own unique flavor of chaos, and I’d love to hear yours.
About the Author
I’m a senior backend engineer and PHP architect with 11 years of experience rescuing legacy systems and building modular, maintainable backends for high-traffic applications. My current stack centers on PHP 8.2, Symfony components, MySQL, and Redis, with a strong focus on clean architecture and test-driven refactoring. I’ve led monolith-to-modular migrations at e-commerce, fintech, and SaaS companies, and I believe that every legacy codebase deserves a path forward — not a rewrite.

