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
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ $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 `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.*
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.*

### Fluent / self-returning methods

Expand Down Expand Up @@ -146,6 +146,29 @@ protected array $tag_cleaners = ['recompute'];
protected array $tags = ['reports'];
```

## Exceptions

All errors thrown by the package live in the `Trm42\CacheDecorator\Exceptions` namespace and extend a single abstract base, `CacheDecoratorException`. Catch that base type to handle any cache-decorator-specific failure in one place, or catch a concrete subclass to distinguish the failure mode:

| Exception | Thrown when |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `CacheDecoratorException` | *(abstract base — never thrown directly; catch it to handle every error below)* |
| `MissingDecoratedObjectException` | No instance was passed to the constructor **and** `decoratedClass()` returned `null`, so there is nothing to wrap. |
| `UndefinedMethodException` | A forwarded call targets a method that exists nowhere on the decorated object. |

```PHP
use Trm42\CacheDecorator\Exceptions\CacheDecoratorException;

try {
$cached->dailyTotals('2026-05-12');
} catch (CacheDecoratorException $e) {
// catches MissingDecoratedObjectException and UndefinedMethodException alike
report($e);
}
```

> **Breaking change.** These types previously surfaced as the SPL exceptions `LogicException` (missing decorated object) and `BadMethodCallException` (undefined method). They now extend `\Exception` via `CacheDecoratorException` and are **not** instances of those SPL classes — update any `catch (LogicException ...)` / `catch (BadMethodCallException ...)` blocks that relied on the old types.

## Using with repositories

For repository-flavored use cases the package ships `RepositoryCacheDecorator`. It behaves exactly like `CacheDecorator` but reads its config from the `repository_cache.*` namespace instead of `cache_decorator.*`, so repository caches can be tuned independently of other decorators.
Expand Down
31 changes: 26 additions & 5 deletions src/CacheDecorator.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@

// At least for now there's a Laravel dependency, if there's need, this can be
// converted to something more generic
use BadMethodCallException;
use DateInterval;
use DateTimeInterface;
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 LogicException;
use Trm42\CacheDecorator\Exceptions\MissingDecoratedObjectException;
use Trm42\CacheDecorator\Exceptions\UndefinedMethodException;

/**
* Magical Cache Decorator class. Meant to be sub classed.
Expand Down Expand Up @@ -183,7 +183,7 @@ public function initDecorated(?object $decorated): void
$class = $this->decoratedClass();

if (! $class) {
throw new LogicException(
throw new MissingDecoratedObjectException(
'No decorated object provided and decoratedClass() returned null. '
.'Either pass an instance to the constructor or override decoratedClass().'
);
Expand Down Expand Up @@ -339,14 +339,14 @@ protected function putCache(string $key, $res): bool
* 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
* converted to an UndefinedMethodException 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
*
* @throws BadMethodCallException If the method doesn't exist on the decorated object
* @throws UndefinedMethodException If the method doesn't exist on the decorated object
*/
protected function callMethod(string $method, array $arguments)
{
Expand All @@ -355,6 +355,27 @@ protected function callMethod(string $method, array $arguments)
return $this->forwardDecoratedCallTo($this->decorated, $method, $arguments);
}

/**
* Throw a package-specific exception for an undefined forwarded method.
*
* Overrides the ForwardsCalls trait helper so that calls to methods missing
* on the decorated object surface as an UndefinedMethodException (a
* CacheDecoratorException) instead of a raw BadMethodCallException. The
* message is kept identical to the trait's so behavior other than the
* thrown type is unchanged.
*
* @param string $method Name of the undefined method
* @return never
*
* @throws UndefinedMethodException
*/
protected static function throwBadMethodCallException($method)
{
throw new UndefinedMethodException(sprintf(
'Call to undefined method %s::%s()', static::class, $method
));
}

/**
* Checks if the method belongs to excludes array or not
*
Expand Down
13 changes: 13 additions & 0 deletions src/Exceptions/CacheDecoratorException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Trm42\CacheDecorator\Exceptions;

/**
* Base type for every exception thrown by the cache-decorator package.
*
* Catch this to handle any cache-decorator-specific failure with a single
* catch block, while still being able to distinguish the concrete subclasses
* ({@see MissingDecoratedObjectException}, {@see UndefinedMethodException})
* when needed.
*/
abstract class CacheDecoratorException extends \Exception {}
9 changes: 9 additions & 0 deletions src/Exceptions/MissingDecoratedObjectException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace Trm42\CacheDecorator\Exceptions;

/**
* Thrown when no decorated instance is passed to the constructor and
* decoratedClass() returns null, so the decorator has nothing to wrap.
*/
class MissingDecoratedObjectException extends CacheDecoratorException {}
9 changes: 9 additions & 0 deletions src/Exceptions/UndefinedMethodException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace Trm42\CacheDecorator\Exceptions;

/**
* Thrown when a forwarded method call targets a method that does not exist on
* the decorated object.
*/
class UndefinedMethodException extends CacheDecoratorException {}
3 changes: 2 additions & 1 deletion src/tests/CachedStubRepositoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Illuminate\Support\Facades\Cache;
use Orchestra\Testbench\TestCase;
use PHPUnit\Framework\Attributes\Test;
use Trm42\CacheDecorator\Exceptions\UndefinedMethodException;
use Trm42\CacheDecorator\ServiceProvider;
use Trm42\CacheDecorator\Tests\Stubs\CachedStubRepository;
use Trm42\CacheDecorator\Tests\Stubs\StubRepository;
Expand Down Expand Up @@ -129,7 +130,7 @@ public function test_insert()
#[Test]
public function test_missing_function()
{
$this->expectException(\BadMethodCallException::class);
$this->expectException(UndefinedMethodException::class);

$this->repository->foobar();
}
Expand Down
6 changes: 4 additions & 2 deletions src/tests/CachedStubServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
use Orchestra\Testbench\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Trm42\CacheDecorator\Exceptions\MissingDecoratedObjectException;
use Trm42\CacheDecorator\Exceptions\UndefinedMethodException;
use Trm42\CacheDecorator\ServiceProvider;
use Trm42\CacheDecorator\Tests\Stubs\CachedAutoStubService;
use Trm42\CacheDecorator\Tests\Stubs\CachedFluentService;
Expand Down Expand Up @@ -83,7 +85,7 @@ public function test_excluded_method_bypasses_cache()
#[Test]
public function test_missing_method_throws()
{
$this->expectException(\BadMethodCallException::class);
$this->expectException(UndefinedMethodException::class);

$this->service->doesNotExist();
}
Expand Down Expand Up @@ -125,7 +127,7 @@ public function test_decorated_class_is_resolved_through_container_with_dependen
#[Test]
public function test_constructor_without_instance_or_decorated_class_throws()
{
$this->expectException(\LogicException::class);
$this->expectException(MissingDecoratedObjectException::class);

new CachedStubService;
}
Expand Down