DEV Community

Cover image for PSR-6 vs PSR-16: Picking a Cache Interface for Framework-Agnostic PHP
Gabriel Anhaia
Gabriel Anhaia

Posted on

PSR-6 vs PSR-16: Picking a Cache Interface for Framework-Agnostic PHP


You add caching to a service on a Friday. The obvious move is the framework's cache facade, so Cache::remember(...) lands in the use case, right next to the business rules. It works. Ship it.

Six months later you want to test that use case without a Redis container, or move one hot path to an in-process cache, or swap the vendor library after a CVE. Now the caching call is welded to a static facade, and the use case that was supposed to be pure PHP imports a framework class to look up a value it computed itself.

The fix is not "cache less." It is picking the seam. PHP has two standard cache interfaces, PSR-6 and PSR-16, plus a domain that only ever sees one small interface it defined itself. Getting those three layers right is the difference between a cache you can reason about and a cache that owns you.

PSR-16: get, set, done

PSR-16 (Psr\SimpleCache\CacheInterface) is the one most people picture when they think "cache." Flat key-value, string in, mixed out.

<?php

use Psr\SimpleCache\CacheInterface;

final readonly class ProfileReader
{
    public function __construct(private CacheInterface $cache) {}

    public function forId(int $id): Profile
    {
        $key = "profile.$id";
        $cached = $this->cache->get($key);
        if ($cached instanceof Profile) {
            return $cached;
        }

        $profile = $this->loadFromDb($id);
        $this->cache->set($key, $profile, 3600);
        return $profile;
    }
}
Enter fullscreen mode Exit fullscreen mode

The full interface is get, set, delete, clear, getMultiple, setMultiple, deleteMultiple, and has. TTL is an int of seconds, a DateInterval, or null for "cache library default." That is the whole surface.

The catch lives in get. On a miss it returns the default you passed (null if you passed nothing). So a cached null and a cache miss look identical. If your value can legitimately be null, PSR-16 forces you to either call has() first — two round trips, and a race between them — or store a sentinel. The instanceof check above sidesteps it only because a Profile is never null.

PSR-6: the Pool and the Item

PSR-6 (Psr\Cache\CacheItemPoolInterface) is the older, wordier standard. You do not read a value; you read an item that knows whether it was a hit.

<?php

use Psr\Cache\CacheItemPoolInterface;

final readonly class ProfileReader
{
    public function __construct(private CacheItemPoolInterface $pool) {}

    public function forId(int $id): Profile
    {
        $item = $this->pool->getItem("profile.$id");
        if ($item->isHit()) {
            return $item->get();
        }

        $profile = $this->loadFromDb($id);
        $item->set($profile);
        $item->expiresAfter(3600);
        $this->pool->save($item);
        return $profile;
    }
}
Enter fullscreen mode Exit fullscreen mode

isHit() is the payoff: a cached null is a hit, an absent key is not, and the two never blur. The CacheItemInterface also carries expiresAfter() and expiresAt() per item, so expiry is a property of the value rather than an argument you have to remember at every call site.

The other thing PSR-6 gives you is deferred writes. saveDeferred($item) queues the write; commit() flushes the queue in one shot. For a request that touches thirty keys, that is one network round trip instead of thirty.

$this->pool->saveDeferred($first);
$this->pool->saveDeferred($second);
$this->pool->commit(); // both persisted together
Enter fullscreen mode Exit fullscreen mode

The cost is ceremony. Every read is two lines minimum, and the Item object is a small allocation you did not ask for on a hot path.

Neither one has tags

Here is the part the "which PSR wins" posts skip: tag-based invalidation is in neither standard. There is no invalidateTags(['customer-7']) in PSR-6 or PSR-16. When a customer's data spans forty cache keys and you need to drop all of them at once, the specs give you nothing but delete one key at a time or clear the entire pool.

Tags are a vendor extension. Symfony's cache contracts ship a tag-aware interface on top of PSR-6. The contracts-based TagAwareCacheInterface is the common way to get real tag invalidation in PHP:

<?php

use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;

final readonly class OrderReader
{
    public function __construct(
        private TagAwareCacheInterface $cache,
    ) {}

    public function forId(int $id): Order
    {
        return $this->cache->get(
            "order.$id",
            function (ItemInterface $item) use ($id): Order {
                $item->tag(["orders", "customer-{$this->customerOf($id)}"]);
                $item->expiresAfter(3600);
                return $this->loadFromDb($id);
            },
        );
    }
}

