Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ The package is intentionally small — four production files plus tests.
- **`src/RepositoryCacheDecorator.php`** — repository-flavored subclass. Identical to `CacheDecorator` except `$config_key` is overridden to `'repository_cache'`, so it reads its config from a separate namespace.
- **`initExcludes()`** auto-adds the decorator's own protected/public method names to `$excludes` so they aren't intercepted by `__call`. If you add new helper methods on `CacheDecorator`, add them to the `$defaults` list in `initExcludes()` or `__call` will try to forward them to the decorated object.
- **`cacheMiss()` sentinel**: a shared `stdClass` instance (stored on `private static ?object $missMarker` and lazily initialised) returned by `getCache()` whenever the cache entry is absent or reads are bypassed (`ttl === null`). `__call()` compares the return of `getCache()` with `=== $this->cacheMiss()` to detect a true miss. This lets the decorator round-trip falsy cached values (`0`, `''`, `[]`, `false`, `null`) without refetching on every call. Custom per-method overrides in subclasses should follow the same pattern (`if ($res === $this->cacheMiss())`), not `if (!$res)`.
- **Cache key format** is `{prefix_key}.{method}.{argKey}={argVal}...` built by `generateCacheKey()` using `Illuminate\Support\Arr::dot($arguments)`. Note: arguments that are objects are not supported (a known TODO in the class header).
- **Cache key format** is `{prefix_key}.{method}.{argKey}={argVal}...` built by `generateCacheKey()` using `Illuminate\Support\Arr::dot($arguments)`. Each dotted leaf value is folded into the key through the protected, overridable `normalizeArgument(mixed): string` seam: scalars/null/bool cast as-is (so existing keys stay byte-identical), `UrlRoutable` uses `getRouteKey()`, `BackedEnum` uses `->value`, `Stringable`/`__toString` string-cast, and anything else falls back to `json_encode` (or `md5(serialize())`). Object arguments are therefore supported; subclasses can override `normalizeArgument()` to customize identity.
- **TTL semantic**: `$ttl` is in **seconds** (Laravel 5.8+ `Cache::put` API) and may also be `DateInterval` / `DateTimeInterface`. `$ttl === null` is a sentinel that bypasses both reads and writes to the cache — `getCache()` short-circuits to the `cacheMiss()` sentinel without touching the `Cache` facade so `__call()` proceeds to invoke the decorated method, and `putCache()` returns `false` without storing anything.
- **`enabled` flag**: when `false` (set via the property, `setEnabled(false)`, or `{$config_key}.enabled = false`), `__call()` returns `callMethod()` directly at the top, skipping `isMethodCacheable`, cache get/put, and tag flushing. This is the on/off switch for caching.
- **`enabled` flag**: when `false` (set via the property, `disable()`, or `{$config_key}.enabled = false`), `__call()` returns `callMethod()` directly at the top, skipping `isMethodCacheable`, cache get/put, and tag flushing. This is the on/off switch for caching.
- **Fluent configuration**: the chainable setters `ttl()`, `enable()`, `disable()`, `prefix()`, `withTags()`, `tagCleaners()`, and `exclude(...)` all return `static`. There are no `setTtl()`/`setEnabled()` setters. Every fluent method name must be listed in the `initExcludes()` defaults or `__call()` would forward it to the decorated object instead of running on the decorator.
- **Config** is read in `getConfig()` from `{$this->config_key}.*` (`ttl`, `enabled`, `use_tags`) plus `app.debug` for the `$debug` flag controlling `Log::debug` output. The base default is `'cache_decorator'`; `RepositoryCacheDecorator` overrides it to `'repository_cache'`.
- **`src/ServiceProvider.php`** — publishes both config files: `config/cache_decorator.php` under the `cache-decorator-config` tag and `config/repository_cache.php` under the `repository-cache-config` tag; `register()` is empty. The provider is auto-discovered via `extra.laravel.providers` in `composer.json`.

