Hemp

Using the Decorator Pattern in Laravel


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.


A Simple Dashboard

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.


The Quick-and-Dirty Approach

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

The Decorator pattern lets you wrap an object in another object that adds behavior, without altering the original class.

With a decorator:

  • The core object does its original job.
  • The decorator intercepts a method call.
  • It adds something: in this case, caching.
  • It then passes the call through to the underlying object if needed.

The key rule: both objects share the same interface.
From the outside, they’re interchangeable.


Applying the Decorator to Our Report

Step 1: Introduce an Interface

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
{
// ...
}

Step 2: Create a Cached Version

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.

Step 3: Update the Controller

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.


Why Is This Better?

Besides looking clean, this approach gives you several real benefits:

1. Separation of Concerns

The controller no longer juggles multiple responsibilities.
It simply asks for a report.

2. Extensibility

Want to extend the behavior even more? Just create more decorators. For instance, you may want a LoggedDashboardReport to implement specific logging.

3. Testability

You can swap in a fake or stub implementation of the interface.
Your controller doesn’t care, it just calls generate().

4. Single Responsibility

Each class handles its own business. DashboardReport calculates data. CachedDashboardReport caches data.

5. Open/Closed Principle

You extend behavior without modifying existing classes.


BONUS: Automating with the Service Container

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.


Conclusion

Is directly using a quick Cache::rememberForever() wrong? Absolutely not. It’s perfectly fine in many cases.

But when you want:

  • cleaner architecture,
  • more reusable components,
  • better testability,
  • and composable behavior...

...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.

↑ Back to the top