Skip to content

Repository files navigation

CacheLayer

Security & Standards Packagist Downloads License: MIT Packagist Version Packagist PHP Version

CacheLayer is a PHP 8.3+ caching toolkit built around four deliberately separate concerns:

CacheLayer
├── Cache
│   ├── PSR-6 and PSR-16
│   ├── generation-tagged records
│   ├── bounded stampede protection
│   ├── optional atomic coordination
│   └── tiering
├── Node Cache
│   └── APCu L1 → SQLite L2
├── Cluster Cache
│   └── durable invalidation between Node Caches
├── Atomic Counters
└── Process-local Memoization

The ordinary cache is disposable storage. Cluster Cache distributes invalidations, not values. Atomic cache coordination is an optional backend capability for conditional claim/replace/consume workflows; unsupported stores return no capability rather than emulating it. Atomic counters remain outside the cache contract because numeric mutation requires a different stronger contract. Memoization stays process-local.

Installation

composer require infocyph/cachelayer

Choose extensions and client packages only for the backends you use: APCu, Redis/Valkey, Memcached, PDO, SysV shared memory, MongoDB, or Cassandra/ScyllaDB.

Cache

use Infocyph\CacheLayer\Cache\Cache;

$cache = Cache::sqlite('app', '/var/cache/my-app/cache.sqlite');

$cache->setMultiple([
    'profile.1' => ['name' => 'Ada'],
    'profile.2' => ['name' => 'Grace'],
], 300);

$profiles = $cache->getMultiple(['profile.1', 'profile.2', 'profile.3']);
$cache->deleteMultiple(['profile.1', 'profile.2']);

Cache implements PSR-6, PSR-16, ArrayAccess, and capability-provider interfaces. It intentionally does not implement Countable, magic property access, runtime namespace mutation, or compatibility aliases. Keys and tags must be 1–64 characters and match [A-Za-z0-9_.-]+; invalid bulk input is rejected before storage is changed.

Namespaces are configured separately from logical keys. Do not encode a namespace as namespace:key: : is reserved by PSR-6/PSR-16 and remains invalid at CacheLayer's public key boundary. For example:

$cache = Cache::redis('mytm', client: $redis);
$cache->set('user', $user); // namespace = mytm, logical key = user

Adapters map that pair into their own internal metadata/data key space. The internal physical representation is an implementation detail and must not be supplied as a public cache key.

A callable passed as the PSR-16 get() default is returned as a value. Use the explicit remember() API to compute and persist a miss:

$user = $cache->remember(
    'user.42',
    fn () => $repository->find(42),
    ttl: 300,
    tags: ['users'],
);

remember() follows get → miss → lock → recheck → resolve → save → release. Lock waiting is bounded; a timeout computes fail-open and records the unlocked computation. No lock operation occurs on a hit.

Atomic cache coordination

Atomic operations are an optional capability, not part of PSR-6/PSR-16 or the base CacheInterface:

$atomic = $cache->atomic();
if ($atomic === null) {
    throw new RuntimeException('Selected cache backend cannot coordinate atomically.');
}

// Exactly one concurrent claimant can create a live claim.
if (!$atomic->setIfAbsent('webhook.claim.42', true, 300)) {
    // Already claimed, or a fail-open backend failure returned the fallback.
}

// Replace only the existing live value that strictly matches with PHP ===.
$advanced = $atomic->compareAndSet('workflow.state.42', 'pending', 'running', 300);

// One caller receives and consumes this state.
$state = $atomic->getAndDelete('oauth.state.42');

setIfAbsent() stores the encoded value and TTL as one conditional backend operation. compareAndSet() replaces an existing live logical value only when the decoded value matches the expected value with PHP strict equality (===); absence is distinct from a cached null, so use setIfAbsent() when absence is the condition. getAndDelete() returns and consumes one live value atomically. CacheLayer never emulates these primitives with public has()/get() plus set()/delete() calls. Atomic replacement/claim TTL accepts relative seconds, DateInterval, or an absolute DateTimeInterface; a non-positive resolved TTL is a no-op and returns false.

Backend Atomic cache coordination Scope
Array memory Yes one PHP process
WeakMap Yes one PHP process / adapter instance
Shared memory Yes one host / shared SysV segment
Redis / Valkey Yes supplied authoritative Redis-compatible store
Redis Cluster Yes stable CacheLayer hash-slot bucket
MongoDB Yes supplied authoritative collection
Memcached Yes supplied Memcached key/CAS domain
SQLite PDO Yes supplied SQLite database
PostgreSQL PDO Yes supplied PostgreSQL database
MySQL / MariaDB PDO Yes supplied transactional database
File / PHP files Yes one reliable filesystem lock domain
APCu No no arbitrary-value CAS / consume primitive
Generic / unknown PDO No no portable cross-driver atomic contract
ScyllaDB No ordinary cache writes do not use the LWT architecture required for the full contract
Null / Tiered No no single retained authoritative coordination domain

