Caching inside an Eloquent model or controller makes invalidation and observability everyone’s problem. A decorator keeps the read operation small and puts infrastructure at its edge.
<?php
declare(strict_types=1);
namespace App\Catalog;
use App\Models\Product;
interface ProductReader
{
public function findPublishedBySlug(string $slug): ?Product;
}
<?php
declare(strict_types=1);
namespace App\Catalog;
use Illuminate\Contracts\Cache\Repository as Cache;
final readonly class CachedProductReader implements ProductReader
{
public function __construct(private ProductReader $inner, private Cache $cache) {}
public function findPublishedBySlug(string $slug): ?\App\Models\Product
{
return $this->cache->remember("catalog.product.{$slug}", now()->addMinutes(10), fn (): ?\App\Models\Product => $this->inner->findPublishedBySlug($slug));
}
}
The provider composes it explicitly. Binding the contract to itself inside the decorator would recurse forever.
$this->app->bind(ProductReader::class, function ($app): ProductReader {
$database = new EloquentProductReader();
return new CachedProductReader($database, $app->make(Cache::class));
});
Add a LoggedProductReader around the cached reader if slow reads need structured timing. Invalidation belongs next to the write path: after publishing or changing a product, call Cache::forget() for the affected key. For wide invalidation, use a versioned key or cache tags only when the configured store supports them.
When not to use it
Do not introduce a repository merely to wrap Product::find(). Decorators pay off when a read boundary already has a name—catalogue lookup, exchange-rate lookup, entitlement lookup—and needs two implementations or cross-cutting behaviour. Laravel’s query cache is not automatic; make freshness explicit.
Define the read shape before adding infrastructure
The original contract is deliberately narrow: it returns one public product for
one slug. Do not turn it into an imitation of Eloquent with all, save, and
arbitrary query callbacks. A generic repository only hides useful ORM features
while still leaking their complexity. This contract names a query that several
callers can share: a product page, an Open Graph endpoint, and a related-items
service all need the same public representation.
<?php
declare(strict_types=1);
namespace App\Catalog;
use App\Models\Product;
interface ProductReader
{
public function findPublishedBySlug(string $slug): ?Product;
}
final class EloquentProductReader implements ProductReader
{
public function findPublishedBySlug(string $slug): ?Product
{
return Product::query()
->select(['id', 'category_id', 'slug', 'name', 'description', 'price_cents', 'published_at'])
->where('slug', $slug)
->whereNotNull('published_at')
->where('published_at', '<=', now())
->with('category:id,name,slug')
->first();
}
}
The eager load is part of the reader's promise. A cache can make an N+1 query
less frequent, but it cannot make it correct. If a product page needs images,
prices, or category data, load the exact relations here or return a dedicated
read DTO. Do not call Product::find() and decide whether a record is public
afterwards: caching a draft, even briefly, is a data-exposure defect.
If visibility varies by locale, customer, or permission, that context must be part of the contract and the key. A key based only on slug is safe only for a response that is identical for every anonymous visitor.
Keep the cache layer boring and deterministic
The cache decorator owns key construction and the acceptable stale window. It
does not add query conditions, silently catch database errors, or decide what
the page looks like. remember() makes its delegation visible: only a cache
miss invokes the inner reader.
<?php
declare(strict_types=1);
namespace App\Catalog;
use App\Models\Product;
use Illuminate\Contracts\Cache\Repository as Cache;
final readonly class CachedProductReader implements ProductReader
{
public function __construct(
private ProductReader $inner,
private Cache $cache,
) {}
public function findPublishedBySlug(string $slug): ?Product
{
return $this->cache->remember(
self::key($slug),
now()->addMinutes(10),
fn (): ?Product => $this->inner->findPublishedBySlug($slug),
);
}
public static function key(string $slug): string
{
return "catalog.product.{$slug}";
}
}
Ten minutes is a publishing decision, not a universal cache setting. It might
be acceptable for a brochure catalogue and unacceptable for a stock level or a
regulated price. Treat the TTL as a safety net; the write path must normally
invalidate the entry sooner. Be equally careful with negative caching. Cache
drivers do not all distinguish a stored null from a miss in the same way, and
a cached absence can conceal a product just published by an editor. When bot
traffic genuinely warrants it, use an explicit result object and a shorter TTL
for misses.
Decorate measurements separately from caching
Observability is another independent concern. Putting this decorator outside
the cache answers “how long did the caller wait?” Putting it inside answers
“how slow were cache misses?” Choose one based on the metric you need; this
version measures the whole operation. The finally block records failures too
but leaves their exception semantics untouched.
<?php
declare(strict_types=1);
namespace App\Catalog;
use App\Models\Product;
use Psr\Log\LoggerInterface;
final readonly class LoggedProductReader implements ProductReader
{
public function __construct(
private ProductReader $inner,
private LoggerInterface $logger,
) {}
public function findPublishedBySlug(string $slug): ?Product
{
$startedAt = hrtime(true);
try {
return $this->inner->findPublishedBySlug($slug);
} finally {
$this->logger->info('catalog.product_read', [
'slug' => $slug,
'duration_ms' => (hrtime(true) - $startedAt) / 1_000_000,
]);
}
}
}
In a busy catalogue, logging every successful read can create more noise and cost than value. Prefer a metric, sample successes, or log only slow reads. Never place product descriptions, personal pricing, or other sensitive data in the log context merely because the decorator has access to the result.
Compose concrete layers from the inside out
Do not resolve ProductReader while registering the ProductReader binding.
That asks the container for the binding currently under construction and ends
in recursion. Create the database implementation directly, then wrap that
specific object. The order is obvious in review and easy to alter deliberately.
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Catalog\CachedProductReader;
use App\Catalog\EloquentProductReader;
use App\Catalog\LoggedProductReader;
use App\Catalog\ProductReader;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Support\ServiceProvider;
use Psr\Log\LoggerInterface;
final class CatalogServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(ProductReader::class, function (): ProductReader {
$database = new EloquentProductReader();
$cached = new CachedProductReader($database, $this->app->make(Cache::class));
return new LoggedProductReader($cached, $this->app->make(LoggerInterface::class));
});
}
}
This composition also keeps tests local. A cache test does not need a provider or a database; it needs a counting fake reader and an array cache. A query test does not need to prove cache mechanics. Separating those failure modes makes the suite faster and the diagnosis clearer.
Invalidate after a successful commit
The reader cannot know when an editor changed a product. The write action owns that fact. Forget after the database commit, not before it: otherwise another request can miss the cache, read old committed data, and refill the key while the writer's transaction is still open.
<?php
declare(strict_types=1);
namespace App\Catalog;
use App\Models\Product;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Support\Facades\DB;
final readonly class PublishProduct
{
public function __construct(private Cache $cache) {}
public function handle(Product $product): void
{
DB::transaction(function () use ($product): void {
$product->forceFill(['published_at' => now()])->save();
DB::afterCommit(function () use ($product): void {
$this->cache->forget(CachedProductReader::key($product->slug));
});
});
}
}
Changing a slug requires forgetting the old key as well as the new one. If the cached product embeds category data, a category update also affects its products. Redis and Memcached cache tags can model broad invalidation, but tags are not portable to every Laravel store. A versioned catalogue key is often a clearer alternative when a broad refresh is acceptable.
Prove the boundary with focused Pest tests
The decorator's central promise is delegation once, then reuse. A test can
prove that without a database, and a separate feature test should prove that
unpublished or future-dated products never leave EloquentProductReader.
<?php
use App\Catalog\CachedProductReader;
use App\Catalog\ProductReader;
use App\Models\Product;
use Illuminate\Cache\ArrayStore;
use Illuminate\Cache\Repository;
it('delegates one slug lookup only once while cached', function (): void {
$product = Product::factory()->make(['slug' => 'desk-lamp']);
$reader = new class($product) implements ProductReader {
public int $calls = 0;
public function __construct(private Product $product) {}
public function findPublishedBySlug(string $slug): ?Product
{
$this->calls++;
return $this->product;
}
};
$cached = new CachedProductReader($reader, new Repository(new ArrayStore()));
expect($cached->findPublishedBySlug('desk-lamp'))->toBe($product)
->and($cached->findPublishedBySlug('desk-lamp'))->toBe($product)
->and($reader->calls)->toBe(1);
});
When not to use this pattern
Use a direct query scope and Cache::remember() for a one-off administrative
lookup. Use Cache::memo() or once() when duplicate work exists only within
one request; neither creates a distributed invalidation problem. Prefer a
purpose-built read model over a hydrated Eloquent model when the output is a
large JSON response.
Finally, do not force unrelated reads into one interface. If callers require different columns, relations, authorization, and filters, the reader either grows a parameter list nobody can reason about or returns a cached shape that is wrong for somebody. Split stable use cases first. Decorators pay off only when the read contract is coherent, the order of cross-cutting layers matters, and someone owns freshness on the write path.
Production edges worth deciding deliberately
An expiry can produce a cache stampede: many requests observe the same missing key and all run the database query. Do not add a distributed lock merely by habit; it can make a healthy page wait behind a slow request. For an expensive, high-traffic read, decide explicitly whether a short lock, Laravel's stale-while-revalidate cache support, or a pre-warming job gives the better failure mode. The correct choice depends on whether callers prefer a slightly old catalogue page or an occasional slow response.
Also decide whether caching an Eloquent model is the representation you want to preserve. Changes to casts, accessors, hidden attributes, or the relations the page touches can make a previously harmless cache entry surprising. A small immutable DTO makes the cache payload and public boundary clearer for a long-lived read. Conversely, do not introduce a DTO just to satisfy a pattern: for a ten-minute internal cache with one consumer, a carefully loaded model can remain the simpler and more maintainable choice.