You've inherited a Laravel codebase. The controllers are 500 lines long. Business logic lives in models, jobs, middleware, and even views. Tests are flaky or nonexistent. Sound familiar?
Don't Rewrite. Strangle.
The temptation is to start fresh. Don't. Rewrites fail more often than they succeed. Instead, use the Strangler Fig pattern: gradually replace pieces of the legacy system while it continues running.
Here's how to apply it with DDD patterns:
Step 1: Identify a Bounded Context
Pick one area of your application that's causing pain. Maybe it's the billing system, or user registration, or order processing. This becomes your first Bounded Context.
Create a new folder structure alongside your existing code:
app/
├── Http/Controllers/ # Legacy
├── Models/ # Legacy
└── Domain/ # New DDD code
└── Billing/
├── Domain/
│ ├── Invoice.php
│ ├── InvoiceId.php
│ └── InvoiceRepositoryInterface.php
├── Application/
│ └── CreateInvoiceHandler.php
└── Infrastructure/
└── EloquentInvoiceRepository.php Step 2: Extract Value Objects First
Value Objects are the safest place to start. They have no dependencies on the rest of the system:
// Before: stringly-typed chaos
$user->email = $request->input('email');
// After: self-validating Value Object
final readonly class Email
{
public function __construct(public string $value)
{
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException("Invalid email: $value");
}
}
}
$user->email = new Email($request->input('email')); Start using Value Objects in new code immediately. Refactor existing code when you touch it.
Step 3: Create a Repository Adapter
You probably can't change your Eloquent models right away. That's fine. Create a repository that adapts between your domain and the existing models:
final class EloquentInvoiceRepository implements InvoiceRepositoryInterface
{
public function save(Invoice $invoice): void
{
// Map domain object to existing Eloquent model
$model = InvoiceModel::findOrNew($invoice->id()->value());
$model->customer_id = $invoice->customerId()->value();
$model->amount = $invoice->total()->cents();
$model->status = $invoice->status()->value;
$model->save();
}
public function findById(InvoiceId $id): ?Invoice
{
$model = InvoiceModel::find($id->value());
if (!$model) return null;
// Map Eloquent model to domain object
return new Invoice(
id: new InvoiceId($model->id),
customerId: new CustomerId($model->customer_id),
total: Money::fromCents($model->amount),
status: InvoiceStatus::from($model->status),
);
}
} Step 4: Route New Features Through the Domain
When you add new features to your Bounded Context, build them properly from the start:
// New controller - thin, delegates to domain
final class InvoiceController extends Controller
{
public function store(
CreateInvoiceRequest $request,
CreateInvoiceHandler $handler
): JsonResponse {
$command = new CreateInvoiceCommand(
customerId: new CustomerId($request->customer_id),
items: $request->items,
);
$invoiceId = $handler->handle($command);
return response()->json(['id' => $invoiceId->value()]);
}
} Step 5: Gradually Migrate Existing Functionality
As you fix bugs or add features to legacy code, migrate it to the new structure. Over time, the legacy code shrinks and the domain grows.
Key rules for migration:
- Never break existing tests (if you have them)
- Keep the API the same - changes are internal only
- One piece at a time - don't try to migrate everything at once
- Add tests as you go - the domain is easy to unit test
The 6-Month Checkpoint
After 6 months of gradual migration, you should have:
- One or two Bounded Contexts fully migrated
- A clear folder structure for domain code
- Value Objects replacing primitives in critical areas
- Repositories abstracting data access
- Command handlers for complex operations
- Unit tests covering domain logic
The legacy code is still there, but it's shrinking. New features are built correctly. The team is learning the patterns.
Want a complete migration guide?
Chapter 31 (Migrating Legacy Code) of Pragmatic DDD with Laravel covers this in detail, with real-world examples and common pitfalls to avoid.
Get the book