What is the difference between PSR-6 CacheItemPoolInterface and PSR-16 CacheInterface, and which should a reusable PHP library depend on?
answer
- pool and item objects vs plain key/value
- getItem() never returns null
- isHit() tells a cached null from a miss
- PSR-16 get($key, $default = null)
- deferred saves only in PSR-6
basics
~20 sPSR-6 is a pool of item objects: getItem(), isHit(), set(), save(), with deferred saves. PSR-16 is a simple key/value API: get(), set(), delete() with a TTL. A library needing only get/set usually depends on PSR-16.
solid answer
~40 sPSR-6 models a **pool** that hands out `CacheItemInterface` objects: `getItem($key)` always returns an item, `isHit()` says whether it was found, `set()` and `expiresAfter()` change it, and `save()` or `saveDeferred()` plus `commit()` persist it. PSR-16 is a thinner layer: `CacheInterface::get($key, $default = null)`, `set($key, $value, $ttl = null)`, `delete()`, `has()` and the `*Multiple()` batch methods. The concrete difference interviewers look for is null: PSR-6's `isHit()` tells a cached `null` from a miss, while PSR-16 returns the default on a miss, so a stored `null` is indistinguishable. For a library that only needs to remember values for a while, PSR-16 is simpler to consume and to fake in tests; depend on PSR-6 when you need deferred batch writes, item-level expiry objects or to cache `null` meaningfully.
code
php · 27 lines<?php
declare(strict_types=1);
use Psr\Cache\CacheItemPoolInterface;
use Psr\SimpleCache\CacheInterface;
// PSR-6: item object, isHit() tells a stored null from a miss
function ratePsr6(CacheItemPoolInterface $pool, string $pair, callable $fetch): ?float
{
$item = $pool->getItem('rate.' . $pair);
if (!$item->isHit()) {
$item->set($fetch($pair))->expiresAfter(600);
$pool->save($item);
}
return $item->get();
}
// PSR-16: plain values; a cached null would look like a miss
function ratePsr16(CacheInterface $cache, string $pair, callable $fetch): ?float
{
$rate = $cache->get('rate.' . $pair);
if ($rate === null) {
$rate = $fetch($pair);
$cache->set('rate.' . $pair, $rate, 600);
}
return $rate;
}go deeper
Remember that PSR-6 works with pool and item objects and PSR-16 with plain get and set by key, both with a TTL.
Explain isHit() versus a default-on-miss, the null ambiguity in PSR-16, deferred saves in PSR-6, and why has() then get() races.
Pick one cache standard for a library's public constructor, justify it by the features actually needed, and design around cached nulls and cache failures.
Set a codebase-wide policy on which cache abstraction packages expose, weighing ecosystem compatibility, testability and features such as batch writes.
## Two standards for the same job PHP-FIG published two caching interfaces: - **PSR-6**, package `psr/cache`, namespace `Psr\Cache`: the full model, with pools and items; - **PSR-16**, package `psr/simple-cache`, namespace `Psr\SimpleCache`: a streamlined key/value interface for the common cases. PSR-16's own summary says PSR-6 solves the problem "in a rather formal and verbose way for what the most simple use cases need". The two share definitions of TTL, expiration and keys; PSR-16 was designed to make compatibility with PSR-6 straightforward, with an adapter from a PSR-6 pool to PSR-16 among its goals, and a cache library can expose both. ## PSR-6: pools and items PSR-6 uses a **repository model**. A `CacheItemPoolInterface` is the store; a `CacheItemInterface` is one key/value entry. 1. `getItem($key)` returns an item. It **must not** return `null`, even on a miss. 2. `isHit()` reports whether the lookup found a live value. `get()` returns the value, or `null` on a miss. 3. `set($value)`, `expiresAfter($time)` and `expiresAt($expiration)` change the item and return it for chaining. 4. `save($item)` persists immediately; `saveDeferred($item)` queues it and `commit()` persists everything queued. Calling code must not instantiate items itself; they only come from `getItem()` or `getItems()`. ## PSR-16: plain values `Psr\SimpleCache\CacheInterface` has eight methods: - `get($key, $default = null)` and `set($key, $value, $ttl = null)`; - `delete($key)`, `clear()` and `has($key)`; - `getMultiple($keys, $default = null)`, `setMultiple($values, $ttl = null)` and `deleteMultiple($keys)`. A TTL is `null`, an integer number of seconds or a `DateInterval`. A zero or negative TTL deletes the entry, because it is already expired. An instance corresponds to one PSR-6 pool. ## Side by side | Question | PSR-6 | PSR-16 | |---|---|---| | Unit of work | item object from a pool | plain value by key | | Miss detection | `isHit()` on the item | `get()` returns `$default` | | Caching `null` | distinguishable from a miss | indistinguishable from a miss | | Expiry | `expiresAfter()` or `expiresAt()` on the item | `$ttl` argument to `set()` | | Batch writes | `saveDeferred()` then `commit()` | `setMultiple()` | | Existence check | `hasItem()`, which may race with `get()` | `has()`, recommended only for cache warming | | Invalid key | `Psr\Cache\InvalidArgumentException` | `Psr\SimpleCache\InvalidArgumentException` | ## The null trap PSR-16 states it directly: a cache miss returns `null` (or the default), so detecting whether someone stored `null` is not possible; this is the main deviation from PSR-6. If a library caches "no rate available" as `null`, every read looks like a miss and it hits the remote service again. Two fixes: - with PSR-16, store a sentinel such as `false` or a small value object, or pass a unique sentinel as `$default`; - with PSR-6, check `isHit()` and store `null` freely. ## Why `has()` then `get()` is wrong in both PSR-16 recommends `has()` only for cache-warming, because another process can remove the entry between `has()` and `get()`. PSR-6 warns that `hasItem()` may race with `get()` for the same reason, while `isHit()` must not have a race with `get()` on the same item. Read once and decide from the result. ## Choosing for a reusable library For a framework-agnostic library, such as an exchange-rate client that caches rates for ten minutes: - **Prefer PSR-16** when the library only needs get, set and a TTL. Consumers can pass nearly any cache, and tests can use a tiny in-memory fake. - **Choose PSR-6** when you need deferred writes across many items, per-item expiry objects, or the ability to cache `null`. - Accept only **one** of them in the constructor. Supporting both doubles the code paths for little gain, since adapters exist between them. ## Versions of the packages Both packages went through typed releases: `psr/cache` added parameter types in 2.0 and return types in 3.0; `psr/simple-cache` added parameter types in 2.0, which also made its `CacheException` extend `\Throwable`, and return types in 3.0. A library should allow the range its supported PHP versions can use.
- In PSR-16, what does set($key, $value, 0) do?A zero or negative TTL means the item is already expired, so the specification says it MUST be deleted from the cache if it exists. It is a common surprise for code that expects 0 to mean "never expire"; for that, pass `null` and rely on the implementation's default or its maximum lifetime.
- Why must calling code never create a PSR-6 CacheItemInterface object with new?PSR-6 says calling libraries MUST NOT instantiate items; they come only from the pool's `getItem()` or `getItems()`. The pool associates the item with its key and storage details, and an item from one implementation should not be assumed to work with another implementation's pool.
saying these in an interview costs you the question
- Says PSR-16 replaced PSR-6, which is now deprecated.
- Believes PSR-16 can tell a cached null apart from a miss.
- Expects PSR-6 getItem() to return null on a miss.
- Uses has() before get() in hot paths as a safe pattern.
- Thinks a TTL of 0 in PSR-16 means cache forever.