DEV Community

Cover image for Multi-Tenant Routing in Hexagonal PHP
Gabriel Anhaia
Gabriel Anhaia

Posted on

Multi-Tenant Routing in Hexagonal PHP


You know the bug before you read the postmortem. Acme Corp opens a ticket: their dashboard is showing rows for Globex. The Globex account manager opens the same screen and sees Acme's invoices. Somebody on the team forgot a where tenant_id = ? clause in one repository method. It shipped. It passed code review. A team gets paged late at night, on a call with two customers explaining what happened.

That bug is a class. Not a typo. What happens when the tenant identifier gets treated as a parameter you remember to pass, instead of a property of the seam between the application and its data.

Hexagonal architecture gives you somewhere to put the fix. TenantId is a domain concept. Every aggregate belongs to exactly one tenant, and the business rule "no read or write ever crosses tenants" is a domain invariant. The mechanics of how you enforce it (separate schemas, a row filter, separate connection pools) belong outside the domain. Once the seam is right, the type system and the adapter refuse the bad query before it runs.

What belongs in the domain

The domain owns one thing about multi-tenancy: the fact that a TenantId exists, and the fact that every aggregate root carries one. The domain knows nothing about how you route queries, which connection a tenant lives on, or whether you use schema-per-tenant or shared-tables-with-filter.

<?php
declare(strict_types=1);

namespace App\Domain\Tenant;

final readonly class TenantId
{
    public function __construct(public string $value)
    {
        if (!preg_match('/^[a-z0-9-]{3,64}$/', $value)) {
            throw new \InvalidArgumentException(
                "invalid tenant id: {$value}"
            );
        }
    }

    public function equals(TenantId $other): bool
    {
        return $this->value === $other->value;
    }
}
Enter fullscreen mode Exit fullscreen mode

That's the whole domain side of multi-tenancy. A value object. No service locator, no static call to fetch "the current tenant", no Eloquent global scope.

An Order aggregate carries one:

<?php
declare(strict_types=1);

namespace App\Domain\Order;

use App\Domain\Tenant\TenantId;

