imanghafoori/laravel-decorator
Laravel Decorator lets you wrap method calls on any class with custom decorators, adding caching, logging, retries, or auth checks without editing the original code. Register a decorator once and every call to that class flows through it.
Install
composer require imanghafoori/laravel-decorator
The service provider is auto-discovered. Publish the config if the package ships one:
php artisan vendor:publish --tag=decorator-config
Look here first, in this order
config/decorator.php — tells you whether decorators are enabled per-environment and how they are resolved.src/Contracts/ — the decorator interface. Everything you write implements this; read it before writing a single decorator.src/Decorators/ — the built-in decorators are the best documentation of intended usage.README.md examples — small package, small surface; the README is close to complete.The mental model
A decorator is a class that wraps a callable. You attach it to a method declaratively (PHP 8 attribute) or wrap a closure imperatively (facade). The decorator runs instead of the method and decides whether, how often, and under what conditions to invoke the original.
namespace App\Decorators;
use Imanghafoori\Decorator\Contracts\DecoratorContract;
class CacheFor implements DecoratorContract
{
public function __construct(public readonly int $seconds = 60) {}
public function decorate(callable $callable, array $params = []): mixed
{
$key = 'deco:'.md5(serialize([$params, request()->fullUrl()]));
return cache()->remember($key, $this->seconds, fn () => $callable());
}
}
First use case — take one expensive read in a controller and cache it without touching the method body:
class ReportController extends Controller
{
#[CacheFor(seconds: 300)]
public function summary()
{
return Report::expensiveAggregate();
}
}
If that works end-to-end, the package is doing its job. If it doesn't fire, the problem is almost always the interception boundary (see Gotchas) rather than your decorator.
1. Declarative attributes for cross-cutting concerns
The highest-value use is concerns that are orthogonal to the method: caching, timing/logging, DB transactions, rate limiting, feature flags, audit trails. The method body stays pure business logic.
#[Transactional]
#[LogSlowCalls(thresholdMs: 200)]
public function transfer(Account $from, Account $to, int $cents): void
{
// no try/catch, no beginTransaction — all in the decorators
}
2. Composable and ordered
Stack multiple attributes and be explicit about order — it is not commutative. Cache outside Transactional means a rolled-back call is never cached; the reverse caches a value from a transaction that never committed. Write the order you mean and add a comment; future readers will reorder it by accident.
3. Imperative decoration for closures and ad-hoc calls
For one-off wrapping (a queued job step, a console command segment, an artisan script), decorating a closure avoids creating a class for a single use:
$total = Decorator::decorate(fn () => $this->computeTotal($order))
->with([new LogSlowCalls(200)]);
Prefer this over attributes in scripts and jobs where the container is not resolving the call site.
4. Keep decorators thin — delegate
A decorator should be ~10 lines: gather context, call the callable, post-process. If it grows, extract the logic into a service and let the decorator be a two-line adapter. This matters because decorators are hard to unit-test in place and easy to test standalone.
5. Test decorators directly, not through the app
$decorator = new CacheFor(seconds: 60);
$calls = 0;
$result = $decorator->decorate(function () use (&$calls) {
$calls++;
return 'value';
}, ['id' => 1]);
$decorator->decorate(fn () => 'value', ['id' => 1]);
$this->assertSame('value', $result);
$this->assertSame(1, $calls); // second call served from cache
This is the payoff of the callable-based contract: no framework bootstrapping, no HTTP, no container. Then add one feature test that asserts the attribute is actually wired on the method — otherwise you have well-tested decorators nobody invokes.
6. Don't reach for it when a first-class Laravel hook exists
| Need | Use instead |
|---|---|
| HTTP request/response concerns | Middleware |
| Auth/authorization | Policies & Gates |
| Side effects after commit | Events / DB::afterCommit |
| Queued work | Jobs |
| Validation | Form requests |
Decorators earn their place for method-level concerns on classes that are not HTTP entry points: actions, services, repositories, domain objects.
7. Give decorators a stable, injectable shape
Constructor promotion for parameters, no container resolution inside the decorator. If a decorator needs a service, inject it into the method signature decorate(callable $callable, array $params) is not the place — resolve via app() or, better, make the decorator a thin wrapper over an injected collaborator constructed by the package's resolver.
Interception only happens where the package can see the call. The single biggest source of "it silently does nothing":
$this->otherMethod() calls generally bypass decoration — only the outer call is wrapped.static methods, final methods, and closures are typically out of scope.new-ed objects) will not be intercepted.Debugging: bisect with the kill switch. Before reading a stack trace, confirm the decorator is even running. Add logger(__CLASS__) as the first line of decorate(). If it never logs, the problem is registration, not logic. Keep an env-driven off switch (DECORATOR_ENABLED=false) so you can confirm a bug is caused by decoration.
Exceptions and transactions interact badly. A decorator that catches exceptions changes rollback semantics for everything inside it. If you stack Transactional with a retry decorator, decide deliberately whether retries happen inside or outside the transaction — a retry inside a failed transaction will hit an aborted connection on Postgres.
Return type fidelity. Your decorator must return exactly what the method returns, including void (return $callable() and let it be null, don't return early) and never. If you add : mixed to decorate() and forget to return in the cache-hit branch, you get a silent null that surfaces as a confusing TypeError three layers away.
Cache keys must include everything that varies. Deriving the key from the method name alone silently serves user A's data to user B. Include request context, authenticated user, and the relevant arguments; hash it.
Reflection cost on hot paths. Attribute-driven decoration re-reads metadata per call. On endpoints doing thousands of calls, cache the resolved decorator list per class in a static array, and make sure production runs with OPcache and config caching on.
Order of stacked decorators is execution order, outermost first. Write a one-line comment above each stack. This is the bug that ships to production because it only manifests on the rollback path.
Testing pitfalls. If Transactional wraps a test method or a factory-heavy setup, the outer transaction and RefreshDatabase can nest in ways that hide data or fail to roll back. Test the decorator in isolation (see above) and keep one thin integration test.
Version pinning and risk. 139 stars, zero dependents, single maintainer, last release April 2024. Pin the exact version in composer.json, and put a thin App\Support\Decorators namespace of your own on top so you can swap the package for a hand-rolled __call/proxy or Laravel's Pipeline with a day's work. Do not let its attribute names leak into hundreds of call sites unguarded.
Extension point worth knowing. The package's resolver is usually the seam for custom behavior — binding your own implementation lets you change how decorator instances are constructed (e.g., to inject services) without forking. Check src/ for a bind/singleton in the service provider; that is your escape hatch.
How can I help you explore Laravel packages today?