Refactoring Legacy
The codebase has two layouts in it: the DDD layout under src/Domain/ and src/Presentation/, and the legacy Laravel layout under app/Models/, app/Http/Controllers/, and app/Services/. Both work. The legacy code is not broken — it's just organised differently.
The question is when to migrate a legacy file into the DDD layout. The wrong answer is "always, eagerly." Greenfield rewrites that nobody asked for create merge conflicts, regressions, and review burden without clear benefit. The right answer is "when you're already touching it and the change is non-trivial."
This chapter is the playbook: when to refactor, the five steps for doing it, and how to keep unmigrated callers alive while the move is in flight.
When to refactor
Good triggers — refactor as part of the change you were already making:
- You're adding a new method to a legacy Service that already has 12 methods — extract the new operation as an Action in a proper domain instead.
- You're fixing a bug in a legacy Controller that does business logic inline — pull the operation into an Action under the right domain.
- A new feature crosses a boundary the legacy code blurs — that's the moment to draw the boundary properly.
Skip refactoring if you're making a one-line fix in code that's stable and untouched for months. Leave it alone.
The five steps
1. Identify the domain
Read the legacy code and decide which bounded context it belongs to. Match against the existing list in the domain glossary — Order, Provider, Accounting, Rental, etc.
If nothing fits, you may need a new domain — but verify with the team first. Adding a domain is a bigger decision than moving code around, and it's much easier to fold work into an existing bounded context than to split it back out later.
2. Create the domain skeleton
If the target domain doesn't exist yet, create it by hand: clone the structure from a neighbouring domain (Domain/Order/, Domain/Quotation/, Domain/Provider/), then rename namespaces and class names. See DDD overview for the canonical directory tree.
If the domain already exists, skip this step and reuse what's there.
3. Move models to Models/Entities/; queries into Filter traits
Relocate the Eloquent model from app/Models/{Name}.php to src/Domain/{Domain}/Models/Entities/{Name}.php. Update the namespace.
Any query scope or where* helper on the model moves into a Filter trait under Models/Scopes/{Name}Filter.php — see Eloquent and queries.
✅ Good — query helper lives in a Filter trait:
// src/Domain/Order/Models/Scopes/OrderFilter.php
trait OrderFilter
{
public function scopeFilterByStatus($query, ?string $status)
{
return $query->when($status, fn ($q) => $q->where('status', $status));
}
}
❌ Bad — query method on the model itself:
class Order extends Model
{
public function scopeFilterByStatus($query, ?string $status) { ... }
}
Keeping query-building on the model makes it grow without bound and forces every consumer to know all of its scopes. The Filter trait keeps the model thin and the query logic in one discoverable place.
4. Extract operations to Actions; wrap a Service around the Contract
Each public method on the legacy Service becomes one Action under src/Domain/{Domain}/Actions/. Each Action is a single public static function handle(...). Multi-statement writes wrap in DB::transaction(...) — see actions.
Define the Contract (src/Domain/{Domain}/Contracts/{Entity}Contract.php) with one-line method signatures. The Service implements the Contract by delegating to Actions — no Eloquent inside the Service. Bind the Contract to the Service in a Domain ServiceProvider and register it in bootstrap/providers.php. See services and contracts.
✅ Good — Service delegates to an Action:
final class OrderService implements OrderContract
{
public function createOrder(CreateOrderData $data): Order
{
return CreateOrder::handle($data);
}
}
❌ Bad — Service does the work inline:
final class OrderService implements OrderContract
{
public function createOrder(CreateOrderData $data): Order
{
return DB::transaction(function () use ($data) {
$order = Order::create($data->toArray());
// ... 30 more lines of business logic
return $order;
});
}
}
The bad version puts the business logic and the contract surface in the same class, making the Action layer pointless and the Service untestable in isolation.
5. Update callers to use the Contract
Replace app(LegacyService::class) calls with app({Entity}Contract::class). Update parameter types to use the new DTOs.
Legacy callers that haven't been migrated yet can keep calling the old Service temporarily — the old Service stays in place as a shim that delegates to the new Contract until every caller is converted.
// Legacy shim during migration — delete once all callers use the Contract
class LegacyOrderService
{
public function createOrder(array $data): Order
{
return app(OrderContract::class)->createOrder(CreateOrderData::fromArray($data));
}
}
This is the entire point of step 5: you don't have to migrate every caller in the same PR. Move them piecewise as you touch them.
Migration safety — don't delete legacy until the last caller is gone
A half-migrated codebase with a deleted legacy class produces fatal errors on the paths you forgot to update. Two safer patterns let you ship incrementally without breaking anything.
Shim during transition
Leave the legacy class in place, but rewrite its body to delegate to the new Contract. Callers keep working unchanged; new code uses the Contract directly.
class LegacyOrderService
{
/** @deprecated Use OrderContract::createOrder instead */
public function createOrder(array $data): Order
{
return app(OrderContract::class)->createOrder(CreateOrderData::fromArray($data));
}
}
Deprecation warning
Add @deprecated PHPDoc so static analysis and IDEs flag callers, making the migration list discoverable. Some teams trigger a soft warning the first time the legacy method is called in a request — this surfaces unmigrated callers in the logs.
grep -r LegacyOrderService returns zero results. A deleted class with even one surviving caller is a 500 error waiting for the right request.What to read next
- DDD overview — the layout you're migrating into.
- Services and contracts — the target shape of step 4.
- Actions — the operation shape you're extracting to.
- Eloquent and queries — where Filter traits and scopes live.
- Domain glossary — the existing bounded contexts you can match against in step 1.