Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/4.8.x-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,34 @@ jobs:
- name: Run tests
run: XDEBUG_MODE=coverage && phpunit -v -c tests/phpunit.xml --coverage-text --strict-coverage --stop-on-risky
shell: bash

# Verifies that Cache remains LSP-compatible with every supported major of
# psr/simple-cache. If a future PSR-16 revision narrows the interface in a way
# that breaks our widened signatures, this job fatals at class load.
# See https://github.com/serbanghita/Mobile-Detect/issues/989.
psr16-compat:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php-version: [8.2, 8.4]
psr-simple-cache: ['^1.0', '^2.0', '^3.0']
steps:
- name: Checkout
uses: actions/checkout@v3

- name: Setup PHP ${{ matrix.php-version }}
uses: shivammathur/setup-php@verbose
with:
php-version: ${{ matrix.php-version }}
extensions: mbstring, intl
tools: composer:latest

- name: Install composer dependencies
run: composer install --no-interaction

Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In psr16-compat, composer install is run and then composer require psr/simple-cache:... --update-with-dependencies is run, which will resolve/install dependencies again. This adds significant time across the matrix; consider removing the initial composer install step and letting the composer require ... --update-with-dependencies perform the install (or alternatively change the order so the pin happens before installing).

Suggested change
- name: Install composer dependencies
run: composer install --no-interaction

Copilot uses AI. Check for mistakes.
- name: Pin psr/simple-cache to ${{ matrix.psr-simple-cache }}
run: composer require "psr/simple-cache:${{ matrix.psr-simple-cache }}" --update-with-dependencies --no-interaction

