---
title: "Cache Project"
description: "Consistent PSR-6/PSR-16 Cache Wiring Across Derafu Packages"
type: "docs"
category: "doc"
tags: [php]
authors: [Anonymous]
date: "2026-09-22"
last_update: "2026-09-22"
time_minutes: 9
draft: false
unlisted: false
url: "https://www.derafu.dev/docs/core/cache"
---

# Consistent PSR-6/PSR-16 Cache Wiring Across Derafu Packages

[![GitHub](https://img.shields.io/badge/github-derafu%2Fcache-blue?logo=github)](https://github.com/derafu/cache)
![GitHub last commit](https://img.shields.io/github/last-commit/derafu/cache/main)
![CI Workflow](https://github.com/derafu/cache/actions/workflows/ci.yml/badge.svg?branch=main&event=push)
![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/derafu/cache)
![GitHub Issues](https://img.shields.io/github/issues-raw/derafu/cache)
![Total Downloads](https://poser.pugx.org/derafu/cache/downloads)
![Monthly Downloads](https://poser.pugx.org/derafu/cache/d/monthly)

A small, opinionated layer over [`symfony/cache`](https://symfony.com/doc/current/components/cache.html) for the *local* backends (no connection, no credentials, no network round trip) — plus a handful of things PSR-6/PSR-16 and `symfony/cache` genuinely don't provide on their own.

## Why

`symfony/cache`'s own file-based adapters (`PhpFilesAdapter`, `FilesystemAdapter`) accept a `null` directory and silently fall back to one you'd have to read the source to find. A cache backed by the filesystem that nobody can point to exactly is a cache nobody can clear with confidence — `derafu/cache`'s adapters make `$namespace` and `$directory` required instead, so that decision is never made for you.

Past that one guardrail, this package adds a handful of things PSR-6/PSR-16 and `symfony/cache` genuinely don't provide on their own: a safe way to build cache keys without hand-rolling sanitization, a stampede-safe "compute and remember" helper, and a way to inspect or clear a cache directory without needing to reconstruct whatever wrote to it.

It is **not** a general abstraction over every backend `symfony/cache` supports. PSR-6/PSR-16 already are that abstraction — building another one on top would just be indirection. See [Scope](#scope-local-only) below.

## Installation

```bash
composer require derafu/cache
```

`ext-apcu` (for `LocalCacheBackend::Apcu`) and `symfony/console` (for the bundled commands) are both entirely optional — see [`composer.json`'s `suggest`](https://github.com/derafu/cache/blob/main/composer.json).

## Scope: Local Only

`LocalCacheBackend` and `LocalCacheFactory` cover exactly the backends that need no connection, no credentials, and no network round trip: they live in this process, in shared memory on this server, or on this filesystem.

```php
enum LocalCacheBackend: string
{
    case None = 'none';           // Never caches anything (NullAdapter).
    case Memory = 'memory';       // Dies with the process (ArrayAdapter).
    case Apcu = 'apcu';           // Shared memory, survives requests (ApcuAdapter).
    case Filesystem = 'filesystem'; // Serialized files, supports objects (FilesystemAdapter).
    case PhpFiles = 'php_files';  // Plain PHP array files, OPcache-friendly (PhpFilesAdapter).
}
```

Redis, Memcached, Couchbase, PDO, and every other connection-based backend `symfony/cache` supports are deliberately out of scope — not a gap, a boundary. They have a fundamentally different construction shape (a connection or DSN, not a namespace/directory pair) and none of the "silent default" problem this package exists to close off: `RedisAdapter`/`MemcachedAdapter` already force you to pass a real connection, no `null` fallback to guard against. Building one directly from `symfony/cache` isn't a workaround, it's simply what those backends need — `$pool->clear()` (part of PSR-6 itself) already works on them the same way it works on everything else.

## `PhpFilesCache` and `FilesystemCache`

```php
use Derafu\Cache\Adapter\PhpFilesCache;
use Derafu\Cache\Adapter\FilesystemCache;

// OPcache-friendly: values must be var_export()-able (arrays/scalars, no objects).
$pool = new PhpFilesCache('my_package', '/var/cache/my_package');

// Serialized: supports arbitrary values, including objects. No OPcache benefit.
$pool = new FilesystemCache('my_package', '/var/cache/my_package');
```

Both extend the matching `symfony/cache` adapter directly, under the same name, so anyone already familiar with `symfony/cache` recognizes immediately what each one does. `$namespace` and `$directory` are both required — `$defaultLifetime` (3600 by default) is the only parameter allowed to have one, since it isn't a "where does my data live" decision.

## `LocalCacheFactory`

For the case where the backend itself is a runtime choice — from configuration, the way a per-feature `cache: memory|filesystem` setting would — rather than something you already know at the call site:

```php
use Derafu\Cache\LocalCacheFactory;
use Derafu\Cache\Enum\LocalCacheBackend;
use Symfony\Component\Cache\Adapter\ArrayAdapter;

$pool = LocalCacheFactory::pool(LocalCacheBackend::Filesystem, 'my_package', '/var/cache/my_package');

// PSR-16 instead of PSR-6 — the same pool, wrapped in Symfony's Psr16Cache bridge.
$cache = LocalCacheFactory::simple(LocalCacheBackend::Memory, 'my_package');

// Tag-aware — $item->tag([...]) on save, invalidateTags([...]) to drop a whole group at once.
$cache = LocalCacheFactory::taggedPool(LocalCacheBackend::Apcu, 'my_package');

// Several pools, fastest first, with automatic backfill on a slower-tier hit.
$pool = LocalCacheFactory::layered([new ArrayAdapter(), $filesystemPool]);
```

`$directory` is required for `Filesystem`/`PhpFiles`, ignored for `None`/`Memory`/`Apcu` — passing it for those three is harmless, omitting it for the other two throws.

`LocalCacheBackend::None` (backed by Symfony's own `NullAdapter`) is how "no caching" is expressed — not a runtime flag a consumer has to check, but a pool like any other. A decorator that always receives a `CacheItemPoolInterface` needs no special case for "caching is off": it just does nothing useful when this happens to be the pool it got.

## `Memoizer`: Compute-and-Cache Without Reinventing Stampede Protection

```php
use Derafu\Cache\Memoizer;

$memoizer = new Memoizer();

$value = $memoizer->remember($pool, 'some_key', function () {
    return expensive_computation();
}, ttl: 3600);
```

Every hand-written PSR-6 caching decorator ends up rewriting the same "is it there? no? compute it, store it, return it" dance — `getItem()` → `isHit()` → `set()` → `save()`. That naive version has no protection against a cache stampede: under concurrent access, every process that hits a cold cache computes and saves independently.

Every `symfony/cache` adapter also implements Symfony's own [`CacheInterface::get()`](https://symfony.com/doc/current/components/cache.html#basic-usage-psr-6) contract, which does the same thing with real locking — only one process computes, the rest wait for it and read the result. `Memoizer::remember()` uses that path automatically whenever the given pool supports it, and falls back to the naive dance only for a PSR-6 implementation that isn't a Symfony adapter (no locking possible without Symfony's own contract). `$ttl` means exactly what it means for `expiresAfter()` — `null` leaves the pool's default lifetime in effect, `0` means the entry never expires, a positive integer is seconds until expiration. Nothing here redefines that; a decorator that also needs a "don't even try to cache" switch should express it as its own separately-named parameter, or — more simply — accept a `LocalCacheBackend::None` pool.

## `CacheKey`: One Safe Way to Build a Key

```php
use Derafu\Cache\CacheKey;

$cacheKey = new CacheKey();

$key = $cacheKey->build('my_package', 'ExampleWorker', ['api_resource' => true]);
// "my_package.ExampleWorker.e381541fbf7da0e4036f1cc881592fce"
```

PSR-6 forbids `{}()/\@:` in keys but sanitizes nothing for you — building a safe, collision-resistant key from real context (a class name with backslashes, a filter array) is left entirely to each caller. `CacheKey::build()` normalizes each part: a string gets its forbidden characters *replaced* (never dropped — dropping them could collide `App\FooBar` with `App\Foo\Bar`, both becoming `AppFooBar`), any other scalar is used as-is, and anything else (an array, an object) is hashed. If the assembled key would exceed 250 bytes (Memcached's real limit, a safe ceiling for any backend), the whole thing collapses to the prefix plus a hash of the full key — long context never silently breaks a backend with an actual key-length limit.

`CacheKey` implements `Derafu\Cache\Contract\CacheKeyInterface` so a consumer that needs key logic driven by what's actually being cached — not just its class name — can inject its own implementation of that one-method interface instead of subclassing `CacheKey` and reaching into its private sanitizing helpers.

## `CacheDirectory`: Inspecting and Clearing by Path Alone

```php
use Derafu\Cache\CacheDirectory;

$directory = new CacheDirectory('/var/cache/my_package');

$directory->stats(); // ['count' => 42, 'totalSize' => 1048576, 'oldest' => 1700000000, 'newest' => 1700003600]
$directory->clear(); // int — how many files were deleted.
```

`stats()` is a real, new capability — neither PSR-6 nor `symfony/cache` expose a way to ask any pool how many entries it holds or how much space they use, for any backend. `clear()` is not: `CacheItemPoolInterface::clear()` is already part of PSR-6 and works fine when you have a live pool instance in hand. `CacheDirectory::clear()` exists for exactly the case where you don't — an admin command that only knows a path, not which adapter type (namespace, TTL, backend) originally wrote there.

## `CacheWarmer` and `WarmableInterface`: Warming Without a Kernel

```php
use Derafu\Cache\CacheWarmer;
use Derafu\Cache\Contract\WarmableInterface;

final class ExampleWarmable implements WarmableInterface
{
    public function warmup(): void { /* ... */ }
}

$failures = (new CacheWarmer())->warmup([$warmableA, $warmableB]);
// list<array{warmable: WarmableInterface, exception: Throwable}> — empty if everything succeeded.
```

`symfony/cache` has no warmup concept at all — only `PruneableInterface` (the opposite: removing *expired* entries). Symfony's real `CacheWarmerInterface` lives in `symfony/http-kernel`, tied to the full framework Kernel's boot lifecycle: it warms the *framework's own* caches (compiled container, routing) into a temporary location and atomically swaps them in, specifically because a half-written container cache would take the whole application down.

Warming an application-level PSR-6 cache doesn't carry that risk — if a warmable fails partway through, the next real read just recomputes that one entry. So `CacheWarmer` doesn't try to replicate the atomic swap or the Kernel's boot-order resolution: it runs each warmable in order, keeps going if one throws, and reports every failure at the end instead of stopping at the first one.

There's deliberately no bundled warmup *command*: a generic one would need to discover which `WarmableInterface` instances exist in a given application and how to construct each one — almost none will have a no-argument constructor, since a real warmable needs whatever it's warming. That discovery is exactly what a DI container is for, which this package chooses not to depend on (see [Console Commands](#console-commands-optional) below). The intended shape is a small, application-specific command that constructs the warmables it actually has and calls `(new CacheWarmer())->warmup($warmables)`.

## Console Commands (Optional)

```php
use Derafu\Cache\Console\ClearLocalCacheCommand;
use Derafu\Cache\Console\LocalCacheStatsCommand;

$application->add(new ClearLocalCacheCommand());
$application->add(new LocalCacheStatsCommand());
```

```bash
php bin/console derafu:local-cache:clear /var/cache/my_package
php bin/console derafu:local-cache:stats /var/cache/my_package
```

Both wrap `CacheDirectory` and require only a path. Named `derafu:local-cache:*`, not the more obvious `cache:clear`/`cache:stats` — that name is already taken in any real Symfony full-stack app (`symfony/framework-bundle` ships its own `cache:clear`, for the framework's own internal cache), and a generic name from a library meant to be added into someone else's `Application` is a collision waiting to happen. Still want a shorter name? Rename after construction: `(new ClearLocalCacheCommand())->setName('cache:clear-local')`.

Neither command is registered automatically — this package has no DI container of its own to register commands into. `symfony/console` is a `suggest`, not a hard requirement: Composer's autoloading is lazy, so a consumer that never references `Console\*` pays nothing for it even without `symfony/console` installed.

## Requirements

PHP 8.5+. Depends on [`symfony/cache`](https://symfony.com/doc/current/components/cache.html), `psr/cache`, and `psr/simple-cache`.



---
Last updated on 22/09/2026
#php
