From 2a1d79d0dfe7b4f0f874c8f5bf46b8d64595a09e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matias=20M=C3=A4ki?= Date: Fri, 12 Jun 2026 00:36:09 +0200 Subject: [PATCH] Forward calls via ForwardsCalls trait instead of method_exists Replace the method_exists() gate in CacheDecorator::callMethod() with Laravel's ForwardsCalls trait. Calls now reach methods the decorated object exposes via its own __call() magic, and self-returning fluent methods return the decorator so chaining stays on the cached surface. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 29 ++++++++++++++++- src/CacheDecorator.php | 22 ++++++++----- src/tests/CachedStubServiceTest.php | 38 +++++++++++++++++++++++ src/tests/Stubs/CachedFluentService.php | 20 ++++++++++++ src/tests/Stubs/CachedMagicService.php | 17 ++++++++++ src/tests/Stubs/FluentServiceContract.php | 17 ++++++++++ src/tests/Stubs/StubFluentService.php | 28 +++++++++++++++++ src/tests/Stubs/StubMagicService.php | 27 ++++++++++++++++ 8 files changed, 190 insertions(+), 8 deletions(-) create mode 100644 src/tests/Stubs/CachedFluentService.php create mode 100644 src/tests/Stubs/CachedMagicService.php create mode 100644 src/tests/Stubs/FluentServiceContract.php create mode 100644 src/tests/Stubs/StubFluentService.php create mode 100644 src/tests/Stubs/StubMagicService.php diff --git a/README.md b/README.md index 1752acf..639b4e5 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,34 @@ $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. *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 `BadMethodCallException` with the message `Call to undefined method {Decorator}::{method}()`. *The current version doesn't support objects as method arguments — coming in v1.0.0.* + +### Fluent / self-returning methods + +`forwardDecoratedCallTo()` rewrites a fluent `return $this;` from the inner object back to the **decorator**, so method chaining stays on the cached surface instead of escaping to the bare inner instance. + +Two conventions make this transparent and type-safe: + +- **List fluent methods in `$excludes`.** They aren't cache candidates anyway, and excluding them keeps them on the always-forward path. (On a cache *miss* `__call()` stores `callMethod()`'s return — now the decorator instance — and a later *hit* would return that cached decorator directly, bypassing the rewrite. Excluding avoids caching a decorator object.) +- **Type fluent methods `: static` behind a shared interface.** Have the inner class implement an interface whose fluent methods return `static`. A caller then sees the same type whether it holds the inner instance or the decorator, and the decorator standing in for `$this` on self-returning calls is type-coherent. + +```PHP +interface ReportingContract { + public function forMonth(string $month): static; // fluent + public function totals(): array; // cacheable +} + +class ReportingService implements ReportingContract { /* ... */ } + +/** @extends CacheDecorator */ +class CachedReportingService extends CacheDecorator { + protected ?string $prefix_key = 'reports'; + protected array $excludes = ['forMonth']; // fluent method stays on the forward path +} + +$cached = new CachedReportingService(new ReportingService); +$cached->forMonth('2026-05')->totals(); // forMonth() returns the decorator; totals() is cached +``` ### Optional: have the decorator instantiate the inner class for you diff --git a/src/CacheDecorator.php b/src/CacheDecorator.php index 8f9eb9d..13ea5ca 100644 --- a/src/CacheDecorator.php +++ b/src/CacheDecorator.php @@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Traits\ForwardsCalls; use LogicException; /** @@ -42,6 +43,8 @@ */ abstract class CacheDecorator { + use ForwardsCalls; + /** @var TInner */ protected object $decorated; @@ -126,7 +129,8 @@ protected function initExcludes(): void { $defaults = ['decoratedClass', 'setTtl', 'setEnabled', 'getConfig', 'initDecorated', 'doesMethodClearTag', 'clearCacheTag', 'getCache', 'putCache', - 'isMethodCacheable', 'generateCacheKey', 'log', 'cacheMiss', ]; + 'isMethodCacheable', 'generateCacheKey', 'log', 'cacheMiss', + 'forwardCallTo', 'forwardDecoratedCallTo', 'throwBadMethodCallException', ]; $this->excludes = array_merge($defaults, $this->excludes); } @@ -330,6 +334,14 @@ protected function putCache(string $key, $res): bool /** * Method for making calls to the decorated object * + * Delegates through Laravel's ForwardsCalls trait so calls also reach + * methods the decorated object exposes via its own __call() magic, not just + * declared methods. When the inner method returns the inner object (a fluent + * `return $this;`), forwardDecoratedCallTo() returns this decorator instead, + * so chaining stays on the cached surface. A genuinely undefined method is + * converted to a BadMethodCallException reading + * "Call to undefined method {Decorator}::{method}()". + * * @param string $method Name of the method * @param array $arguments Arguments for the method * @return mixed What ever the decorated method returns @@ -338,13 +350,9 @@ protected function putCache(string $key, $res): bool */ protected function callMethod(string $method, array $arguments) { - if (method_exists($this->decorated, $method)) { - $this->log('Calling method from the decorated object'); - - return $this->decorated->{$method}(...$arguments); - } + $this->log('Calling method from the decorated object'); - throw new BadMethodCallException("Method '{$method}' does not exist in the decorated object"); + return $this->forwardDecoratedCallTo($this->decorated, $method, $arguments); } /** diff --git a/src/tests/CachedStubServiceTest.php b/src/tests/CachedStubServiceTest.php index 2fdeb83..5e6d0d3 100644 --- a/src/tests/CachedStubServiceTest.php +++ b/src/tests/CachedStubServiceTest.php @@ -8,8 +8,12 @@ use PHPUnit\Framework\Attributes\Test; use Trm42\CacheDecorator\ServiceProvider; use Trm42\CacheDecorator\Tests\Stubs\CachedAutoStubService; +use Trm42\CacheDecorator\Tests\Stubs\CachedFluentService; +use Trm42\CacheDecorator\Tests\Stubs\CachedMagicService; use Trm42\CacheDecorator\Tests\Stubs\CachedStubService; use Trm42\CacheDecorator\Tests\Stubs\CachedStubServiceWithDependency; +use Trm42\CacheDecorator\Tests\Stubs\StubFluentService; +use Trm42\CacheDecorator\Tests\Stubs\StubMagicService; use Trm42\CacheDecorator\Tests\Stubs\StubService; /** @@ -160,4 +164,38 @@ public function test_falsy_return_values_are_cached_not_refetched(string $method "Falsy return from {$method}() should round-trip via cache instead of re-invoking the inner service" ); } + + #[Test] + public function test_magic_method_is_forwarded_and_cached() + { + $magicInner = new StubMagicService; + $service = new CachedMagicService($magicInner); + $service->setEnabled(true); + $service->setTtl(300); + + // magicCompute() only exists via StubMagicService::__call(), so the old + // method_exists() gate would have thrown BadMethodCallException. + $first = $service->magicCompute(4); + $second = $service->magicCompute(4); + + $this->assertEquals(12, $first); + $this->assertEquals(12, $second); + $this->assertEquals(1, $magicInner->callCount, 'Second call should hit cache, not the inner __call()'); + } + + #[Test] + public function test_fluent_method_returns_decorator_not_inner() + { + $fluentInner = new StubFluentService; + $service = new CachedFluentService($fluentInner); + $service->setEnabled(true); + $service->setTtl(300); + + // withFlag() returns `$this` (the inner); forwardDecoratedCallTo() + // rewrites that to the decorator so chaining stays on the cached surface. + $returned = $service->withFlag(true); + + $this->assertSame($service, $returned, 'Fluent call should return the decorator, not the inner object'); + $this->assertEquals('on', $returned->result()); + } } diff --git a/src/tests/Stubs/CachedFluentService.php b/src/tests/Stubs/CachedFluentService.php new file mode 100644 index 0000000..40412d9 --- /dev/null +++ b/src/tests/Stubs/CachedFluentService.php @@ -0,0 +1,20 @@ + + */ +class CachedFluentService extends CacheDecorator +{ + protected ?string $prefix_key = 'fluent'; + + protected array $excludes = ['withFlag']; +} diff --git a/src/tests/Stubs/CachedMagicService.php b/src/tests/Stubs/CachedMagicService.php new file mode 100644 index 0000000..6671fb0 --- /dev/null +++ b/src/tests/Stubs/CachedMagicService.php @@ -0,0 +1,17 @@ + + */ +class CachedMagicService extends CacheDecorator +{ + protected ?string $prefix_key = 'magic'; +} diff --git a/src/tests/Stubs/FluentServiceContract.php b/src/tests/Stubs/FluentServiceContract.php new file mode 100644 index 0000000..55a96ca --- /dev/null +++ b/src/tests/Stubs/FluentServiceContract.php @@ -0,0 +1,17 @@ +callCount++; + $this->flag = $flag; + + return $this; + } + + public function result(): string + { + return $this->flag ? 'on' : 'off'; + } +} diff --git a/src/tests/Stubs/StubMagicService.php b/src/tests/Stubs/StubMagicService.php new file mode 100644 index 0000000..c9b3172 --- /dev/null +++ b/src/tests/Stubs/StubMagicService.php @@ -0,0 +1,27 @@ + $arguments + */ + public function __call(string $method, array $arguments): mixed + { + if ($method === 'magicCompute') { + $this->callCount++; + + return $arguments[0] * 3; + } + + throw new \BadMethodCallException("Method '{$method}' does not exist"); + } +}