Memcached uses native add()/CAS primitives and a reserved tombstone for linearizable logical consumption. File and PHP-file caches route ordinary key writers and atomic mutations through the same deterministic per-key flock(); their guarantee is therefore limited to a filesystem where that lock domain is reliable. PDO capability is runtime-qualified: SQLite, PostgreSQL, and MySQL/MariaDB expose it, while unknown PDO drivers do not. SQLite coordinates writers with BEGIN IMMEDIATE; PostgreSQL and MySQL/MariaDB use transactional row locking. Atomic PDO calls require CacheLayer to own the transaction and reject execution inside a caller-owned active transaction.

Use dedicated, untagged keys for portable replay claims, nonces, challenges, state transitions, and one-time state. Tag rotation is a separate invalidation mechanism and is not part of the portable atomic linearization boundary. Redis/Valkey, Redis Cluster, and MongoDB therefore reject compareAndSet() on tagged records. Array memory and SharedMemory can validate tag generations inside their own local atomic domain, but callers should not depend on tagged CAS when code must be portable across backends. Tiered caches remain non-atomic even when an individual tier supports the capability.

For security-sensitive coordination, use failOpen: false when the caller must distinguish a backend outage from a normal conditional miss. With fail-open enabled, setIfAbsent() and compareAndSet() fall back to false, getAndDelete() returns the supplied default, and backend_failure is recorded.

Tags and expiration

$cache->setTagged('article.7', $article, ['articles', 'author.12'], 600);
$cache->invalidateTags(['articles', 'author.12']);

Each tagged record embeds its complete snapshot of opaque 128-bit tag generations. Invalidation replaces each generation, and reads fetch all required generations in a batch. A missing or mismatched generation makes the complete record stale, so lost metadata cannot resurrect an older record. There are no per-entry reverse tag indexes or partially tagged writes.

Zero and negative PSR-16 TTLs delete the key. Namespaces are validated—not normalized—and must be 1–64 characters matching [A-Za-z0-9_.-]+.

Native bulk paths

Bulk methods validate once and call the adapter’s native bulk contract. Deferred PSR-6 items are also persisted through the same bulk path on commit().

Backend Bulk read/write strategy
Array memory direct array lookup/update
WeakMap one prune pass plus direct lookup
Null store immediate misses/no-op writes
APCu array apcu_fetch, grouped stores
Redis / Valkey MGET, MSET, pipelined TTL writes
Memcached getMulti, TTL-grouped setMulti
PDO chunked IN (...), multi-row upsert
MongoDB $in, bulkWrite
ScyllaDB partition-bucketed IN, bounded unlogged batches
Shared memory one lock per batch operation
File / PHP files optimized sequential filesystem access
Redis Cluster fixed hash buckets and same-slot grouped operations

Redis Cluster uses 128 stable bucket hash tags. Memcached and Redis Cluster clear a namespace by replacing opaque namespace/bucket generations, so they do not scan, flush other namespaces, or maintain a permanent key membership index.

Adapters

The public factories are:

Cache::memory();       Cache::weakMap();      Cache::nullStore();
Cache::apcu();         Cache::file();         Cache::phpFiles();
Cache::sharedMemory(); Cache::redis();        Cache::valkey();
Cache::redisCluster(); Cache::memcached();    Cache::pdo();
Cache::sqlite();       Cache::mongodb();      Cache::scylla();
Cache::tiered([...]);

Data and internal metadata use physically separate key spaces. Adapters are public for PSR-6 use, but tagging, stampede protection, policy-aware error handling, atomic capability discovery, and metrics are facade responsibilities; use Cache for consistent CacheLayer semantics. SQL-like stores can install schema explicitly with PdoCacheSchema::install() and pass initializeSchema: false to PdoCacheAdapter in deployment-controlled environments.

phpFiles creates executable PHP files and is only appropriate for a trusted directory and trusted payloads. Never point SQLite at NFS, SMB, or another shared network filesystem.

Tiering

$cache = Cache::tiered([
    ['driver' => 'apcu', 'namespace' => 'app'],
    ['driver' => 'valkey', 'namespace' => 'app'],
]);

A bulk read asks L1 for the full batch, asks later tiers only for remaining keys, and promotes hits upward in batches. Writes and deletes are one batch per participating tier. The tiered facade intentionally does not expose atomic cache coordination because multiple tiers cannot form one linearizable authority.

