Meta description: I walk through how I broke apart a 200,000-line PHP monolith into clean, testable modules — without stopping feature delivery or rewriting everything from scratch.
Last updated: May 2026
Introduction
In my second year at a mid-sized e-commerce company, I inherited a PHP application that had been in production since 2009. It had no namespaces, no autoloading, 900-line functions, and a database connection opened at the top of every single file with mysql_connect() — a function deprecated in PHP 5.5 and removed in PHP 7.0. Adding a feature meant scrolling through files nobody fully understood. Fixing a bug in the checkout flow broke the invoice PDF generator on the other side of the codebase.
The team wanted to rewrite it in Laravel. Management said no. So instead, I did something better: I refactored it incrementally into a modular architecture without halting the product roadmap, without a Big Bang rewrite, and without breaking production. This is exactly how I did it.
TL;DR
- Introduce Composer autoloading and PSR-4 namespaces first — this is the foundation everything else builds on.
- Extract bounded domains (billing, auth, catalog) into isolated modules with their own service classes, never touching other modules directly.
- Use the Strangler Fig pattern: keep the legacy code running while you replace it domain by domain. Never try to refactor everything at once.
Why Legacy PHP Monolith Refactoring Is Worth Doing Incrementally
A full rewrite sounds appealing. In practice, Joel Spolsky’s observation holds: you lose all the bug fixes, edge-case handling, and institutional knowledge encoded in that ugly code the moment you throw it away. The PHP codebase I worked on had 14 years of business logic buried in it — including an entire set of tax calculation rules that weren’t documented anywhere else.
Incremental modularization lets you improve the architecture continuously while the system stays live. It reduces risk by making each change small and reversible. And it teaches you the domain deeply, which makes every future change faster.
The key insight is that you don’t need a clean architecture to start getting the benefits of one. You need clear module boundaries, dependency inversion, and a test harness — and you can introduce all three into legacy PHP without a framework migration.
Important: The biggest mistake I made early on was trying to “clean up” code while refactoring structure. Behavior-preserving refactoring and code cleanup are two different activities. Do structural refactoring first. Clean up style and logic second, with tests already in place.
[INTERNAL LINK: related article on PHP dependency injection patterns]
Prerequisites
Before starting, you need:
- PHP 7.4+ (if you’re on PHP 5.x, upgrade first — the refactoring path is meaningless on unsupported versions)
- Composer installed globally (
composer --version) - At least one passing test or a manual regression checklist — you need a safety net
- Git with a clean working branch per module —
git checkout -b refactor/billing-module - Basic understanding of PSR standards, particularly PSR-4 and PSR-12
Step-by-Step: How I Refactored a PHP Monolith to Modules
Step 1: Introduce Composer and PSR-4 Autoloading
The single most impactful first step is replacing require_once spaghetti with Composer autoloading. Before this, every file inclusion was manual. After it, classes load automatically by namespace.
composer init
In composer.json, add your autoload mapping:
{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
composer dump-autoload
Then add this single line to your legacy entry point (index.php or bootstrap.php):
require_once __DIR__ . '/vendor/autoload.php';
Every new class you write from this point forward goes in src/ with a proper namespace. The old require_once files stay untouched — you’re not breaking anything, you’re adding a new layer.
Step 2: Map Your Domain Boundaries
Before writing a single line of modular code, I spent a week just reading the codebase and drawing a domain map. I identified six bounded contexts: Auth, Catalog, Cart, Billing, Fulfillment, and Notifications.
The most valuable exercise was creating a dependency matrix — a spreadsheet listing every file and which other files it required. Files that touched everything were the last ones I refactored. Files that were mostly standalone were the first.
src/
Auth/
Services/AuthService.php
Repositories/UserRepository.php
Contracts/AuthServiceInterface.php
Billing/
Services/InvoiceService.php
Repositories/InvoiceRepository.php
Contracts/InvoiceServiceInterface.php
...
Each module gets Services/, Repositories/, and Contracts/ subdirectories. The Contracts/ folder holds interfaces — this is what enables dependency inversion later.
Step 3: Extract One Module with the Strangler Fig Pattern
The Strangler Fig pattern means building the new code alongside the old, then gradually routing traffic to the new version until the old code is unused and can be deleted. I started with Auth because it had the clearest boundaries.
First, I wrote the interface:
namespace App\Auth\Contracts;
interface AuthServiceInterface
{
public function login(string $email, string $password): bool;
public function logout(): void;
public function currentUser(): ?array;
}
Then the service class that implements it:
namespace App\Auth\Services;
use App\Auth\Contracts\AuthServiceInterface;
class AuthService implements AuthServiceInterface
{
public function login(string $email, string $password): bool
{
// New, clean implementation
}
// ...
}
Then, in the legacy login controller, I replaced the inline logic with a call to the new service — without touching anything else:
// Old: 80 lines of inline logic
// New:
$auth = new \App\Auth\Services\AuthService();
$auth->login($email, $password);
The rest of the application is completely unaware this change happened. That’s the point.
Step 4: Introduce a Service Container for Dependency Injection
Once two or more modules exist, manual new ClassName() calls become a coupling problem. I introduced a minimal service container using PHP-DI, which integrates cleanly with any codebase without a framework.
composer require php-di/php-di
// bootstrap/container.php
$builder = new \DI\ContainerBuilder();
$builder->addDefinitions([
\App\Auth\Contracts\AuthServiceInterface::class =>
\DI\create(\App\Auth\Services\AuthService::class),
\App\Billing\Contracts\InvoiceServiceInterface::class =>
\DI\create(\App\Billing\Services\InvoiceService::class),
]);
$container = $builder->build();
Now modules depend on interfaces, not concrete classes. Swapping an implementation is a one-line config change. More importantly, your tests can inject mock implementations without monkey-patching globals.
Step 5: Add Tests Module by Module
This step is non-negotiable. Each newly extracted module must have at minimum:
- Unit tests for each service method
- One integration test that validates the module works with real data
composer require --dev phpunit/phpunit
// tests/Auth/AuthServiceTest.php
namespace Tests\Auth;
use App\Auth\Services\AuthService;
use PHPUnit\Framework\TestCase;
class AuthServiceTest extends TestCase
{
public function test_login_returns_false_for_invalid_credentials(): void
{
$service = new AuthService();
$this->assertFalse($service->login('bad@email.com', 'wrongpass'));
}
}
./vendor/bin/phpunit tests/
Real-World Tips I Use in Production
Refactor the database layer first within each module. The biggest coupling in most PHP monoliths is the database — raw SQL queries scattered across view files and business logic mixed together. I always extract a Repository class first, then the Service.
Use feature flags during the transition period. For high-traffic features, I wrap the new module call in a feature flag so I can toggle between old and new code without a deployment:
if (config('features.new_auth_module')) {
$container->get(AuthServiceInterface::class)->login($email, $password);
} else {
legacy_login($email, $password); // old code
}
Never refactor and add features in the same pull request. This makes code review impossible and introduces bugs that are hard to attribute to either change.
Common Errors and How I Fixed Them
Error: Class not found after adding PSR-4 autoloading Almost always means composer dump-autoload wasn’t run after adding the new file. Run it, and if that doesn’t fix it, check that the namespace declaration matches the directory path exactly — App\Auth\Services must live at src/Auth/Services/.
Error: Circular dependency detected in service container This means two modules depend on each other, which means your domain boundaries are wrong. Introduce a third module that both can depend on, or use events to decouple them. I introduced an App\Shared\Events namespace to hold domain events for exactly this reason.
Error: Legacy code breaks after extracting a module Nine times out of ten, this is because the legacy code was depending on a side effect of the old implementation — a global variable being set, a session key being written. Use grep -r 'old_function_name' to find all call sites before extraction.
Error: PHPUnit throws Cannot redeclare function during test runs Legacy PHP files with procedural functions pollute the global namespace when included. I wrap them in if (!function_exists('legacy_fn')) guards as a temporary measure, then eliminate them module by module.
[SOURCE: https://www.php-fig.org/psr/psr-4/] [SOURCE: https://php-di.org/doc/getting-started.html]
FAQ
Q: How do I refactor a legacy PHP monolith to modular architecture without breaking production? A: Use the Strangler Fig pattern — build new modules alongside the legacy code, route traffic to them incrementally, and delete old code only after the new version is verified. Never attempt a Big Bang cutover. Feature flags are your safety net during the transition period.
Q: What is the best folder structure for a modular PHP application without a framework? A: I recommend domain-based directories under src/, each with Services/, Repositories/, and Contracts/ subdirectories. Follow PSR-4 autoloading with Composer. This structure scales from small modules to full Domain-Driven Design without needing a framework like Laravel or Symfony to enforce it.
Q: How long does it take to refactor a large PHP monolith to modular architecture? A: In my experience, a 100,000-line codebase takes 6–12 months of part-time refactoring alongside normal feature work. The first module takes the longest (setup + learning curve). By the third or fourth module, the process is fast and mostly mechanical. Budget for it explicitly — “we’ll do it as we go” almost never happens without dedicated time.
Q: Should I use Laravel or Symfony when refactoring a PHP monolith? A: Not necessarily, and not right away. Introducing a framework into a legacy codebase simultaneously with modularization doubles your risk. I prefer to modularize first using Composer + PHP-DI + PSR standards, then optionally migrate to a framework once the modules are clean and tested. Each step in isolation is safe; both at once is fragile.
Q: How do I handle shared database tables when splitting a PHP monolith into modules? A: In the short term, allow multiple modules to share tables but access them only through their own Repository classes. In the longer term, aim for each module to own its tables and expose data to other modules only through service interfaces or events — never through direct cross-module database queries.
Conclusion
Refactoring a legacy PHP monolith doesn’t require a rewrite, a new framework, or six months of feature freeze. It requires a clear domain map, one module extracted at a time, and the discipline to never mix structural refactoring with feature work. The result is a codebase that’s testable, deployable in pieces, and something your team can actually be proud to work in.
About the Author
I’m a senior software engineer and technical lead with 11 years of experience in PHP, Python, and distributed systems. I’ve led refactoring efforts on legacy codebases ranging from 50,000 to 500,000 lines, and I’ve built greenfield services on Laravel, Symfony, and bare PHP. My philosophy is pragmatic modernization: make things incrementally better, ship continuously, and never bet the company on a rewrite.