// Elsewhere, when a customer changes:
$cache->invalidateTags(["customer-7"]);
Enter fullscreen mode Exit fullscreen mode

The moment you write invalidateTags, you have left the PSR world and coupled to Symfony's contracts. That is fine — as long as you know you did it, and you keep it out of the domain.

Neither one handles stampede either

A cache stampede is what happens when a hot key expires and forty concurrent requests all miss at the same millisecond, all hit the database, and all recompute the same value. The database that was serving one query a second is suddenly serving forty.

PSR-6 and PSR-16 have no answer. Their get is a plain read; if it misses, you recompute, and nothing coordinates the recompute across requests.

Symfony's cache contracts solve it with probabilistic early expiration. The get() method takes a beta parameter, and before the TTL is up, a single request is randomly chosen to recompute the value while everyone else keeps serving the old one.

<?php

use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;

$value = $cache->get(
    "profile.$id",
    function (ItemInterface $item) use ($id): Profile {
        $item->expiresAfter(3600);
        return $this->loadFromDb($id);
    },
    beta: 1.0,
);
Enter fullscreen mode Exit fullscreen mode

With beta: 1.0 the recompute tends to fire a little before expiry, so the key never goes cold under load. beta: INF forces immediate expiration, which is handy for a manual refresh. Again: this is a Symfony feature, not a PSR one. Choosing it is a real decision, and it belongs at the edge of your system, not in the middle.

Pick the interface your domain depends on

Both PSRs are vendor-adapter interfaces. They are shaped for cache libraries to implement, not for your business code to consume. getItem, isHit, expiresAfter, beta — none of that is the language your use case speaks.

So do not typehint them in the domain. Define the small interface your domain actually wants, in domain words, and put it in the application layer:

<?php

namespace App\Application\Port;

use App\Domain\Customer\CustomerId;
use App\Domain\Profile\Profile;

interface ProfileCache
{
    public function get(CustomerId $id): ?Profile;
    public function put(CustomerId $id, Profile $profile): void;
    public function forget(CustomerId $id): void;
}
Enter fullscreen mode Exit fullscreen mode

The use case depends on ProfileCache and nothing else. It never learns whether a hit means isHit() or a non-null return, whether TTL is per-item or per-call, or whether tags exist. Those are adapter concerns.

The adapter is where a PSR interface (or Symfony's contracts, tags and all) gets to live:

<?php

namespace App\Infrastructure\Cache;

use App\Application\Port\ProfileCache;
use App\Domain\Customer\CustomerId;
use App\Domain\Profile\Profile;
use Psr\SimpleCache\CacheInterface;

final readonly class Psr16ProfileCache implements ProfileCache
{
    public function __construct(private CacheInterface $cache) {}

    public function get(CustomerId $id): ?Profile
    {
        $value = $this->cache->get("profile.{$id->value}");
        return $value instanceof Profile ? $value : null;
    }

    public function put(CustomerId $id, Profile $profile): void
    {
        $this->cache->set("profile.{$id->value}", $profile, 3600);
    }

    public function forget(CustomerId $id): void
    {
        $this->cache->delete("profile.{$id->value}");
    }
}
Enter fullscreen mode Exit fullscreen mode

Want stampede protection and tags tomorrow? Write a SymfonyContractsProfileCache that implements the same ProfileCache port, wire it in the composition root, and change nothing else. Your unit tests use an InMemoryProfileCache that is a fifteen-line array. The reserved-character rules in both PSRs ({}()/\@: are off-limits in keys) stay trapped in the adapter, so the domain never has to sanitize a key.

So which one

For new code with a modern stack, reach for Symfony's cache contracts as the adapter's backing interface: you get PSR-6 compatibility, tags, and stampede handling in one place. If you only need flat get/set and want the smallest possible dependency, PSR-16 is less to hold in your head. Use PSR-6 directly when you need per-item expiry or deferred batch writes and do not want the Symfony contracts.

But that is the second question. The first is where the cache interface points. Whichever PSR you pick, keep it in the adapter and hand your domain a port it can read out loud. Then the Friday decision to add a cache never becomes the thing you rewrite the following spring.


Caching is a textbook edge concern: it speeds up the boring path and means nothing to the business rule underneath. The instant a PSR interface or a beta parameter shows up inside a use case, the framework has crept into the domain again. Keeping it behind a port you defined is exactly the discipline Decoupled PHP is built around — the framework, the cache library, and the storage engine are all adapters your domain never has to name.

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)