- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
A query lands on the slow-query log. SELECT * FROM users WHERE id = ?, run forty thousand times an hour, every one of them returning the same row. The fix is obvious: cache the user. The next pull request is where it goes wrong.
Someone adds a Cache::remember() call inside UserRepository::findById. A week later there is a second cache call inside the use case, because the repository one missed an edge case. Then a controller checks the cache before calling the use case at all, to skip a round trip. Now the cache lives in three layers, the invalidation lives in zero of them, and a support ticket arrives: a user changed their email and the old one keeps coming back for the next hour.
Caching is not a feature of the domain. A user does not know it is cached. The use case that loads a user does not care whether the row came from MySQL, from Redis, or from a sticky note. Caching is an infrastructure concern, and in a hexagonal layout there is one honest place for it: a decorator that wraps the real repository behind the same port.
The port stays exactly as it was
The use case depends on an interface. The interface speaks domain language. It says nothing about storage, and it will say nothing about caching either.
<?php
declare(strict_types=1);
namespace App\Application\Port;
use App\Domain\User\User;
use App\Domain\User\UserId;
interface UserRepository
{
public function findById(UserId $id): ?User;
public function save(User $user): void;
}
Two methods. A read and a write. The use case calls findById, gets a User or null, and moves on. It has no idea how many adapters sit behind this interface, and that is the whole point. Anything you add later has to fit through these two method signatures or it does not belong here.
The real adapter, unchanged
This is the adapter that talks to the database. It already exists. You are not editing it.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Persistence\Doctrine;
use App\Application\Port\UserRepository;
use App\Domain\User\User;
use App\Domain\User\UserId;
use Doctrine\ORM\EntityManagerInterface;
final readonly class DoctrineUserRepository
implements UserRepository
{
public function __construct(
private EntityManagerInterface $em,
private UserRecordMapper $mapper,
) {}
public function findById(UserId $id): ?User
{
$record = $this->em->find(
UserRecord::class,
$id->value,
);
return $record === null
? null
: $this->mapper->toDomain($record);
}
public function save(User $user): void
{
$this->em->persist(
$this->mapper->toRecord($user),
);
}
}
No cache call here. No if (cache hit). This adapter has one job: move data between the domain and Doctrine. When you read this file in a year, the database story is all of it. Nothing else is hiding in here.
The caching adapter wraps the real one
Here is the decorator. It implements the same UserRepository interface, holds a reference to the inner repository and a cache client, and adds caching around the calls it delegates.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Persistence\Cache;
use App\Application\Port\UserRepository;
use App\Domain\User\User;
use App\Domain\User\UserId;
use Psr\SimpleCache\CacheInterface;
final readonly class CachingUserRepository
implements UserRepository
{
public function __construct(
private UserRepository $inner,
private CacheInterface $cache,
private UserCacheSerializer $serializer,
private int $ttlSeconds = 300,
) {}
public function findById(UserId $id): ?User
{
$key = $this->keyFor($id);
$cached = $this->cache->get($key);
if ($cached !== null) {
return $this->serializer->fromCache($cached);
}
$user = $this->inner->findById($id);
if ($user !== null) {
$this->cache->set(
$key,
$this->serializer->toCache($user),
$this->ttlSeconds,
);
}
return $user;
}
public function save(User $user): void
{
$this->inner->save($user);
$this->cache->delete(
$this->keyFor($user->id()),
);
}
private function keyFor(UserId $id): string
{
return 'user:' . $id->value;
}
}
The $inner property is typed as UserRepository, the interface, not as DoctrineUserRepository, the concrete class. The decorator does not know or care what it wraps. Wrap the Doctrine adapter in production, wrap an in-memory fake in a test, wrap another decorator that adds logging. As long as it satisfies the port, the caching layer is happy.
The serializer turns a User into a cacheable string and back. Keep it out of the cache class so the storage format is one swappable piece. A user changes their email, and the old one keeps coming back for the next hour. That bug is what the next section closes.
Invalidation lives on the write path
The email-keeps-coming-back ticket has one cause: a write that did not tell the cache. The fix is structural, not a sprinkle of cache->delete calls across the codebase. Every write to a User goes through UserRepository::save. There is exactly one save that touches the cache, and it is the decorator's.
Look again at that save:
public function save(User $user): void
{
$this->inner->save($user);
$this->cache->delete(
$this->keyFor($user->id()),
);
}
Write to the database first, then drop the cache key. Order matters. If you delete the key first and the database write throws, the next read repopulates the cache from the old row and you are back where you started. Write, then invalidate. A reader that hits between the two steps gets a freshly written row from the inner repository and warms the cache with the correct value.
Deleting the key beats overwriting it with the new value. A delete is one operation and it cannot go stale. Writing the new value into the cache means the cache write and the database write can disagree under concurrency, and now you own a consistency bug that only shows up under load. Delete, and let the next read pay the one-time miss.
There is no TTL on correctness here. The 300-second TTL is a backstop for keys that get written by something outside this decorator: a raw SQL migration, a replication lag fix, a manual UPDATE during an incident. The TTL bounds how long that staleness can live. The save invalidation handles the normal path; the TTL handles the path you forgot about.
Wiring it up in the composition root
The decorator gets assembled in one place, the container. This is the only file that knows caching exists at all.
$c->set(
DoctrineUserRepository::class,
fn(C $c) => new DoctrineUserRepository(
$c->get(EntityManagerInterface::class),
$c->get(UserRecordMapper::class),
),
);
$c->set(
UserRepository::class,
fn(C $c) => new CachingUserRepository(
$c->get(DoctrineUserRepository::class),
$c->get(CacheInterface::class),
$c->get(UserCacheSerializer::class),
ttlSeconds: 300,
),
);
The use case asks the container for a UserRepository and gets the caching adapter, which wraps the Doctrine adapter. The use case never named either concrete class. To turn caching off for a debugging session, bind UserRepository straight to DoctrineUserRepository and delete nothing. The seam is the wiring, not the code.
What you did not change
Walk back through the layers. The domain User has no cache annotations and no awareness that it is ever stored, let alone cached. The UserRepository port has the same two methods it always had. The use case reads identically before and after; it calls findById and save against an interface. The Doctrine adapter is the file it was last week.
The cache concern landed in one new class, in the Infrastructure namespace, behind the existing port. That is the test for whether a concern is in the right place: adding it should touch the layer that owns it and no other. Caching touched infrastructure and the container. It did not reach into the domain, the application layer, or the existing adapter.
Testing the decorator in isolation
Because the decorator depends on the interface and a PSR-16 cache, you test it with fakes and never start a database.
public function test_second_read_hits_cache(): void
{
$inner = new SpyUserRepository([
new User(new UserId('u-1'), 'ada@example.com'),
]);
$cache = new ArrayCache();
$repo = new CachingUserRepository(
$inner,
$cache,
new UserCacheSerializer(),
);
$repo->findById(new UserId('u-1'));
$repo->findById(new UserId('u-1'));
self::assertSame(1, $inner->findByIdCalls());
}
public function test_save_invalidates_the_key(): void
{
$user = new User(new UserId('u-1'), 'ada@example.com');
$inner = new SpyUserRepository([$user]);
$cache = new ArrayCache();
$repo = new CachingUserRepository(
$inner,
$cache,
new UserCacheSerializer(),
);
$repo->findById(new UserId('u-1'));
$repo->save($user);
$repo->findById(new UserId('u-1'));
self::assertSame(2, $inner->findByIdCalls());
}
The first test proves the second read never reaches the inner repository. The second proves a write forces the following read back to the source. Both run in milliseconds with no infrastructure. The caching behaviour is now a thing you can assert, not a thing you hope is working in production.
The decorator pattern scales past caching. Wrap the same port to add request-scoped memoization, metrics, retry-on-deadlock, or a feature flag that swaps backends. Each one is a small class that implements the port, delegates to an inner instance, and adds its single concern. Stack them in the container in the order you want them to run.
The framework taught you to put the cache call next to the query. The hexagonal habit is to put it next to nothing — in its own adapter, behind the port the use case already trusts. Decoupled PHP walks this seam and the others around it, from the first port to the migration playbook for apps that have outlived two framework upgrades. If the decorator above felt like a tool you want more of, the book is the long version of the same idea.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)