final readonly class Order
{
    public function __construct(
        public OrderId $id,
        public TenantId $tenantId,
        public CustomerId $customerId,
        public int $totalCents,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

The constructor forces TenantId. There is no way to build an Order that doesn't know which tenant it belongs to. A developer cannot accidentally persist a tenantless order, because the type won't let them.

What belongs outside

The interesting part is the adapter. There are three common shapes for tenant-aware storage in PHP. The hexagonal answer is the same for all three: pick one, hide it behind a port, the domain doesn't care.

Strategy What it does When to reach for it
Schema-per-tenant Each tenant has its own tenant_acme.orders table Strong isolation requirements, ≤ a few hundred tenants, comfort with migrations across many schemas
Row filter Shared orders table, every query carries WHERE tenant_id = ? Many small tenants, you want one place to operate, the row count per table is fine
Connection-pool routing Each tenant maps to a physical DB host Compliance or noisy-neighbor isolation, willing to operate N databases

The repository port is identical for all three. It does not mention TenantId because the adapter already knows it.

<?php
declare(strict_types=1);

namespace App\Domain\Order;

interface OrderRepository
{
    public function save(Order $order): void;
    public function findById(OrderId $id): ?Order;
    public function listForCustomer(CustomerId $id): array;
}
Enter fullscreen mode Exit fullscreen mode

The domain reads as if there were one tenant. The adapter does the routing.

The seam: a tenant-aware connection

The piece that turns "I remembered to filter by tenant" into "I cannot forget to filter by tenant" is a connection wrapper that closes over the current TenantId and refuses to issue a query that doesn't honor it. Every adapter goes through this wrapper. There is no other path to the database from inside the application.

<?php
declare(strict_types=1);

namespace App\Infrastructure\Persistence;

use App\Domain\Tenant\TenantId;
use PDO;
use PDOStatement;

final class TenantAwareConnection
{
    public function __construct(
        private readonly PDO $pdo,
        private readonly TenantId $tenantId,
    ) {}

    public function tenantId(): TenantId
    {
        return $this->tenantId;
    }

    public function prepare(string $sql): PDOStatement
    {
        $this->assertTenantClause($sql);
        return $this->pdo->prepare($sql);
    }

    public function execute(string $sql, array $params): PDOStatement
    {
        $this->assertTenantClause($sql);
        $params['tenant_id'] = $this->tenantId->value;
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($params);
        return $stmt;
    }

    private function assertTenantClause(string $sql): void
    {
        $normalized = strtolower($sql);
        $isWrite = str_starts_with(ltrim($normalized), 'insert')
            || str_starts_with(ltrim($normalized), 'update')
            || str_starts_with(ltrim($normalized), 'delete');

        $hasTenant = str_contains($normalized, ':tenant_id')
            || str_contains($normalized, 'tenant_id =');

        if (!$hasTenant) {
            throw new \LogicException(
                'query rejected: no tenant_id binding. '
                . 'every query through TenantAwareConnection '
                . 'must reference :tenant_id'
            );
        }

        if ($isWrite && !str_contains($normalized, ':tenant_id')) {
            throw new \LogicException(
                'write rejected: tenant_id must be bound, not literal'
            );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The wrapper carries the tenant, rejects any SQL that doesn't bind :tenant_id, and auto-binds the parameter so the repository can't pass the wrong one. A repository method that forgets the filter throws on the prepare call, and the first time you run the test in development, it throws. There is no path to production for that bug.

The string check is a guardrail. The real guarantee is the adapter underneath this wrapper, the row filter or schema selector, which never sees a query without the tenant. The string check is what makes the omission loud in dev so it never reaches the row-filter layer in the first place.

Tenant-aware connection wrapping every query

The middleware: where TenantId enters the world

TenantId doesn't appear from thin air. It comes from the request — a subdomain, a header, a JWT claim, a session. That extraction is an inbound adapter concern. The middleware reads it, validates it, and pushes it into a request-scoped container before any controller or use case runs.

<?php
declare(strict_types=1);

namespace App\Infrastructure\Http;

use App\Domain\Tenant\TenantId;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

final class TenantContextMiddleware implements MiddlewareInterface
{
    public function __construct(
        private readonly TenantResolver $resolver,
        private readonly TenantContext $context,
    ) {}

    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler,
    ): ResponseInterface {
        $tenantId = $this->resolver->resolve($request);

        if ($tenantId === null) {
            return new JsonResponse(
                ['error' => 'tenant not resolved'],
                401,
            );
        }

        $this->context->bind($tenantId);

        try {
            return $handler->handle(
                $request->withAttribute('tenant_id', $tenantId),
            );
        } finally {
            $this->context->clear();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The TenantContext is request-scoped: one instance per HTTP request, bound at the start and cleared at the end. In Symfony you register it as a request-scoped service and inject; in Laravel you bind it as a scoped singleton; in a long-running worker (Roadrunner, FrankenPHP, Swoole) you reset it on each job.

<?php
declare(strict_types=1);

namespace App\Infrastructure\Tenant;

use App\Domain\Tenant\TenantId;

final class TenantContext
{
    private ?TenantId $current = null;

    public function bind(TenantId $tenantId): void
    {
        $this->current = $tenantId;
    }

    public function current(): TenantId
    {
        if ($this->current === null) {
            throw new \LogicException(
                'no tenant bound — every entry point '
                . 'must run TenantContextMiddleware first'
            );
        }
        return $this->current;
    }

    public function clear(): void
    {
        $this->current = null;
    }
}
Enter fullscreen mode Exit fullscreen mode

The factory for TenantAwareConnection resolves the TenantId from this context at injection time:

<?php
declare(strict_types=1);

namespace App\Infrastructure\Persistence;

use App\Infrastructure\Tenant\TenantContext;
use PDO;

final class TenantAwareConnectionFactory
{
    public function __construct(
        private readonly PDO $pdo,
        private readonly TenantContext $context,
    ) {}

    public function forCurrentTenant(): TenantAwareConnection
    {
        return new TenantAwareConnection(
            $this->pdo,
            $this->context->current(),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

If a worker, console command, or scheduled job calls into a use case without first binding a TenantId, the factory throws on construction. Every entry point has to declare which tenant it is operating as.

The repository: tenant-scoped by construction

The repository receives a TenantAwareConnection that is already scoped to one tenant. It never reads from TenantContext. It never accepts a TenantId parameter. Its only API is "operate on this tenant's data, whichever tenant that is".

<?php
declare(strict_types=1);

namespace App\Infrastructure\Persistence;

use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Order\OrderRepository;
use App\Domain\Order\CustomerId;
use App\Domain\Tenant\TenantId;

final class PdoOrderRepository implements OrderRepository
{
    public function __construct(
        private readonly TenantAwareConnection $conn,
    ) {}

    public function save(Order $order): void
    {
        $this->assertSameTenant($order->tenantId);

        $this->conn->execute(
            'insert into orders (id, tenant_id, customer_id, total_cents)
             values (:id, :tenant_id, :customer_id, :total_cents)
             on conflict (id) do update
                set total_cents = excluded.total_cents
                where orders.tenant_id = :tenant_id',
            [
                'id' => $order->id->value,
                'customer_id' => $order->customerId->value,
                'total_cents' => $order->totalCents,
            ],
        );
    }

    public function findById(OrderId $id): ?Order
    {
        $stmt = $this->conn->execute(
            'select id, tenant_id, customer_id, total_cents
             from orders
             where id = :id and tenant_id = :tenant_id',
            ['id' => $id->value],
        );

        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
        return $row ? $this->hydrate($row) : null;
    }

    public function listForCustomer(CustomerId $id): array
    {
        $stmt = $this->conn->execute(
            'select id, tenant_id, customer_id, total_cents
             from orders
             where customer_id = :customer_id
               and tenant_id = :tenant_id
             order by id',
            ['customer_id' => $id->value],
        );

        return array_map(
            $this->hydrate(...),
            $stmt->fetchAll(\PDO::FETCH_ASSOC),
        );
    }

    private function assertSameTenant(TenantId $orderTenant): void
    {
        if (!$orderTenant->equals($this->conn->tenantId())) {
            throw new CrossTenantWriteException(
                "order belongs to {$orderTenant->value}, "
                . "connection is scoped to "
                . "{$this->conn->tenantId()->value}",
            );
        }
    }

    private function hydrate(array $row): Order
    {
        return new Order(
            new OrderId($row['id']),
            new TenantId($row['tenant_id']),
            new CustomerId($row['customer_id']),
            (int) $row['total_cents'],
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

The upsert's where orders.tenant_id = :tenant_id clause and the assertSameTenant check both block the same bug from two angles. The upsert can't overwrite a row belonging to another tenant even if a malicious or buggy caller passes a duplicate ID. The assertSameTenant check rejects an Order whose tenantId doesn't match the connection's tenantId. That catches the "use case loaded an order, mutated it, then tried to save it through a different tenant's connection" bug.

Request flow with tenant routing

The test that proves the seam holds

A code review can miss a cross-tenant read. A test cannot. The test you want is the one that tries to do the wrong thing and asserts that it fails.

<?php
declare(strict_types=1);

namespace Tests\Integration\Persistence;

use App\Domain\Order\CustomerId;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Tenant\TenantId;
use App\Infrastructure\Persistence\CrossTenantWriteException;
use App\Infrastructure\Persistence\PdoOrderRepository;
use App\Infrastructure\Persistence\TenantAwareConnection;
use PDO;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

final class CrossTenantIsolationTest extends TestCase
{
    private PDO $pdo;

    protected function setUp(): void
    {
        $this->pdo = new PDO('sqlite::memory:');
        $this->pdo->setAttribute(
            PDO::ATTR_ERRMODE,
            PDO::ERRMODE_EXCEPTION,
        );
        $this->pdo->exec(
            'create table orders (
                id text not null,
                tenant_id text not null,
                customer_id text not null,
                total_cents integer not null,
                primary key (id, tenant_id)
            )',
        );
    }

    #[Test]
    public function read_for_one_tenant_does_not_see_another_tenants_row(): void
    {
        $acme = new TenantId('acme');
        $globex = new TenantId('globex');

        $acmeRepo = new PdoOrderRepository(
            new TenantAwareConnection($this->pdo, $acme),
        );
        $globexRepo = new PdoOrderRepository(
            new TenantAwareConnection($this->pdo, $globex),
        );

        $acmeRepo->save(new Order(
            new OrderId('order-1'),
            $acme,
            new CustomerId('cust-1'),
            5000,
        ));

        self::assertNull(
            $globexRepo->findById(new OrderId('order-1')),
            'globex must not see acme\'s order',
        );
        self::assertNotNull(
            $acmeRepo->findById(new OrderId('order-1')),
            'acme must still see its own order',
        );
    }

    #[Test]
    public function write_with_mismatched_tenant_id_is_rejected(): void
    {
        $acme = new TenantId('acme');
        $globex = new TenantId('globex');

        $globexRepo = new PdoOrderRepository(
            new TenantAwareConnection($this->pdo, $globex),
        );

        $orderForAcme = new Order(
            new OrderId('order-2'),
            $acme,
            new CustomerId('cust-1'),
            5000,
        );

        $this->expectException(CrossTenantWriteException::class);
        $globexRepo->save($orderForAcme);
    }
}
Enter fullscreen mode Exit fullscreen mode

The first test would have caught the original Acme/Globex bug. The second test catches the more subtle one: the use case that loaded an aggregate under one tenant, held onto it through a service call, and tried to save it under another. Both run in milliseconds against in-memory SQLite. Both belong in CI as gates, not as advisories.

Add a third test that pokes the wrapper directly: pass it a query with no tenant_id and assert the LogicException. That is the regression test for the developer who, six months from now, adds a "quick fix" repository method and forgets the filter.

Where the strategies diverge

The repository above does row filtering. The seam is portable to the other two strategies without touching the domain or the repository.

For schema-per-tenant, TenantAwareConnection doesn't auto-bind tenant_id. Instead, on construction, it issues set search_path = tenant_acme, public (Postgres) or use acme (MySQL). The repository writes plain SQL with no WHERE tenant_id = ?. The isolation is enforced by the schema selection. The factory still resolves the tenant from TenantContext. The middleware doesn't change at all.

For connection-pool routing, the factory looks up the tenant's physical connection from a routing table (tenants.connection_url) and returns a TenantAwareConnection wrapping the right PDO. The repository is unchanged. The seam moves from query rewriting to connection selection — but TenantContext, TenantContextMiddleware, and the repository code stay identical.

This is the payoff of putting TenantId in the domain and the strategy in the adapter. You can migrate a noisy tenant onto its own host without touching a line of business code.

What you keep

The seam is four pieces:

  • TenantId as a domain value object, required on every aggregate that is tenant-scoped.
  • TenantContext as a request-scoped object, bound by middleware at the edge.
  • TenantAwareConnection as the only path from application code to the database, scoped to one tenant for its lifetime.
  • A repository that closes over the connection, asserts tenant match on writes, and is impossible to use cross-tenant.

The two tests — read isolation and write rejection — go in CI and stay there. Add a third for any new tenant-scoped aggregate.

What you do not keep is the habit of asking "did this query filter by tenant?" in code review. The wiring asks for you. Add the wrapper to the next repository you touch.


If this was useful

This is one chapter's worth of the seam patterns in Decoupled PHP — the book walks the same shape across HTTP, queues, external APIs, and the migration path from a framework-coupled service to a hexagonal one, with a runnable examples repo. The companion System Design Pocket Guide: Fundamentals covers the broader isolation question: schema-per-tenant vs row-filter vs database-per-tenant, with the trade-offs that drive the pick.

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)