Expand All @@ -36,5 +37,5 @@ The classes use Laravel facades (`Cache`, `Config`, `Log` from `Illuminate\Suppo
- Generic subclasses extend `CacheDecorator` (or `RepositoryCacheDecorator` if you want the `repository_cache.*` config namespace), set `$ttl`, `$prefix_key`, `$excludes`, `$tag_cleaners`, `$tags` as protected properties, and either inject the decorated instance via the constructor or override `decoratedClass()` returning a FQCN string.
- Inside custom method overrides, reference the inner object as `$this->decorated`.
- To customize caching for a single method, override it in the subclass and use the protected helpers `generateCacheKey()`, `getCache()`, `putCache()` (see README example).
- TTL values throughout (subclass `$ttl`, `cache_decorator.ttl` / `repository_cache.ttl` config, calls to `setTtl()`) are in seconds — this changed from "minutes" when upgrading from the Laravel 5.x line.
- TTL values throughout (subclass `$ttl`, `cache_decorator.ttl` / `repository_cache.ttl` config, calls to `ttl()`) are in seconds — this changed from "minutes" when upgrading from the Laravel 5.x line.
- **Keep the README in sync.** Any `src/` change that affects the main classes (`CacheDecorator`, `RepositoryCacheDecorator`, `ServiceProvider`) and their public-facing surface — property types/signatures, subclassing conventions, config keys, the `__call` flow, or anything a user copy-pastes from a usage example — must be documented in `README.md` as part of the same change. This is a core part of the package's DX and ease of use: the README usage examples should compile and run cleanly against the current code and follow the same conventions as the test stubs under `src/tests/Stubs/`.
54 changes: 50 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ class CachedReportingService extends CacheDecorator {
}
```

> **TTL is read from config**, not from a `$ttl` property. The constructor calls `getConfig()`, which overwrites `$ttl` from `cache_decorator.ttl` (default `300` seconds; `repository_cache.ttl` for `RepositoryCacheDecorator`). To override it per-instance, call `setTtl(...)` after construction (e.g. in your subclass constructor) — it accepts `int` seconds, a `DateInterval`, a `DateTimeInterface`, or `null` to bypass the cache entirely.
> **TTL is read from config**, not from a `$ttl` property. The constructor calls `getConfig()`, which overwrites `$ttl` from `cache_decorator.ttl` (default `300` seconds; `repository_cache.ttl` for `RepositoryCacheDecorator`). To override it per-instance, call `->ttl(...)` after construction (e.g. in your subclass constructor) — it accepts `int` seconds, a `DateInterval`, a `DateTimeInterface`, or `null` to bypass the cache entirely. See [Fluent configuration](#fluent-configuration) for the full chainable grammar.

…and use it like this:

Expand All @@ -64,7 +64,52 @@ $cached->dailyTotals('2026-05-12'); // cache miss → calls ReportingService::da
$cached->dailyTotals('2026-05-12'); // cache hit → returns the cached value
```

The decorator forwards any method not listed in `$excludes` to the underlying object via `__call()` and caches the result. Forwarding goes through Laravel's `ForwardsCalls` trait, so calls also reach methods the decorated object exposes through *its own* `__call()` magic — not just declared methods. Calling a method that exists nowhere on the decorated object throws `UndefinedMethodException` (see [Exceptions](#exceptions)) with the message `Call to undefined method {Decorator}::{method}()`. *The current version doesn't support objects as method arguments — coming in v1.0.0.*
The decorator forwards any method not listed in `$excludes` to the underlying object via `__call()` and caches the result. Forwarding goes through Laravel's `ForwardsCalls` trait, so calls also reach methods the decorated object exposes through *its own* `__call()` magic — not just declared methods. Calling a method that exists nowhere on the decorated object throws `UndefinedMethodException` (see [Exceptions](#exceptions)) with the message `Call to undefined method {Decorator}::{method}()`. Method arguments may be scalars, arrays, or objects — see [Object arguments in cache keys](#object-arguments-in-cache-keys).

### Fluent configuration

Every configuration setter is chainable (returns the decorator) and there's an expressive, self-documenting grammar for tuning an instance at runtime — no subclass property edits required:

```PHP
$cached = (new CachedReportingService(new ReportingService))
->ttl(600) // TTL in seconds (also DateInterval / DateTimeInterface / null)
->prefix('reports') // cache-key prefix
->withTags(['reports']) // cache tags (tag-capable store)
->tagCleaners(['recompute']) // methods that flush the tags after running
->exclude('debugDump'); // never cache these methods

$cached->totals(); // configured and cached
```

| Method | Returns | Behavior |
|---|---|---|
| `ttl($ttl)` | `static` | Set TTL (seconds / `DateInterval` / `DateTimeInterface` / `null` to bypass). |
| `enable()` | `static` | Turn caching on. |
| `disable()` | `static` | Turn caching off (forwards straight to the inner object). |
| `prefix(?string)` | `static` | Set the cache-key prefix. |
| `withTags(array)` | `static` | Set the cache tags. |
| `tagCleaners(array)` | `static` | Set the methods that flush the tags. |
| `exclude(string ...)` | `static` | Append method names to the never-cache list. |

> If you add your own public methods to a `CacheDecorator` **subclass**, remember that any method *not* listed in `$excludes` is forwarded to the inner object by `__call()`. The fluent methods above are already excluded by the base class, so chaining always resolves on the decorator and never leaks to the inner instance.

### Object arguments in cache keys

Method arguments may be scalars, arrays, or **objects** — the decorator folds each argument into the cache key through a stable identity rule:

1. **Scalars / `null` / `bool`** — cast as-is (existing scalar-only keys are byte-identical, so upgrading invalidates nothing).
2. **`UrlRoutable`** (Eloquent models, etc.) — uses `getRouteKey()`, the model's natural stable identity. Two distinct instances with the same route key share a cache entry.
3. **`BackedEnum`** — uses its `->value`; **`Stringable` / `__toString`** — string-cast.
4. **Anything else** (plain objects, nested arrays of objects) — a bounded, stable hash (`json_encode`, falling back to `md5(serialize(...))`).

Arrays are flattened (`Arr::dot`) and each leaf runs through the same rule, so nested objects are handled too.

```PHP
$cached->reportFor($user); // User implements UrlRoutable → keyed by getRouteKey()
$cached->reportFor($sameUserReloaded); // same route key → cache hit
```

To customize how a given argument type contributes to the key, override the protected `normalizeArgument(mixed $value): string` seam in your subclass — it sits alongside `generateCacheKey()`, `getCache()`, and `putCache()` as a first-class extension point.

### Fluent / self-returning methods

Expand Down Expand Up @@ -242,10 +287,11 @@ Environment variables:

A few breaking changes tightened the public contract:

- **TTL bypass uses `null`, not `false`.** The "skip the cache" sentinel for `$ttl` is now `null`. The property type is `int|DateInterval|DateTimeInterface|null` (default `null`) and `setTtl()` has the same typed signature — replace any `protected $ttl = false;` with `protected $ttl = null;` and any `setTtl(false)` with `setTtl(null)`.
- **TTL bypass uses `null`, not `false`.** The "skip the cache" sentinel for `$ttl` is now `null`. The property type is `int|DateInterval|DateTimeInterface|null` (default `null`) and `ttl()` has the same typed signature — replace any `protected $ttl = false;` with `protected $ttl = null;` and any `ttl(false)` with `ttl(null)`.
- **The `void` setters were dropped for a fluent grammar.** `setTtl()` / `setEnabled(bool)` have been removed in favor of the chainable `ttl()`, `enable()`, and `disable()` methods (see [Fluent configuration](#fluent-configuration)). Replace `setTtl($n)` with `ttl($n)`, `setEnabled(true)` with `enable()`, and `setEnabled(false)` with `disable()`.
- **`$tags` and `$tag_cleaners` are plain arrays.** Both default to `[]` (no longer `array|false`). If the cache driver doesn't support tags (or `use_tags` is disabled in config), they are reset to `[]` rather than `false`. Custom subclasses that initialized either property to `false` should switch to `[]`.
- **Falsy cached values round-trip correctly.** Previously a method returning `0`, `''`, `[]`, or `false` would look like a cache miss and be refetched on every call. `getCache()` now returns a `cacheMiss()` sentinel (a shared `stdClass`) on a true miss, and `__call()` compares with `===` — so falsy results are cached and served from cache as expected. If you wrote a custom method override with `if (!$res)` around `getCache()`, switch it to `if ($res === $this->cacheMiss())` (see the override example above).
- **The `enabled` flag now actually short-circuits caching.** Setting `$enabled = false` (via the property, `setEnabled(false)`, or `{$config_key}.enabled = false`) now causes `__call()` to forward straight to the decorated object, skipping cache reads, writes, and tag flushing.
- **The `enabled` flag now actually short-circuits caching.** Setting `$enabled = false` (via the property, `disable()`, or `{$config_key}.enabled = false`) now causes `__call()` to forward straight to the decorated object, skipping cache reads, writes, and tag flushing.

### From the repository-only version

Expand Down
122 changes: 111 additions & 11 deletions src/CacheDecorator.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@

// At least for now there's a Laravel dependency, if there's need, this can be
// converted to something more generic
use BackedEnum;
use DateInterval;
use DateTimeInterface;
use Illuminate\Contracts\Routing\UrlRoutable;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Traits\ForwardsCalls;
use Stringable;
use Trm42\CacheDecorator\Exceptions\MissingDecoratedObjectException;
use Trm42\CacheDecorator\Exceptions\UndefinedMethodException;

Expand Down Expand Up @@ -39,7 +42,6 @@
* @todo Add some kind of timer functionality to monitor result and cache speed
* @todo How to handle empty returns (maybe config whether to cache empty or not and the placeholder)
* @todo How to live without Laravel dependencies?
* @todo What if the decorated method parameters are objects? O___O
*/
abstract class CacheDecorator
{
Expand Down Expand Up @@ -127,32 +129,92 @@ public function __construct(?object $decorated = null)
*/
protected function initExcludes(): void
{
$defaults = ['decoratedClass', 'setTtl', 'setEnabled', 'getConfig', 'initDecorated',
$defaults = ['decoratedClass', 'getConfig', 'initDecorated',
'doesMethodClearTag', 'clearCacheTag', 'getCache', 'putCache',
'isMethodCacheable', 'generateCacheKey', 'log', 'cacheMiss',
'forwardCallTo', 'forwardDecoratedCallTo', 'throwBadMethodCallException', ];
'isMethodCacheable', 'generateCacheKey', 'normalizeArgument', 'log', 'cacheMiss',
'forwardCallTo', 'forwardDecoratedCallTo', 'throwBadMethodCallException',
'ttl', 'enable', 'disable', 'prefix', 'withTags', 'tagCleaners', 'exclude', ];

$this->excludes = array_merge($defaults, $this->excludes);
}

/**
* Set Cache TTL
* Set the cache TTL for this instance.
*
* @param int|DateInterval|DateTimeInterface|null $ttl Cache time-to-live in seconds, or null to skip cache.
*/
public function setTtl(int|DateInterval|DateTimeInterface|null $ttl): void
public function ttl(int|DateInterval|DateTimeInterface|null $ttl): static
{
$this->ttl = $ttl;

return $this;
}

/**
* Turn caching on for this instance.
*/
public function enable(): static
{
$this->enabled = true;

return $this;
}

/**
* Turn caching off for this instance (forwards straight to the decorated object).
*/
public function disable(): static
{
$this->enabled = false;

return $this;
}

/**
* Enable or disable caching
* Set the cache key prefix at runtime.
*/
public function prefix(?string $prefix): static
{
$this->prefix_key = $prefix;

return $this;
}

/**
* Set the cache tags applied to this decorator's entries. Requires a
* tag-capable cache store.
*
* @param bool $bool True == enable
* @param list<string> $tags
*/
public function setEnabled(bool $bool): void
public function withTags(array $tags): static
{
$this->enabled = $bool;
$this->tags = $tags;

return $this;
}

/**
* Set the methods that flush the cache tags after running. Requires a
* tag-capable cache store.
*
* @param list<string> $methods
*/
public function tagCleaners(array $methods): static
{
$this->tag_cleaners = $methods;

return $this;
}

/**
* Append one or more method names to the excludes list so they are never
* cached (forwarded straight to the decorated object).
*/
public function exclude(string ...$methods): static
{
$this->excludes = array_values([...$this->excludes, ...$methods]);

return $this;
}

/**
Expand Down Expand Up @@ -409,7 +471,7 @@ protected function generateCacheKey(string $method, array $arguments): string
$params = '';

foreach ($temp_params as $k => $v) {
$params .= ".{$k}={$v}";
$params .= ".{$k}=".$this->normalizeArgument($v);
}

$key = "{$this->prefix_key}.{$method}{$params}";
Expand All @@ -419,6 +481,44 @@ protected function generateCacheKey(string $method, array $arguments): string
return $key;
}

/**
* Normalize a single (already dotted) argument value into a stable string
* token for the cache key. Override this in a subclass to customize how a
* given argument type contributes to the key.
*
* Resolution order:
* 1. Scalars / null / bool → cast as-is (keeps existing keys byte-identical).
* 2. UrlRoutable (Eloquent models, etc.) → getRouteKey() — natural, stable identity.
* 3. BackedEnum → its ->value; Stringable / __toString → string cast.
* 4. Anything else (plain objects, closures-as-data) → a bounded, stable
* hash (json_encode when encodable, otherwise md5(serialize())).
*
* @param mixed $value A leaf argument value to fold into the cache key
* @return string Stable string token representing the value
*/
protected function normalizeArgument(mixed $value): string
{
if ($value === null || is_scalar($value)) {
return (string) $value;
}

if ($value instanceof UrlRoutable) {
return (string) $value->getRouteKey();
}

if ($value instanceof BackedEnum) {
return (string) $value->value;
}

if ($value instanceof Stringable || (is_object($value) && method_exists($value, '__toString'))) {
return (string) $value;
}

$json = json_encode($value);

return $json !== false ? $json : md5(serialize($value));
}

/**
* Simple wrapper around the Log facade to get logging when necessary
*
Expand Down
Loading