Fully Implementing PSR-16 Simple Cache is Less Than Simple

Foreword: Please note this falls firmly into "nitpick" territory rather than shining light onto any sort of major issue. I just thought it was an interesting gotcha and wanted to share.

TL;DR: To fully implement the PSR-16 specification as defined, an implementation MUST be contravariant of the defined interface in its accepted parameters when implementing v2 or later.

I work on an application with a proprietary caching layer. Recently I wanted to build a PSR-16 "Simple Cache" adapter for it, and started going through the spec and provided interface in detail. I wanted my implementation to be as correct as possible.

While working my way through getMultiple/setMultiple/deleteMultiple I noted something that seemed like an oversight.

/**
* Obtains multiple cache items by their unique keys.
*
* @param iterable<string> $keys A list of keys that can be obtained in a single operation.
* @param mixed $default Default value to return for keys that do not exist.
*
* @return iterable<string, mixed> A list of key => value pairs. Cache keys that do not exist or are stale will have $default as value.
*
* @throws \Psr\SimpleCache\InvalidArgumentException
* MUST be thrown if $keys is neither an array nor a Traversable,
* or if any of the $keys are not a legal value.
*/
public function getMultiple(iterable $keys, mixed $default = null): iterable;

Take a moment to read this carefully

@throws \Psr\SimpleCache\InvalidArgumentException
MUST be thrown if $keys is neither an array nor a Traversable
or if any of the $keys are not a legal value.

Then, inspect the definition

public function getMultiple(iterable $keys, mixed $default = null): iterable;

The takeaway here should be:

iterable $keys

To implement getMultiple, we MUST throw a \Psr\SimpleCache\InvalidArgumentException when $keys is invalid, yet $keys is defined as iterable which makes it impossible to handle if an implementation matches this signature.

Passing anything non-iterable will throw a TypeError before the code even runs. This would make it impossible to throw an InvalidArgumentException.

Parameter types were added to the interface with the release of v2. My suspicion was that the @throws had simply not been updated, because if the parameter type is explicitly defined as iterable, the function cannot throw a custom exception.

I originally suspected this would make it impossible to fulfill the spec. However, I had not considered contravariance. The implementing methods accepted parameter types may be wider than the interface it is fulfilling. In this instance, $keys does not need to be defined as iterable in the implementation.

This means a technically complete implementation must omit iterable and look something like

public function getMultiple($keys, mixed $default = null): iterable {
if(!is_array($keys) && !$var instanceof Traversable) {
// Must implement \Psr\SimpleCache\InvalidArgumentException;
throw new ExampleException;
}
}

Note \Psr\SimpleCache\InvalidArgumentException is an interface. An implementation must define a concrete exception class that implements it before it can be thrown

So while it's not impossible to implement, it certainly is strange to implement. One can only correctly implement the spec by manually checking $keys type like this.

Doing a little investigation, it seems like most major implementations still target v1 || v2 || v3 and as v1 did not have typed parameters, neither do the implementations iterable parameters. This means most major implementations are implemented correctly.

However, there are at least a couple of implementations that get this wrong.

laminas/laminas-cache

yiisoft/cache

phpfastcache/phpfastcache

I'm sure there are others, but I found these three in my quick search. It is a very easy mistake to make when not implementing v1.

This is not Java. There are no checked exceptions forcing anyone's hand. In practice, this is a minor quirk of the spec. If a TypeError gets thrown instead of an \Psr\SimpleCache\InvalidArgumentException, almost certainly nothing meaningful breaks. I doubt anything depends on this behavior. It’s just an inconsistency between what the spec says and what implementations are doing. It is nothing to lose sleep over, but it might be worth fixing for consistency's sake.

In summary, to fully implement the getMultiple/setMultiple/deleteMultiple methods of the PSR-16 spec, one cannot simply copy their definitions directly from the CacheInterface. The implementation must check the validity of their iterable parameters and not have them defined as an iterable.