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
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReportingService> */
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

Expand Down
22 changes: 15 additions & 7 deletions src/CacheDecorator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -42,6 +43,8 @@
*/
abstract class CacheDecorator
{
use ForwardsCalls;

/** @var TInner */
protected object $decorated;

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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<int|string, mixed> $arguments Arguments for the method
* @return mixed What ever the decorated method returns
Expand All @@ -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);
}

/**
Expand Down
38 changes: 38 additions & 0 deletions src/tests/CachedStubServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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());
}
}
20 changes: 20 additions & 0 deletions src/tests/Stubs/CachedFluentService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace Trm42\CacheDecorator\Tests\Stubs;

use Trm42\CacheDecorator\CacheDecorator;

/**
* Decorator over StubFluentService. The fluent `withFlag` is excluded so it
* stays on the always-forward path: forwardDecoratedCallTo() rewrites the
* inner `return $this;` to this decorator instead of caching a decorator
* instance.
*
* @extends CacheDecorator<StubFluentService>
*/
class CachedFluentService extends CacheDecorator
{
protected ?string $prefix_key = 'fluent';

protected array $excludes = ['withFlag'];
}
17 changes: 17 additions & 0 deletions src/tests/Stubs/CachedMagicService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace Trm42\CacheDecorator\Tests\Stubs;

use Trm42\CacheDecorator\CacheDecorator;

/**
* Decorator over StubMagicService, whose methods only exist via its own
* __call() magic. Proves the decorator forwards (and caches) calls that
* method_exists() would never see.
*
* @extends CacheDecorator<StubMagicService>
*/
class CachedMagicService extends CacheDecorator
{
protected ?string $prefix_key = 'magic';
}
17 changes: 17 additions & 0 deletions src/tests/Stubs/FluentServiceContract.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace Trm42\CacheDecorator\Tests\Stubs;

/**
* Shared contract implemented by both the inner service and its decorator.
*
* Fluent methods are typed `: static` so a caller holding either the inner
* instance or the decorator sees the same type — the decorator substituting
* itself for the inner object on self-returning calls stays type-safe.
*/
interface FluentServiceContract
{
public function withFlag(bool $flag): static;

public function result(): string;
}
28 changes: 28 additions & 0 deletions src/tests/Stubs/StubFluentService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace Trm42\CacheDecorator\Tests\Stubs;

/**
* Service with a fluent, self-returning method to exercise the
* forwardDecoratedCallTo() rewrite (inner `return $this;` becomes the
* decorator).
*/
class StubFluentService implements FluentServiceContract
{
public int $callCount = 0;

protected bool $flag = false;

public function withFlag(bool $flag): static
{
$this->callCount++;
$this->flag = $flag;

return $this;
}

public function result(): string
{
return $this->flag ? 'on' : 'off';
}
}
27 changes: 27 additions & 0 deletions src/tests/Stubs/StubMagicService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace Trm42\CacheDecorator\Tests\Stubs;

/**
* Service that exposes methods through its own __call() magic instead of
* declaring them. Used to prove the decorator forwards (and caches) calls to
* magic methods that method_exists() would not see.
*/
class StubMagicService
{
public int $callCount = 0;

/**
* @param array<int, mixed> $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");
}
}