Key Concepts

Refactoring Legacy

A five-step playbook for migrating legacy `app/` code into the DDD layout — when it's worth doing, how to keep callers working during the move, and what to leave alone.

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.

Don't open a PR titled "refactor X into DDD" with no other purpose. Drive-by rewrites that nobody asked for fight every reviewer for time and merge against every other change in the queue. Refactor as a side-effect of feature or fix work, not as the work itself.

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 glossaryOrder, 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.

Only delete the legacy class once grep -r LegacyOrderService returns zero results. A deleted class with even one surviving caller is a 500 error waiting for the right request.