diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2bc2e77..f633d38 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -116,7 +116,7 @@ jobs: CACHELAYER_PG_USER: postgres CACHELAYER_PG_PASS: postgres run: | - find src tests -name '*.php' -print0 | xargs -0 -n1 php -l > /dev/null + composer test:syntax composer test:code composer test:lint composer test:sniff @@ -156,7 +156,7 @@ jobs: run: composer install --no-interaction --prefer-dist --no-progress - name: Composer Audit (Release Guard) - run: composer audit --abandoned=ignore + run: composer release:audit - name: Quality Gate (PHPStan) run: composer test:static diff --git a/README.md b/README.md index 13565e8..368dfee 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,11 @@ CacheLayer is 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. +## Project Background + +CacheLayer was separated from the existing Intermix project to improve package +visibility, maintenance focus, and faster feature enrichment for caching. + ## Features - Unified `Cache` facade implementing PSR-6, PSR-16, `ArrayAccess`, and `Countable` @@ -57,12 +62,7 @@ $metrics = $cache->exportMetrics(); ## Documentation -- User docs are in `docs/` -- Build docs locally with Sphinx (if installed): - -```bash -make -C docs html -``` +https://docs.infocyph.com/projects/CacheLayer ## Testing diff --git a/captainhook.json b/captainhook.json index bc08ef3..fa19900 100644 --- a/captainhook.json +++ b/captainhook.json @@ -15,7 +15,7 @@ "options": [] }, { - "action": "composer audit --no-interaction --abandoned=ignore", + "action": "composer release:audit", "options": [] }, { diff --git a/docs/adapters/apcu.rst b/docs/adapters/apcu.rst index 227aa10..3dd761a 100644 --- a/docs/adapters/apcu.rst +++ b/docs/adapters/apcu.rst @@ -19,3 +19,23 @@ Highlights: * efficient bulk fetch through APCu array fetch path ``Cache::local()`` will choose APCu automatically when available. + +Use When +-------- + +Use APCu when your cache can stay in local runtime memory and you want very +low latency without network calls. + +Example +------- + +.. code-block:: php + + use Infocyph\CacheLayer\Cache\Cache; + + $cache = Cache::apcu('app'); + $cache->set('feature_flag:new_checkout', true, 60); + + if ($cache->has('feature_flag:new_checkout')) { + // fast local hit + } diff --git a/docs/adapters/dynamodb.rst b/docs/adapters/dynamodb.rst index e74ccab..fa05dc7 100644 --- a/docs/adapters/dynamodb.rst +++ b/docs/adapters/dynamodb.rst @@ -26,3 +26,18 @@ Supported injected client methods: * ``deleteItem`` * ``scan`` * ``batchWriteItem`` + +Example +------- + +.. code-block:: php + + use Infocyph\CacheLayer\Cache\Cache; + + $cache = Cache::dynamoDb( + namespace: 'edge', + table: 'cachelayer_entries', + config: ['region' => 'us-east-1', 'version' => 'latest'], + ); + + $cache->set('homepage:blocks', $blocks, 45); diff --git a/docs/adapters/file.rst b/docs/adapters/file.rst index 8b5ef85..ff97b47 100644 --- a/docs/adapters/file.rst +++ b/docs/adapters/file.rst @@ -22,3 +22,18 @@ Highlights: * ``setNamespaceAndDirectory()`` supported Best for local/single-host environments. + +Example +------- + +.. code-block:: php + + use Infocyph\CacheLayer\Cache\Cache; + + $cache = Cache::file('catalog', __DIR__ . '/storage/cache'); + + $cache->setTagged('category:shoes', ['count' => 120], ['catalog'], 300); + $payload = $cache->get('category:shoes'); + + // Flush all catalog-tagged entries after product import. + $cache->invalidateTag('catalog'); diff --git a/docs/adapters/index.rst b/docs/adapters/index.rst index ae61adf..1c65de3 100644 --- a/docs/adapters/index.rst +++ b/docs/adapters/index.rst @@ -7,6 +7,13 @@ Cache Adapters CacheLayer ships multiple adapters for different runtime and infrastructure needs. +Choosing quickly: + +* Start with ``file`` or ``pdo`` for most applications. +* Use ``memory``/``apcu`` for fastest local access. +* Use ``redis``/``memcache`` for distributed deployments. +* Use cloud adapters (``mongodb``, ``dynamoDb``, ``s3``) when cache must live outside app hosts. + .. toctree:: :maxdepth: 1 diff --git a/docs/adapters/memcached.rst b/docs/adapters/memcached.rst index 11bdc63..bb12cc4 100644 --- a/docs/adapters/memcached.rst +++ b/docs/adapters/memcached.rst @@ -20,3 +20,19 @@ Highlights: * factory auto-configures ``MemcachedLockProvider`` for ``remember()`` when using this adapter You may pass your own preconfigured ``Memcached`` client. + +Example +------- + +.. code-block:: php + + use Infocyph\CacheLayer\Cache\Cache; + + $cache = Cache::memcache('session', [ + ['127.0.0.1', 11211, 100], + ]); + + $state = $cache->remember('user:42:state', function ($item) { + $item->expiresAfter(120); + return loadSessionState(42); + }); diff --git a/docs/adapters/mongodb.rst b/docs/adapters/mongodb.rst index 229d8f7..a27ca76 100644 --- a/docs/adapters/mongodb.rst +++ b/docs/adapters/mongodb.rst @@ -27,3 +27,19 @@ Supported injected collection methods: * ``deleteOne`` * ``deleteMany`` * ``countDocuments`` + +Example +------- + +.. code-block:: php + + use Infocyph\CacheLayer\Cache\Cache; + + $cache = Cache::mongodb( + 'analytics', + database: 'app_cache', + collectionName: 'entries', + uri: 'mongodb://127.0.0.1:27017', + ); + + $cache->set('dashboard:kpi', ['orders' => 120, 'refunds' => 4], 120); diff --git a/docs/adapters/null-store.rst b/docs/adapters/null-store.rst index 5384849..1172839 100644 --- a/docs/adapters/null-store.rst +++ b/docs/adapters/null-store.rst @@ -15,3 +15,15 @@ Behavior: * ``remember()`` recomputes every call Useful for disabling caching without changing calling code. + +Example +------- + +.. code-block:: php + + use Infocyph\CacheLayer\Cache\Cache; + + $cache = Cache::nullStore(); + + $cache->set('foo', 'bar'); + $value = $cache->get('foo', 'fallback'); // "fallback" diff --git a/docs/adapters/pdo.rst b/docs/adapters/pdo.rst index eaffe07..3f96790 100644 --- a/docs/adapters/pdo.rst +++ b/docs/adapters/pdo.rst @@ -47,3 +47,20 @@ Examples: 'postgres', 'postgres', ); + +Typical Usage +------------- + +.. code-block:: php + + use Infocyph\CacheLayer\Cache\Cache; + + $cache = Cache::pdo('orders'); + + $summary = $cache->remember('orders:summary:today', function ($item) { + $item->expiresAfter(60); + return loadOrderSummary(); + }, tags: ['orders']); + + // Invalidate all related records after an order mutation. + $cache->invalidateTag('orders'); diff --git a/docs/adapters/php-files.rst b/docs/adapters/php-files.rst index d0239f2..060a3b8 100644 --- a/docs/adapters/php-files.rst +++ b/docs/adapters/php-files.rst @@ -21,3 +21,15 @@ Highlights: * ``setNamespaceAndDirectory()`` supported Good for environments where opcode cache integration is desired. + +Example +------- + +.. code-block:: php + + use Infocyph\CacheLayer\Cache\Cache; + + $cache = Cache::phpFiles('view-cache', __DIR__ . '/storage/php-cache'); + $cache->set('compiled:home', $compiledTemplate, 900); + + $compiled = $cache->get('compiled:home'); diff --git a/docs/adapters/redis-cluster.rst b/docs/adapters/redis-cluster.rst index cfe4711..7201d79 100644 --- a/docs/adapters/redis-cluster.rst +++ b/docs/adapters/redis-cluster.rst @@ -19,3 +19,17 @@ Highlights: * tracks namespace key membership through an index set (``:__keys``) for clear/count operations Useful when using Redis Cluster topology. + +Example +------- + +.. code-block:: php + + use Infocyph\CacheLayer\Cache\Cache; + + $cache = Cache::redisCluster( + 'checkout', + ['10.0.0.11:6379', '10.0.0.12:6379', '10.0.0.13:6379'], + ); + + $cache->set('cart:token:abc', ['items' => 3], 1200); diff --git a/docs/adapters/redis.rst b/docs/adapters/redis.rst index eb2b51a..b82bcfc 100644 --- a/docs/adapters/redis.rst +++ b/docs/adapters/redis.rst @@ -24,3 +24,17 @@ DSN notes: * host/port parsed from DSN * optional password and DB selection (``/db-index``) are supported + +Example +------- + +.. code-block:: php + + use Infocyph\CacheLayer\Cache\Cache; + + $cache = Cache::redis('api', 'redis://127.0.0.1:6379/0'); + + $response = $cache->remember('endpoint:/v1/users?page=1', function ($item) { + $item->expiresAfter(30); + return fetchApiPayload(); + }, tags: ['users']); diff --git a/docs/adapters/s3.rst b/docs/adapters/s3.rst index 8cbb0f0..83626ef 100644 --- a/docs/adapters/s3.rst +++ b/docs/adapters/s3.rst @@ -26,3 +26,19 @@ Supported injected client methods: * ``deleteObject`` * ``listObjectsV2`` * ``deleteObjects`` + +Example +------- + +.. code-block:: php + + use Infocyph\CacheLayer\Cache\Cache; + + $cache = Cache::s3( + namespace: 'reports', + bucket: 'my-cache-bucket', + config: ['region' => 'us-east-1', 'version' => 'latest'], + prefix: 'cachelayer/reports', + ); + + $cache->set('monthly:2026-04', $reportPayload, 3600); diff --git a/docs/adapters/serialization.rst b/docs/adapters/serialization.rst index f7efbe2..d137ced 100644 --- a/docs/adapters/serialization.rst +++ b/docs/adapters/serialization.rst @@ -13,4 +13,16 @@ The payload format stores: * absolute expiration timestamp (or null) * internal format marker +Example +------- + +.. code-block:: php + + use Infocyph\CacheLayer\Cache\Cache; + + $cache = Cache::memory('serialize-demo'); + $cache->set('payload', ['a' => 1, 'b' => [2, 3]], 60); + + $payload = $cache->get('payload'); + See :ref:`serializer` for resource handlers, closure support, and serializer API details. diff --git a/docs/adapters/shared-memory.rst b/docs/adapters/shared-memory.rst index 9769dd6..dc0ff43 100644 --- a/docs/adapters/shared-memory.rst +++ b/docs/adapters/shared-memory.rst @@ -20,3 +20,13 @@ Notes: * data is not portable across hosts * capacity limited by shared memory segment size + +Example +------- + +.. code-block:: php + + use Infocyph\CacheLayer\Cache\Cache; + + $cache = Cache::sharedMemory('worker-bus', 8 * 1024 * 1024); + $cache->set('heartbeat:worker-1', time(), 15); diff --git a/docs/adapters/sqlite.rst b/docs/adapters/sqlite.rst index fe20671..0d7f7b3 100644 --- a/docs/adapters/sqlite.rst +++ b/docs/adapters/sqlite.rst @@ -15,3 +15,13 @@ Equivalent behavior: Use ``Cache::pdo(...)`` directly if you want to switch to MySQL/MariaDB/PostgreSQL without changing the rest of your cache usage pattern. + +Example +------- + +.. code-block:: php + + use Infocyph\CacheLayer\Cache\Cache; + + $cache = Cache::sqlite('jobs', __DIR__ . '/storage/cache/jobs.sqlite'); + $cache->set('job:run:summary', ['ok' => 12, 'failed' => 1], 300); diff --git a/docs/adapters/weak-map.rst b/docs/adapters/weak-map.rst index 2ff3c62..ffe78a4 100644 --- a/docs/adapters/weak-map.rst +++ b/docs/adapters/weak-map.rst @@ -15,3 +15,16 @@ Object entries remain available while strongly referenced elsewhere. When an object is collected, its cache entry can disappear naturally. Use when you specifically want object lifecycle-aware caching. + +Example +------- + +.. code-block:: php + + use Infocyph\CacheLayer\Cache\Cache; + + $cache = Cache::weakMap('objects'); + $dto = (object) ['id' => 42, 'name' => 'Ada']; + + $cache->set('dto:42', $dto, 30); + $sameObject = $cache->get('dto:42'); diff --git a/docs/cache.rst b/docs/cache.rst index aa63338..658007e 100644 --- a/docs/cache.rst +++ b/docs/cache.rst @@ -15,6 +15,9 @@ It implements: It also adds tagged invalidation, stampede-safe ``remember()``, lock provider selection, metrics hooks, and payload compression controls. +CacheLayer was separated from the existing Intermix project for better +standalone visibility and faster cache-specific feature enrichment. + Installation ------------ @@ -190,12 +193,12 @@ Methods: Default collector is ``InMemoryCacheMetricsCollector``. -Metrics are grouped by adapter class and metric name, for example: +Metrics are grouped by readable adapter name and metric name, for example: .. code-block:: php [ - 'Infocyph\\CacheLayer\\Cache\\Adapter\\FileCacheAdapter' => [ + 'file' => [ 'hit' => 10, 'miss' => 4, 'set' => 3, diff --git a/docs/cookbook.rst b/docs/cookbook.rst new file mode 100644 index 0000000..66ace85 --- /dev/null +++ b/docs/cookbook.rst @@ -0,0 +1,85 @@ +.. _cookbook: + +========================== +Cookbook and Process Flows +========================== + +This page shows practical end-to-end usage patterns so you can wire CacheLayer +into real application flows quickly and safely. + +Flow 1: File Adapter (Single Host) +---------------------------------- + +Use this when your app runs on one machine (or shared filesystem) and you want +zero external dependencies. + +Process flow: + +1. Create the cache pool with a stable namespace and directory. +2. Read through cache using ``remember()``. +3. Tag related entries so updates can invalidate groups. +4. Export metrics for visibility. + +.. code-block:: php + + use Infocyph\CacheLayer\Cache\Cache; + + $cache = Cache::file('shop', __DIR__ . '/storage/cache'); + + // Read-through cache on miss with stampede protection. + $product = $cache->remember('product:42', function ($item) { + $item->expiresAfter(300); + return loadProductFromDatabase(42); + }, tags: ['products', 'product:42']); + + // On product update, invalidate only related cache. + $cache->invalidateTags(['products', 'product:42']); + + // Optional: inspect adapter-level metrics. + $metrics = $cache->exportMetrics(); + // ['file' => ['hit' => ..., 'miss' => ..., 'set' => ...]] + +Flow 2: PDO Adapter (SQLite Default, MySQL/PostgreSQL Ready) +------------------------------------------------------------- + +Use this when you want SQL-backed caching and easy portability across +SQLite/MySQL/MariaDB/PostgreSQL via PDO. + +Process flow: + +1. Start local/dev with default SQLite behavior (no DSN required). +2. Move to MySQL/PostgreSQL in staging/production by changing DSN only. +3. Keep the same cache API and tagging flow. +4. Keep stampede protection enabled via the auto-configured PDO lock provider. + +.. code-block:: php + + use Infocyph\CacheLayer\Cache\Cache; + + // Development: defaults to sqlite: + $cache = Cache::pdo('billing'); + + // Production example (PostgreSQL): + // $cache = Cache::pdo( + // 'billing', + // 'pgsql:host=127.0.0.1;port=5432;dbname=app', + // 'postgres', + // 'secret', + // ); + + $invoice = $cache->remember('invoice:2026-1001', function ($item) { + $item->expiresAfter(180); + return buildInvoicePayload(1001); + }, tags: ['invoices', 'customer:77']); + + // Invalidate by business scope when source data changes. + $cache->invalidateTag('customer:77'); + +Recommended Rollout Pattern +--------------------------- + +1. Start with ``Cache::local()`` or ``Cache::file()``. +2. Add tags to all business-domain cache keys. +3. Replace direct ``get()+set()`` misses with ``remember()``. +4. Watch ``exportMetrics()`` and tune TTL values. +5. Switch adapter backend only when scaling needs change. diff --git a/docs/index.rst b/docs/index.rst index 48f9abb..4725b26 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -13,6 +13,20 @@ CacheLayer is a standalone caching toolkit for PHP 8.3+ with: * value serialization for closures and resources * process-local memoization helpers (``memoize``, ``remember``, ``once``) +Project Background +------------------ + +CacheLayer was separated from the existing Intermix project for better package +visibility and focused feature enrichment around caching. + +How to Use This Manual +---------------------- + +1. Start with ``cache`` for the unified API and factory overview. +2. Read ``adapters/index`` to choose a backend and copy its example. +3. Follow ``cookbook`` for complete end-to-end process flows. +4. Use ``metrics-and-locking`` for production visibility and stampede control. + Quick Start ----------- @@ -35,6 +49,7 @@ Quick Start cache adapters/index + cookbook metrics-and-locking serializer memoize diff --git a/docs/metrics-and-locking.rst b/docs/metrics-and-locking.rst index eb82ef4..0dd035b 100644 --- a/docs/metrics-and-locking.rst +++ b/docs/metrics-and-locking.rst @@ -14,8 +14,10 @@ Cache facade metrics API: * ``setMetricsExportHook(?callable $hook): self`` Default collector is ``InMemoryCacheMetricsCollector``. +Exported snapshots use readable adapter keys such as ``file``, ``pdo``, +``redis``, ``memory``, and ``redis_cluster``. -Metric counters are tracked per adapter class, for example: +Metric counters are tracked per adapter name, for example: * ``hit`` * ``miss`` @@ -35,6 +37,7 @@ Metric counters are tracked per adapter class, for example: $cache->get('k'); $metrics = $cache->exportMetrics(); + // ['file' => ['set' => 1, 'hit' => 1, ...]] Locking and Stampede Protection ------------------------------- diff --git a/psalm.xml b/psalm.xml index bb0401c..49a4a35 100644 --- a/psalm.xml +++ b/psalm.xml @@ -3,8 +3,7 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="https://getpsalm.org/schema/config" xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd" - errorLevel="3" - findUnusedCode="true" + errorLevel="2" > diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index 0c4ac13..c261332 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -487,11 +487,13 @@ public function deleteMultiple(iterable $keys): bool } /** + * Returns metrics grouped by readable adapter name. + * * @return array> */ public function exportMetrics(): array { - $snapshot = $this->metrics->export(); + $snapshot = $this->readableMetricsSnapshot($this->metrics->export()); if ($this->metricsExportHook !== null) { ($this->metricsExportHook)($snapshot); } @@ -1174,6 +1176,50 @@ private function purgeKeyAndTagMeta(string $key): void $this->adapter->deleteItem($this->tagMetaKey($key)); } + private function readableAdapterName(string $adapterClass): string + { + $short = $adapterClass; + if (str_contains($short, '\\')) { + $parts = explode('\\', $short); + $short = end($parts); + } + + if (str_ends_with($short, 'CacheAdapter')) { + $short = substr($short, 0, -strlen('CacheAdapter')); + } + + return match ($short) { + 'Array' => 'memory', + 'MemCache' => 'memcache', + 'Null' => 'null_store', + 'PhpFiles' => 'php_files', + 'SharedMemory' => 'shared_memory', + 'WeakMap' => 'weak_map', + 'RedisCluster' => 'redis_cluster', + 'DynamoDb' => 'dynamodb', + 'MongoDb' => 'mongodb', + default => strtolower((string) preg_replace('/(?> $snapshot + * @return array> + */ + private function readableMetricsSnapshot(array $snapshot): array + { + $readable = []; + + foreach ($snapshot as $adapterClass => $counters) { + $name = $this->readableAdapterName((string) $adapterClass); + foreach ($counters as $metric => $count) { + $readable[$name][$metric] = ($readable[$name][$metric] ?? 0) + (int) $count; + } + } + + return $readable; + } + private function stampedeLockKey(string $key): string { return '__im_lock_' . hash('xxh128', $key); diff --git a/src/Cache/CacheInterface.php b/src/Cache/CacheInterface.php index a1f11de..eb8c0bd 100644 --- a/src/Cache/CacheInterface.php +++ b/src/Cache/CacheInterface.php @@ -32,6 +32,9 @@ public function clearCache(): bool; public function configurePayloadCompression(?int $thresholdBytes = null, int $level = 6): self; /** + * Returns metrics grouped by readable adapter name (for example ``file``, + * ``pdo``, ``redis``) and metric name. + * * @return array> */ public function exportMetrics(): array; diff --git a/tests/Cache/CacheFeaturesTest.php b/tests/Cache/CacheFeaturesTest.php index 60ef792..7087068 100644 --- a/tests/Cache/CacheFeaturesTest.php +++ b/tests/Cache/CacheFeaturesTest.php @@ -169,7 +169,7 @@ public function release(?LockHandle $handle): void $this->cache->get('x'); $metrics = $this->cache->exportMetrics(); - $adapter = Infocyph\CacheLayer\Cache\Adapter\FileCacheAdapter::class; + $adapter = 'file'; expect($metrics[$adapter]['miss'] ?? 0)->toBeGreaterThanOrEqual(1) ->and($metrics[$adapter]['hit'] ?? 0)->toBeGreaterThanOrEqual(1) @@ -202,4 +202,3 @@ public function release(?LockHandle $handle): void $this->cache->configurePayloadCompression(null); }); -