- name: Run tests
run: vendor/bin/phpunit -c tests/phpunit.xml
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
],
"require": {
"php": ">=8.0",
"psr/simple-cache": "3.0.0"
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "3.95.1",
Expand Down
117 changes: 89 additions & 28 deletions src/Cache/Cache.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,36 @@
use DateTime;

use function is_int;
use function is_iterable;
use function is_string;
use function time;

/**
* In-memory cache implementation of PSR-16
* @See https://www.php-fig.org/psr/psr-16/
* In-memory cache implementation of PSR-16.
*
* @see https://www.php-fig.org/psr/psr-16/
*
* Public method parameters are intentionally left without scalar type
* declarations (return types are kept). This keeps the class
* Liskov-compatible with PSR-16 v1/v2 as well as v3, so it loads cleanly
* in hosts (e.g. WordPress sites) where another plugin's autoloader has
* already registered an older `Psr\SimpleCache\CacheInterface`.
* Re-narrowing these parameters will break that compatibility. See
* https://github.com/serbanghita/Mobile-Detect/issues/989.
*/
class Cache implements CacheInterface
{
protected array $cache = [];

/**
* @inheritdoc
* @param string $key
* @param mixed $default
* @return mixed
* @throws CacheInvalidArgumentException
*/
public function get(string $key, mixed $default = null): mixed
public function get($key, mixed $default = null): mixed
{
$this->checkKey($key);
$key = $this->checkKey($key);

if (isset($this->cache[$key])) {
if ($this->cache[$key]['ttl'] === null || $this->cache[$key]['ttl'] > time()) {
Expand All @@ -39,12 +52,15 @@ public function get(string $key, mixed $default = null): mixed
}

/**
* @inheritdoc
* @param string $key
* @param mixed $value
* @param int|DateInterval|null $ttl
* @throws CacheInvalidArgumentException
*/
public function set(string $key, mixed $value, int|DateInterval|null $ttl = null): bool
public function set($key, mixed $value, $ttl = null): bool
{
$this->checkKey($key);
$key = $this->checkKey($key);
$ttl = $this->checkTtl($ttl);

// From https://www.php-fig.org/psr/psr-16/ "Definitions" -> "Expiration"
// If a negative or zero TTL is provided, the item MUST be deleted from the cache if it exists, as it is expired already.
Expand All @@ -64,20 +80,20 @@ public function set(string $key, mixed $value, int|DateInterval|null $ttl = null
return true;
}

/** @inheritdoc */
public function delete(string $key): bool
/**
* @param string $key
* @throws CacheInvalidArgumentException
*/
public function delete($key): bool
{
$this->checkKey($key);
$key = $this->checkKey($key);
$this->deleteSingle($key);

return true;
}

/**
* Deletes the cache item from memory.
*
* @param string $key Cache key
* @return void
*/
private function deleteSingle(string $key): void
{
Expand All @@ -93,12 +109,12 @@ public function clear(): bool
}

/**
* @inheritdoc
* @param string $key
* @throws CacheInvalidArgumentException
*/
public function has(string $key): bool
public function has($key): bool
{
$this->checkKey($key);
$key = $this->checkKey($key);

if (isset($this->cache[$key])) {
if ($this->cache[$key]['ttl'] === null || $this->cache[$key]['ttl'] > time()) {
Expand All @@ -111,33 +127,49 @@ public function has(string $key): bool
return false;
}

/** @inheritdoc */
public function getMultiple(iterable $keys, mixed $default = null): iterable
/**
* @param iterable<string> $keys
* @param mixed $default
* @throws CacheInvalidArgumentException
*/
public function getMultiple($keys, mixed $default = null): iterable
{
$data = [];
$keys = $this->checkIterable($keys, 'keys');

$data = [];
foreach ($keys as $key) {
$data[$key] = $this->get($key, $default);
}

return $data;
}

/** @inheritdoc */
public function setMultiple(iterable $values, int|DateInterval|null $ttl = null): bool
/**
* @param iterable<string, mixed> $values
* @param int|DateInterval|null $ttl
* @throws CacheInvalidArgumentException
*/
public function setMultiple($values, $ttl = null): bool
{
$return = [];
$values = $this->checkIterable($values, 'values');
$ttl = $this->checkTtl($ttl);

$return = [];
foreach ($values as $key => $value) {
$return[] = $this->set($key, $value, $ttl);
}

return $this->checkReturn($return);
}

/** @inheritdoc */
public function deleteMultiple(iterable $keys): bool
/**
* @param iterable<string> $keys
* @throws CacheInvalidArgumentException
*/
public function deleteMultiple($keys): bool
{
$keys = $this->checkIterable($keys, 'keys');

foreach ($keys as $key) {
$this->delete($key);
}
Expand All @@ -146,10 +178,14 @@ public function deleteMultiple(iterable $keys): bool
}

/**
* @param mixed $key
* @throws CacheInvalidArgumentException
*/
protected function checkKey(string $key): string
protected function checkKey($key): string
{
if (!is_string($key)) {
throw new CacheInvalidArgumentException('Cache key must be a string.');
}

if ($key === '' || !preg_match('/^[A-Za-z0-9_.]{1,64}$/', $key)) {
throw new CacheInvalidArgumentException("Invalid key: '$key'. Must be alphanumeric, can contain _ and . and can be maximum of 64 chars.");
Expand All @@ -158,10 +194,35 @@ protected function checkKey(string $key): string
return $key;
}

/** */
protected function getTTL(DateInterval|int|null $ttl): ?int
/**
* @param mixed $ttl
* @throws CacheInvalidArgumentException
*/
protected function checkTtl($ttl): int|DateInterval|null
{
if ($ttl !== null && !is_int($ttl) && !($ttl instanceof DateInterval)) {
throw new CacheInvalidArgumentException('TTL must be null, int, or DateInterval.');
}

return $ttl;
}

/**
* @param mixed $iterable
* @return iterable<mixed>
* @throws CacheInvalidArgumentException
*/
protected function checkIterable($iterable, string $argName): iterable
{
if (!is_iterable($iterable)) {
throw new CacheInvalidArgumentException(sprintf('%s must be iterable.', ucfirst($argName)));
}

return $iterable;
}

protected function getTTL(DateInterval|int|null $ttl): ?int
{
if ($ttl instanceof DateInterval) {
return (new DateTime())->add($ttl)->getTimestamp() - time();
}
Expand Down
78 changes: 78 additions & 0 deletions tests/CacheTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -396,4 +396,82 @@

$this->assertEquals(0, $evicted);
}

/**
* Regression test for https://github.com/serbanghita/Mobile-Detect/issues/989
*
* Cache must remain LSP-compatible with PSR-16 v1 (untyped parameters) so it
* loads cleanly in hosts where another plugin has registered an older
* Psr\SimpleCache\CacheInterface. Re-adding scalar type declarations on the
* listed parameters will break that at class load with a fatal error.
*/
public function testPublicMethodParametersAreUntypedForPsr16V1Compatibility(): void
{
// [method => parameter index] pairs that MUST have no type declaration.
$untypedSlots = [
['get', 0], // $key
['set', 0], // $key
['set', 2], // $ttl
['delete', 0], // $key
['has', 0], // $key
['getMultiple', 0], // $keys
['setMultiple', 0], // $values
['setMultiple', 1], // $ttl
['deleteMultiple', 0], // $keys
];

foreach ($untypedSlots as [$method, $index]) {
$param = (new \ReflectionMethod(Cache::class, $method))->getParameters()[$index];
self::assertFalse(
$param->hasType(),
sprintf(
'Cache::%s() parameter $%s must have no type declaration for PSR-16 v1 LSP compatibility, got: %s',
$method,
$param->getName(),
(string) $param->getType()
)
);
}
}

public function testSetWithInvalidTtlTypeThrowsException(): void
{
$this->expectException(CacheInvalidArgumentException::class);
$this->cache->set('isMobile', true, 'not-a-valid-ttl');
}

public function testSetMultipleWithNonIterableThrowsException(): void
{
$this->expectException(CacheInvalidArgumentException::class);
/** @phpstan-ignore-next-line Intentional: asserts runtime validation of the widened signature. */
$this->cache->setMultiple('not-iterable');

Check failure on line 447 in tests/CacheTest.php

View workflow job for this annotation

GitHub Actions / run (8.2, ubuntu-latest, latest, ^9.6.18)

No error to ignore is reported on line 447.

Check failure on line 447 in tests/CacheTest.php

View workflow job for this annotation

GitHub Actions / run (8.3, ubuntu-latest, latest, ^9.6.18)

No error to ignore is reported on line 447.

Check failure on line 447 in tests/CacheTest.php

View workflow job for this annotation

GitHub Actions / run (8.4, ubuntu-latest, latest, ^9.6.18)

No error to ignore is reported on line 447.
}

public function testGetMultipleWithNonIterableThrowsException(): void
{
$this->expectException(CacheInvalidArgumentException::class);
/** @phpstan-ignore-next-line Intentional: asserts runtime validation of the widened signature. */
$this->cache->getMultiple('not-iterable');

Check failure on line 454 in tests/CacheTest.php

View workflow job for this annotation

GitHub Actions / run (8.2, ubuntu-latest, latest, ^9.6.18)

No error to ignore is reported on line 454.

Check failure on line 454 in tests/CacheTest.php

View workflow job for this annotation

GitHub Actions / run (8.3, ubuntu-latest, latest, ^9.6.18)

No error to ignore is reported on line 454.

Check failure on line 454 in tests/CacheTest.php

View workflow job for this annotation

GitHub Actions / run (8.4, ubuntu-latest, latest, ^9.6.18)

No error to ignore is reported on line 454.
}

public function testDeleteMultipleWithNonIterableThrowsException(): void
{
$this->expectException(CacheInvalidArgumentException::class);
/** @phpstan-ignore-next-line Intentional: asserts runtime validation of the widened signature. */
$this->cache->deleteMultiple('not-iterable');

Check failure on line 461 in tests/CacheTest.php

View workflow job for this annotation

GitHub Actions / run (8.2, ubuntu-latest, latest, ^9.6.18)

No error to ignore is reported on line 461.

Check failure on line 461 in tests/CacheTest.php

View workflow job for this annotation

GitHub Actions / run (8.3, ubuntu-latest, latest, ^9.6.18)

No error to ignore is reported on line 461.

Check failure on line 461 in tests/CacheTest.php

View workflow job for this annotation

GitHub Actions / run (8.4, ubuntu-latest, latest, ^9.6.18)

No error to ignore is reported on line 461.
}

public function testGetWithNonStringKeyThrowsException(): void
{
$this->expectException(CacheInvalidArgumentException::class);
/** @phpstan-ignore-next-line Intentional: asserts runtime validation of the widened signature. */
$this->cache->get(123);

Check failure on line 468 in tests/CacheTest.php

View workflow job for this annotation

GitHub Actions / run (8.2, ubuntu-latest, latest, ^9.6.18)

No error to ignore is reported on line 468.

Check failure on line 468 in tests/CacheTest.php

View workflow job for this annotation

GitHub Actions / run (8.3, ubuntu-latest, latest, ^9.6.18)

No error to ignore is reported on line 468.

Check failure on line 468 in tests/CacheTest.php

View workflow job for this annotation

GitHub Actions / run (8.4, ubuntu-latest, latest, ^9.6.18)

No error to ignore is reported on line 468.
}

public function testSetWithNonStringKeyThrowsException(): void
{
$this->expectException(CacheInvalidArgumentException::class);
/** @phpstan-ignore-next-line Intentional: asserts runtime validation of the widened signature. */
$this->cache->set(['notAKey'], 'value');

Check failure on line 475 in tests/CacheTest.php

View workflow job for this annotation

GitHub Actions / run (8.2, ubuntu-latest, latest, ^9.6.18)

No error to ignore is reported on line 475.

Check failure on line 475 in tests/CacheTest.php

View workflow job for this annotation

GitHub Actions / run (8.3, ubuntu-latest, latest, ^9.6.18)

No error to ignore is reported on line 475.

Check failure on line 475 in tests/CacheTest.php

View workflow job for this annotation

GitHub Actions / run (8.4, ubuntu-latest, latest, ^9.6.18)

No error to ignore is reported on line 475.
}
}
Loading