Sometimes you want to keep things simple. Other times you want to reach for a clean, extensible solution that avoids cluttering your controllers or bloating your models. This post shows how the Decorator pattern can help you do exactly that, using a real example from my app Pushsilver.
Here’s a basic DashboardController that renders some high-level billing stats:
final readonly class DashboardController extends Controller{public function __invoke(Request $request): Response{$report = new DashboardReport;return Inertia::render('Dashboard', ['stats' => $report->generate($request->user()->currentTeam->currency),'invoices' => InvoiceResource::collection(Invoice::with('client', 'team', 'items')->latest()->paginate(5)),]);}}final readonly class DashboardController extends Controller{public function __invoke(Request $request): Response{$report = new DashboardReport;return Inertia::render('Dashboard', ['stats' => $report->generate($request->user()->currentTeam->currency),'invoices' => InvoiceResource::collection(Invoice::with('client', 'team', 'items')->latest()->paginate(5)),]);}}
And the DashboardReport itself looks something like this:
final readonly class DashboardReport{public function generate(string $currency = 'usd'): array{return [['name' => 'Paid', 'stat' => Currency::money($this->paidTotal(), $currency)],['name' => 'Open', 'stat' => Currency::money($this->openTotal(), $currency)],['name' => 'Draft', 'stat' => Currency::money($this->draftTotal(), $currency)],['name' => 'Past Due', 'stat' => Currency::money($this->pastDueTotal(), $currency)],];}// ...}final readonly class DashboardReport{public function generate(string $currency = 'usd'): array{return [['name' => 'Paid', 'stat' => Currency::money($this->paidTotal(), $currency)],['name' => 'Open', 'stat' => Currency::money($this->openTotal(), $currency)],['name' => 'Draft', 'stat' => Currency::money($this->draftTotal(), $currency)],['name' => 'Past Due', 'stat' => Currency::money($this->pastDueTotal(), $currency)],];}// ...}
There's nothing fancy here, the controller asks the report to generate the numbers, and the report queries the database to build them.
But here’s the catch: this dashboard is loaded constantly, and every call to generate() performs several database hits. Even efficient queries add up over time.
Naturally, this is a great place for caching.
You could drop some caching directly into the controller:
$stats = Cache::rememberForever('reports.team.' . $request->user()->currentTeam->getKey(),fn () => new DashboardReport()->generate($request->user()->currentTeam->currency));$stats = Cache::rememberForever('reports.team.' . $request->user()->currentTeam->getKey(),fn () => new DashboardReport()->generate($request->user()->currentTeam->currency));
And this works fine! There’s nothing wrong with it. You’ll see this pattern everywhere.
But it has a drawback: your caching logic now lives in your controller, and it has nothing to do with the controller’s real responsibility.
Today, this is not a problem. But as your app grows, this sort of thing compounds. Controllers accumulate more and more behavior they shouldn't own. Testing becomes harder. You lose the ability to reuse or modify the caching logic independently.
This is exactly the kind of situation where the Decorator pattern can help!
The Decorator pattern lets you wrap an object in another object that adds behavior, without altering the original class.
With a decorator:
The key rule: both objects share the same interface.
From the outside, they’re interchangeable.
We want both the real report and the decorated version to look identical from the controller’s perspective:
interface ReportInterface{public function generate(string $currency = 'usd'): array;}interface ReportInterface{public function generate(string $currency = 'usd'): array;}
Let's modify our original report class to implement this interface:
final readonly class DashboardReport implements ReportInterface{// ...}final readonly class DashboardReport implements ReportInterface{// ...}
Now let's create another implementation of our shiny new interface that wraps our original report instance and adds caching:
final readonly class CachedDashboardReport implements ReportInterface{public function __construct(private ReportInterface $report,private string $cacheKey,) {}public function generate(string $currency = 'usd'): array{return Cache::rememberForever($this->cacheKey,fn () => $this->report->generate($currency));}}final readonly class CachedDashboardReport implements ReportInterface{public function __construct(private ReportInterface $report,private string $cacheKey,) {}public function generate(string $currency = 'usd'): array{return Cache::rememberForever($this->cacheKey,fn () => $this->report->generate($currency));}}
Notice what happened here. There's no database calls or actual report logic anywhere in this class. It simply wraps the original generate call in our caching strategy. Both concerns (caching and data fetching) are separated.
Now your controller controls composition, not caching. We can update our controller to pass in the original report instance, and the cache key we'd like to use:
$report = new CachedDashboardReport(new DashboardReport,'reports.team.' . $request->user()->currentTeam->getKey());$report = new CachedDashboardReport(new DashboardReport,'reports.team.' . $request->user()->currentTeam->getKey());
And the rest stays exactly the same:
'stats' => $report->generate($request->user()->currentTeam->currency),'stats' => $report->generate($request->user()->currentTeam->currency),
The controller never knows whether the report is cached, decorated, wrapped with logging, or swapped out for a fake implementation during tests.
Besides looking clean, this approach gives you several real benefits:
The controller no longer juggles multiple responsibilities.
It simply asks for a report.
Want to extend the behavior even more? Just create more decorators. For instance, you may want a LoggedDashboardReport to implement specific logging.
You can swap in a fake or stub implementation of the interface.
Your controller doesn’t care, it just calls generate().
Each class handles its own business. DashboardReport calculates data. CachedDashboardReport caches data.
You extend behavior without modifying existing classes.
Manually instantiating new CachedDashboardReport(new DashboardReport(...)) in your controller is fine, but we can make it even cleaner by letting Laravel do the work.
We can use the container's extend method to automatically wrap our service whenever it is resolved. (See the docs on extending bindings for more details).
In your AppServiceProvider:
public function register(): void{// 1. Bind the base implementation$this->app->bind(ReportInterface::class, DashboardReport::class);// 2. Decorate it$this->app->extend(ReportInterface::class, function (ReportInterface $service, Application $app) {return new CachedDashboardReport($service,'reports.team.' . $app['request']->user()->currentTeam->getKey());});}public function register(): void{// 1. Bind the base implementation$this->app->bind(ReportInterface::class, DashboardReport::class);// 2. Decorate it$this->app->extend(ReportInterface::class, function (ReportInterface $service, Application $app) {return new CachedDashboardReport($service,'reports.team.' . $app['request']->user()->currentTeam->getKey());});}
Now, we can just type-hint the interface in our controller. Laravel will build the object graph for us, wrapping the report in the cache decorator automatically:
public function __invoke(Request $request, ReportInterface $report): Response{return Inertia::render('Dashboard', ['stats' => $report->generate($request->user()->currentTeam->currency),// ...]);}public function __invoke(Request $request, ReportInterface $report): Response{return Inertia::render('Dashboard', ['stats' => $report->generate($request->user()->currentTeam->currency),// ...]);}
This is the ultimate goal: our controller doesn't know about caching, and it doesn't even know about the concrete DashboardReport class. It just asks for the interface and gets the behavior we configured.
Is directly using a quick Cache::rememberForever() wrong? Absolutely not. It’s perfectly fine in many cases.
But when you want:
...the Decorator pattern is a powerful, elegant tool.
It keeps your controllers focused, your domain logic pure, and your cross-cutting concerns organized in their own small, well-named classes.
It’s one of those patterns that feels surprisingly natural in Laravel, and once you start using it, you’ll find tons of places where it makes your codebase smoother and more joyful to work in.