Caching

One injectable cache API for file storage, Redis and Memcached.

Inject PhpOnWheels\Cache\Cache into a controller or service. The demo and blank project bootstraps already register it. The default stores data in storage/cache/data, outside the document root; no server or extra PHP extension is needed.

Start with remember()

remember() returns the cached value, or runs your callback and stores its result on a miss. Its TTL is in seconds.

PHP

<?php
namespace App\Controllers;

use PhpOnWheels\Cache\Cache;
use PhpOnWheels\Database\Connection;
use PhpOnWheels\Http\Response;
use PhpOnWheels\Http\ServerRequest;
use PhpOnWheels\Routing\Handler;

final class TotalsController implements Handler
{
    public function __construct(
        private readonly Cache $cache,
        private readonly Connection $database,
    ) {
    }

    public function handle(ServerRequest $request): Response
    {
        $count = $this->cache->remember(
            'notes:count',
            fn (): int => $this->database->query('notes')->count(),
            ttlSeconds: 60,
        );

        return Response::json(['count' => $count]);
    }
}

This example caches a shared total. For private data, include the authenticated user or tenant id in the key and check authorization before reading the cache. A cache entry is shared deliberately, across requests and users; it is not request-local storage.

The API

PHP

<?php
$cache->set('price:42', ['amount' => 19.95, 'currency' => 'EUR'], 300);
$price = $cache->get('price:42');      // The array above, or null on a miss.
$exists = $cache->has('price:42');     // true, even for cached null or false.
$cache->delete('price:42');           // Deleting an absent entry is safe.

$cache->set('feature:enabled', false); // Uses CACHE_TTL (300 seconds by default).
$cache->set('reference:version', 7, 0); // 0 means no expiration.
$cache->set('nullable', null);
$cache->remember('nullable', fn (): string => 'not called');

$cache->clear();                      // Only this application's namespace.
  • Values may be strings, integers, finite floats, booleans, null, or nested arrays of those values. Empty arrays and scalar types survive a round trip.
  • Objects (including models and JsonSerializable), resources, recursive arrays, invalid UTF-8 and non-finite numbers are refused. Convert a model into an array of data first. Nothing uses PHP serialize() or unserialize().
  • get() returns null for a miss and a cached null. has() distinguishes them; remember() treats null as a hit. It does not confuse false, zero or an empty string with a miss.
  • Keys contain 1 to 1024 bytes and are hashed before reaching a backend, so punctuation cannot create a path or a Memcached protocol command.
  • Omitting the TTL uses CACHE_TTL; zero never expires. Negative TTLs and expiration beyond the portable signed Unix timestamp limit (2038) are refused. Expiration is checked on reads, not dependent on a scheduler.

The returned value is a JSON-data union, not an inferred model or array shape. Narrow with is_array(), is_string(), etc. before using it as a particular type. Nested array elements are a JSON boundary, validated recursively on writes.

Choose a backend

Change the environment and restart long-lived workers. The API and controller code stay the same.

File storage (default)

.env

CACHE_DRIVER=file

CACHE_TTL=300

CACHE_PATH=storage/cache/data

CACHE_DRIVER=storage is an alias for file. Relative paths resolve against the project root, not the current working directory. Files are written atomically with mode 0600; newly created directories use 0700. Use a private, trusted directory outside public/, dedicated to caching. File cache entries are local to that filesystem, not shared across separate servers. Expired entries are misses; their files remain until overwritten or cleared, avoiding deletion races with concurrent writers.

Redis

.env

CACHE_DRIVER=redis

CACHE_HOST=127.0.0.1

CACHE_PORT=6379

CACHE_DATABASE=0

CACHE_TIMEOUT=2

CACHE_PREFIX=my-app-production

# CACHE_USERNAME=app

# CACHE_PASSWORD=...

Install ext-redis and run a Redis server. CACHE_USERNAME is optional for ACL authentication; a password without a username uses Redis password authentication. CACHE_DATABASE selects a non-negative database index. A username without a password is an error. For a TLS-enabled Redis endpoint use CACHE_HOST=tls://cache.example.test; PHP's TLS verification remains enabled.

Memcached

.env

CACHE_DRIVER=memcached

CACHE_HOST=127.0.0.1

CACHE_PORT=11211

CACHE_TIMEOUT=2

CACHE_PREFIX=my-app-production

Install ext-memcached and run a Memcached server. The adapter handles Memcached's special interpretation of TTLs longer than 30 days. This adapter does not implement SASL or TLS: use a local server or a trusted private network, never a public endpoint.

Namespaces and clearing

CACHE_PREFIX defaults to a hash of the project root, isolating local projects automatically. Set an explicit, unique prefix for each application and environment in production. All instances sharing a cache must use the same prefix; an application whose deployment path changes should also set a stable prefix. Do not share a prefix between applications or environments.

Shell

./pow cache:clear

This reads the cache configuration without booting the application or connecting to its database. It clears data only for the configured backend and namespace. ./pow storage:clear still clears compiled templates only; it does not touch application data. The admin Storage page likewise manages templates, not this cache.

Redis clearing scans and deletes namespaced keys rather than using FLUSHDB. Memcached has no safe namespaced enumeration, so clearing replaces a shared random generation token instead of using a server-wide flush. Existing values from the old generation become unreachable and remain on the server until expiration or eviction (including indefinitely until eviction for TTL 0). Generation tokens are read from the server on each operation, so other workers see a clear immediately. Token eviction also causes safe misses, never resurrection of a previous generation.

Failures and concurrency

Backend connection failures, failed writes and corrupt cache data throw PhpOnWheels\Cache\CacheFailed. Invalid keys, values or TTLs throw InvalidArgumentException; invalid environment settings name the configuration key. There is no silent fallback to another driver and no success-shaped response when a backend is unavailable. Catch CacheFailed at an application boundary if your application deliberately chooses to log the failure and run without caching.

remember() is cache-aside, not a distributed lock: simultaneous misses may run the callback more than once. Callback exceptions propagate and are not cached. Concurrent writes are last-write-wins; clearing is not a transaction with concurrent writers. Do not rely on caches for uniqueness, authorization, a durable queue, or correctness-critical locking. Entries can be evicted early by the server.

The cache service retains no decoded values, callbacks or request objects in memory. Every read reaches the store, so external updates and clears are visible in long-lived workers. File writes use rename, letting readers see a complete old or new value. The same API works under both worker and per-request SAPIs.

Existing applications and tests

Already-created projects can add the binding below inside their boot callback. New blank and demo projects include it automatically. Configuration and remote connections are resolved lazily on the first use of Cache.

PHP

<?php
use PhpOnWheels\Cache\Cache;
use PhpOnWheels\Cache\CacheFactory;

$app->container->singleton(
    Cache::class,
    static fn (): Cache => CacheFactory::fromEnvironment($env, $root),
);

For offline tests, construct new Cache(new FileCacheStore($temporaryDirectory, 'test')) and clear/remove the temporary directory in teardown. Live integration tests use REDIS_HOST/REDIS_PORT and MEMCACHED_HOST/MEMCACHED_PORT, with their corresponding PHP extensions. Redis authentication can be supplied through REDIS_USERNAME/REDIS_PASSWORD.