Immutable security and failure policy

Payload and runtime policy is provided at construction and never stored globally:

use Infocyph\CacheLayer\Cache\Cache;
use Infocyph\CacheLayer\Cache\CacheOptions;

function createCache(string $integrityKey): Cache
{
    return Cache::redis('app', options: new CacheOptions(
        integrityKey: $integrityKey,
        maxPayloadBytes: 8_388_608,
        compressionThreshold: 4096,
        compressionLevel: 6,
        allowClosures: false,
        allowObjects: false,
        failOpen: true,
    ));
}

Records use only the CacheLayer v2 markers cl2:, cl2-gz:, and cl2-sig:. Compression is threshold-based and retained only when smaller. HMAC verification, payload bounds, bounded decompression, and deserialization policy are isolated per cache instance. Corrupt payloads are safe misses.

Construction and configuration errors throw. Runtime backend failures default to fail-open: reads become misses, writes/deletes return false, and backend_failure is recorded. Set failOpen: false to propagate runtime failures. Pass deploy-varying values from the application's composition root; CacheLayer never reads process environment state.

Node Cache

use Infocyph\CacheLayer\Node\NodeCache;
use Infocyph\CacheLayer\Node\NodeCacheConfig;

$node = NodeCache::create(new NodeCacheConfig(
    namespace: 'app',
    sqliteFile: '/var/cache/my-app/cache.sqlite',
));

Node Cache combines an APCu L1 with a local SQLite L2, uses miss-only bulk L2 reads and bulk L1 promotion, and retains WAL, synchronous=NORMAL, bounded busy timeout, bounded pruning, checkpoint, and optimization maintenance.

Cluster Cache

Cluster Cache adds durable invalidation around independent Node Caches. It keeps per-node cursors, replay, retention-gap recovery, consumer status, key/tag/namespace invalidation, bounded draining, PDO or Redis/Valkey Streams transports, and a transactional outbox.

$runtime->invalidateKey('product.42');
$runtime->invalidateTags(['products', 'catalog']);
$runtime->clearNamespace();
$runtime->consume();

Failed local invalidation stops consumption without advancing the cursor; operators can repair the cause and retry. A poison event can only be skipped explicitly with skipEventAfterClear(), which clears the local namespace before advancing. Plain key invalidation cannot fence an in-flight resolver, so mutable read-through data that requires ordering should also use a tag generation. It does not replicate values and is not a distributed lock, session store, or counter system.

Atomic counters and memoization

AtomicCounters uses an AtomicCounterStoreInterface; Redis/Valkey is the distributed implementation. Counters are never emulated with cache get() plus set(). Atomic counters are separate from Cache::atomic(): counters mutate numeric state, while the cache capability provides conditional claim/replace/consume primitives for encoded cache records.

The memoize(), remember(object: ...), and once() helpers plus MemoizeTrait provide bounded process-local memoization. Their state survives requests in persistent workers until evicted or reset with flush_memoizers(); call that reset at request boundaries when cross-request reuse is not intended. They are independent of persistent backend caching.

Metrics and benchmarks

Metrics distinguish calls from key volume: get_batch, get_batch_keys, hits/misses, set/delete batch counts, tag-generation fetches, promotions, lock outcomes, atomic claim/compare/consume outcomes, and backend failures. exportMetrics() returns a snapshot and can invoke an export hook.

PHPBench scenarios in benchmarks/ cover single operations, 10/100/1000-key bulk operations, tagged/plain records, tier and Node promotion, codec security/compression, and remember paths. Backend-focused tests separately verify operation counts for native bulk calls. These are microbenchmarks, not production throughput claims.

Development

composer ic:doctor
composer ic:process
composer ic:tests

Integration suites self-skip when their optional service or extension is unavailable.

Security

Do not disclose suspected vulnerabilities in a public issue, discussion or pull request. Follow SECURITY.md and use GitHub private vulnerability reporting.

CacheLayer is protected by PHPForge, which provides automated tests, static and taint analysis, dependency auditing, architecture checks and release-readiness gates. Automated controls do not replace responsible disclosure or manual review.


Made with ❤️ for the PHP community
MIT Licensed
DocumentationSecurityCode of ConductContributing
🗂️ BugFeatureDocumentationQuestionCI failure
🔀 GeneralBug fixFeatureRefactorPerformanceSecurity & reliabilityDocumentationMaintenance

About

A standalone cache toolkit for modern PHP applications. It provides a unified API over PSR-6 and PSR-16 with local, distributed and cloud adapters.

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages