DEV Community

Cover image for The Anti-Corruption Layer: Integrating Legacy PHP Without Catching Its Diseases
Gabriel Anhaia
Gabriel Anhaia

Posted on

The Anti-Corruption Layer: Integrating Legacy PHP Without Catching Its Diseases


You've inherited the situation. A new service is being carved out of a Laravel monolith that has been running since PHP 5.4. The new code is clean: PSR-12, strict types, value objects, ports and adapters, a domain that imports nothing framework-flavored. Then your tech lead drops the requirement at standup: the new module has to talk to LegacyCustomerManager, a static class from 2013 that returns associative arrays with keys like cst_dt_brth and statuses like 'A', 'P', 'X'.

You have two choices. You can let the old shape walk into your new domain. $customer['cst_dt_brth'] becomes a method, 'A'/'P'/'X' becomes a string constant, and the legacy gravity wins. Or you can put a wall between the two codebases and translate at the boundary.

That wall has a name. Eric Evans called it the Anti-Corruption Layer in Domain-Driven Design (2003), and it is the one DDD pattern that earns its keep even in services that otherwise skip aggregates and ubiquitous language.

What the ACL actually defends against

The Anti-Corruption Layer is one rule:

When two models meet, neither speaks the other's language. A translator in the middle owns the conversion in both directions.

That is the whole pattern. The "anti-corruption" part is the promise that your clean model does not learn the bad habits of the model on the other side: its naming, its nullability rules, its silent contract violations.

What gets corrupted without an ACL:

  • Field names. cst_dt_brth ends up in your Customer because someone passed the array straight through.
  • Types. A legacy "date" that is sometimes '2014-03-01', sometimes '01/03/2014', and sometimes 0, becomes string|int in your domain.
  • Status semantics. 'A' (active), 'P' (pending), 'X' (deactivated-but-still-billable) leak as magic strings into every conditional in your codebase.
  • Failure modes. The legacy call returns null on "not found", false on "permission denied", and an array with error => 1 on "database down". Your code learns to check all three.
  • Side effects. LegacyCustomerManager::get($id) updates a last-accessed timestamp. Your tests start writing to the legacy DB.

Six months in, the new module is no longer new. It is the legacy module wearing a Composer namespace.

Without an ACL the legacy model leaks into the clean domain; with an ACL the translator absorbs the impedance.

The legacy module, in all its glory

Here is the kind of thing the ACL is built to hide. A real static class, frozen in 2013, that the rest of the company still depends on:

<?php

class LegacyCustomerManager
{
    public static function get($id)
    {
        $row = DB::query(
            "SELECT * FROM tb_customer WHERE id_cst = $id"
        );
        if (!$row) {
            return null;
        }
        return $row[0];
    }

    public static function getByEmail($email)
    {
        $rows = DB::query(
            "SELECT * FROM tb_customer
             WHERE cst_email = '" . addslashes($email) . "'"
        );
        if (count($rows) === 0) {
            return false;
        }
        return $rows[0];
    }

    public static function save($data)
    {
        if (!isset($data['cst_status'])) {
            $data['cst_status'] = 'P';
        }
        DB::insertOrUpdate('tb_customer', $data);
        return ['ok' => 1, 'id' => $data['id_cst'] ?? null];
    }
}
Enter fullscreen mode Exit fullscreen mode

The data shape it returns:

[
    'id_cst'      => 4711,
    'cst_email'   => 'jo@example.com',
    'cst_nm'      => 'Jo Example',
    'cst_dt_brth' => '1989-04-12',
    'cst_status'  => 'A',
    'cst_country' => 'DE',
    'cst_ts_crt'  => '2014-03-01 11:43:09',
]
Enter fullscreen mode Exit fullscreen mode

Eight columns, four naming conventions, and one status code that has been the source of more billing incidents than anyone wants to count. You are not allowed to change it. The product team has tried. The DBA blocked it twice.

This is the model you have to integrate with. Without an ACL, every consumer in the new module learns this shape. With an ACL, exactly one file does.

The clean domain, designed for the business

Before drafting the translator, write what the new module actually wants to work with. The shape comes from your domain, not from the legacy table:

<?php
declare(strict_types=1);

namespace App\Domain\Customer;

use DateTimeImmutable;

enum CustomerStatus: string
{
    case Active = 'active';
    case Pending = 'pending';
    case Deactivated = 'deactivated';
}

final readonly class Customer
{
    public function __construct(
        public CustomerId $id,
        public EmailAddress $email,
        public string $name,
        public ?DateTimeImmutable $dateOfBirth,
        public CustomerStatus $status,
        public CountryCode $country,
        public DateTimeImmutable $createdAt,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

Four value objects (CustomerId, EmailAddress, CountryCode, plus the enum), one entity, one constructor that refuses to build a half-valid object. No 'A'/'P'/'X'. No cst_dt_brth. No false lurking in return types.

The port the domain consumes is also written from the business's point of view, not the database's:

<?php
declare(strict_types=1);

namespace App\Domain\Customer;

interface CustomerRepository
{
    public function findById(CustomerId $id): ?Customer;

    public function findByEmail(EmailAddress $email): ?Customer;

    public function save(Customer $customer): void;
}
Enter fullscreen mode Exit fullscreen mode

Two finders that return ?Customer: no false return, no associative arrays. One save that takes a fully-built Customer. The domain does not know there is a legacy module behind this interface. As far as it is concerned, the implementation might be Doctrine, a JSON file, or an in-memory map.

The ACL: a translator and an adapter

The Anti-Corruption Layer in PHP is usually two collaborating classes inside the same adapter package:

  1. A translator that converts between legacy arrays and domain objects, both directions.
  2. An adapter that implements the port, calls the legacy module, and delegates every conversion to the translator.

The translator goes first because it is the heart of the pattern:

<?php
declare(strict_types=1);

namespace App\Adapter\LegacyCustomer;

use App\Domain\Customer\Customer;
use App\Domain\Customer\CustomerId;
use App\Domain\Customer\CustomerStatus;
use App\Domain\Customer\EmailAddress;
use App\Domain\Customer\CountryCode;
use DateTimeImmutable;

final class LegacyCustomerTranslator
{
    public function toDomain(array $row): Customer
    {
        return new Customer(
            id: new CustomerId((int) $row['id_cst']),
            email: new EmailAddress((string) $row['cst_email']),
            name: (string) $row['cst_nm'],
            dateOfBirth: $this->parseBirth($row['cst_dt_brth'] ?? null),
            status: $this->parseStatus((string) $row['cst_status']),
            country: new CountryCode((string) $row['cst_country']),
            createdAt: new DateTimeImmutable(
                (string) $row['cst_ts_crt']
            ),
        );
    }

    public function toLegacy(Customer $c): array
    {
        return [
            'id_cst'      => $c->id->value,
            'cst_email'   => $c->email->value,
            'cst_nm'      => $c->name,
            'cst_dt_brth' => $c->dateOfBirth?->format('Y-m-d'),
            'cst_status'  => match ($c->status) {
                CustomerStatus::Active      => 'A',
                CustomerStatus::Pending     => 'P',
                CustomerStatus::Deactivated => 'X',
            },
            'cst_country' => $c->country->value,
            'cst_ts_crt'  => $c->createdAt->format('Y-m-d H:i:s'),
        ];
    }

    private function parseStatus(string $raw): CustomerStatus
    {
        return match ($raw) {
            'A' => CustomerStatus::Active,
            'P' => CustomerStatus::Pending,
            'X' => CustomerStatus::Deactivated,
            default => throw new UnknownLegacyStatus($raw),
        };
    }

    private function parseBirth(mixed $raw): ?DateTimeImmutable
    {
        if ($raw === null || $raw === '' || $raw === 0) {
            return null;
        }
        $formats = ['Y-m-d', 'd/m/Y', 'm/d/Y'];
        foreach ($formats as $fmt) {
            $dt = DateTimeImmutable::createFromFormat($fmt, (string) $raw);
            if ($dt !== false) {
                return $dt;
            }
        }
        throw new InvalidLegacyDate((string) $raw);
    }
}
Enter fullscreen mode Exit fullscreen mode

Every quirk of the old system lives in this file: the three date formats, the single-letter statuses, the mixed-type "empty" representation that can be null, '', or 0. A new consumer reading the domain object never has to know any of it existed.

UnknownLegacyStatus, InvalidLegacyDate, LegacySaveFailed, and CustomerNotFound are simple \RuntimeException subclasses declared in the same namespace (one-liner each: final class UnknownLegacyStatus extends \RuntimeException {}).

The adapter then wraps LegacyCustomerManager and uses the translator on every call:

<?php
declare(strict_types=1);

namespace App\Adapter\LegacyCustomer;

use App\Domain\Customer\Customer;
use App\Domain\Customer\CustomerId;
use App\Domain\Customer\CustomerRepository;
use App\Domain\Customer\EmailAddress;
use LegacyCustomerManager;

final class LegacyCustomerRepository implements CustomerRepository
{
    public function __construct(
        private LegacyCustomerTranslator $translator,
    ) {}

    public function findById(CustomerId $id): ?Customer
    {
        $row = LegacyCustomerManager::get($id->value);
        if ($row === null) {
            return null;
        }
        return $this->translator->toDomain($row);
    }

    public function findByEmail(EmailAddress $email): ?Customer
    {
        $row = LegacyCustomerManager::getByEmail($email->value);
        if ($row === false || $row === null) {
            return null;
        }
        return $this->translator->toDomain($row);
    }

    public function save(Customer $customer): void
    {
        $payload = $this->translator->toLegacy($customer);
        $result = LegacyCustomerManager::save($payload);
        if (($result['ok'] ?? 0) !== 1) {
            throw new LegacySaveFailed(
                "save returned: " . json_encode($result)
            );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Two things to notice. First, the adapter normalises the three different "not found" return shapes (null, false, missing) into one: a domain ?Customer. Second, the 'ok' => 1 envelope dies here. Code outside this file never sees it.

That is the entire ACL. One translator, one adapter, one package. Inside it is dirty. Outside it is clean.

The ACL sits between the new module and the legacy system, translating both ways and absorbing every quirk.

What the rest of the codebase looks like

The use case the rest of the team writes is unaware of any of this:

<?php
declare(strict_types=1);

namespace App\UseCase;

use App\Domain\Customer\CustomerRepository;
use App\Domain\Customer\EmailAddress;

final class DeactivateCustomerByEmail
{
    public function __construct(
        private CustomerRepository $customers,
    ) {}

    public function execute(EmailAddress $email): void
    {
        $customer = $this->customers->findByEmail($email);
        if ($customer === null) {
            throw new CustomerNotFound($email);
        }

        $deactivated = $customer->deactivate();
        $this->customers->save($deactivated);
    }
}
Enter fullscreen mode Exit fullscreen mode

Nothing in this file knows the legacy module exists. Swap LegacyCustomerRepository for a DoctrineCustomerRepository on migration day and the use case keeps compiling.

Testing the ACL is where the value compounds

The translator is a pure function, both directions. That means contract tests are trivial:

<?php
declare(strict_types=1);

use App\Adapter\LegacyCustomer\LegacyCustomerTranslator;
use App\Domain\Customer\CustomerStatus;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;

final class LegacyCustomerTranslatorTest extends TestCase
{
    #[Test]
    public function it_maps_status_codes(): void
    {
        $t = new LegacyCustomerTranslator();
        $row = $this->validRow();

        $row['cst_status'] = 'A';
        $this->assertSame(
            CustomerStatus::Active,
            $t->toDomain($row)->status,
        );

        $row['cst_status'] = 'X';
        $this->assertSame(
            CustomerStatus::Deactivated,
            $t->toDomain($row)->status,
        );
    }

    #[Test]
    public function it_round_trips_through_legacy_shape(): void
    {
        $t = new LegacyCustomerTranslator();
        $domain = $t->toDomain($this->validRow());
        $back = $t->toLegacy($domain);

        $this->assertSame('A', $back['cst_status']);
        $this->assertSame('jo@example.com', $back['cst_email']);
    }

    private function validRow(): array
    {
        return [
            'id_cst' => 4711,
            'cst_email' => 'jo@example.com',
            'cst_nm' => 'Jo Example',
            'cst_dt_brth' => '1989-04-12',
            'cst_status' => 'A',
            'cst_country' => 'DE',
            'cst_ts_crt' => '2014-03-01 11:43:09',
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Every quirk gets one named test. When the legacy module adds a fourth date format three years from now, you find out at the translator boundary. The checkout form never sees it.

Where teams get the ACL wrong

Four anti-patterns show up over and over.

Half an ACL. The translator converts only on the way in, and your save call hands toArray() on a domain object straight to the legacy module. The legacy shape leaks back through the write path. The ACL has to translate in both directions or the asymmetry will bite you the first time a status code changes.

An ACL that knows about your domain. The translator file imports from a Laravel facade, a Symfony service, or any framework class. It should depend on nothing outside its own package and the domain types it builds. Anything else is a leak going the other way: the new world contaminating its own boundary.

An ACL that does business logic. "While we are translating, let's also enrich the customer with their last order total." No. The translator translates. Enrichment belongs in a use case. The moment translation does business work, you have built a second domain.

One translator for many legacy modules. A LegacyTranslator god class that knows about customers, orders, payments, and invoices is a maintenance trap. One translator per legacy contract, named for the contract. They share value objects, not translation logic.

When you can skip the ACL

You do not always need one. Skip it when:

  • The other side already speaks a clean contract — an OpenAPI service with versioned schemas, a well-typed gRPC stub, a library you trust to evolve carefully.
  • The integration is read-only and the data is throwaway (a one-shot import script).
  • You are inside the same bounded context and the "other model" is just a different file in the same domain.

Reach for the ACL when:

  • The other side is older than your test suite.
  • The shape of its data is decided by the database, not the business.
  • You expect the integration to outlive both teams currently maintaining it.
  • The other side is owned by another team and you cannot make them change.

The asymmetry of effort matters. Writing the translator costs you a day. Pulling cst_status out of a hundred call sites two years from now costs a quarter. Start with the translator: copy a real row out of the legacy table, write toDomain and toLegacy, and run a round-trip test against ten production samples before you write a single line of the adapter.


If this was useful

The ACL chapter is one slice of a longer argument about keeping PHP applications honest as they age. Decoupled PHP walks through the same hexagonal layout end to end — domain, ports, adapters, use cases, the strangler pattern for migrating legacy Laravel and Symfony services without a freeze week. If you have ever had to integrate with a 12-year-old module while keeping the new code clean, the migration chapters are the ones to read first.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)