<?php
namespace App\Observers;
use App\Models\Product;
use App\Jobs\WarmRelatedProductsCache;
class ProductObserver
{
public function saved(Product $product): void
{
if (! $product->wasChanged(['stock', 'is_active'])) {
return;
}
WarmRelatedProductsCache::dispatch($product->id)
->delay(now()->addSeconds(15))
->onQueue('cache-warming');
}
public function deleted(Product $product): void
{
// Neighbours of the same category may now be stale.
Product::where('category_id', $product->category_id)
->pluck('id')
->each(function ($id) {
WarmRelatedProductsCache::dispatch($id)->onQueue('cache-warming');
});
}
}
<?php
namespace App\Jobs;
use App\Models\Product;
use App\Services\RelatedProductsService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
class WarmRelatedProductsCache implements ShouldQueue, ShouldBeUnique
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $uniqueFor = 120;
public function __construct(public int $productId)
{
}
public function uniqueId(): string
{
return 'related-products:' . $this->productId;
}
public function handle(RelatedProductsService $service): void
{
$product = Product::find($this->productId);
if ($product === null) {
Cache::forget($service->cacheKey($this->productId));
return;
}
$related = $service->compute($product);
Cache::put($service->cacheKey($product->id), $related, now()->addHours(6));
}
}
<?php
namespace App\Services;
use App\Models\Product;
use Illuminate\Support\Facades\Cache;
class RelatedProductsService
{
public function cacheKey(int $productId): string
{
return "products:{$productId}:related";
}
public function relatedFor(Product $product): array
{
return Cache::remember(
$this->cacheKey($product->id),
now()->addHours(6),
fn () => $this->compute($product)
);
}
public function compute(Product $product): array
{
return Product::query()
->where('category_id', $product->category_id)
->where('id', '!=', $product->id)
->where('is_active', true)
->where('stock', '>', 0)
->orderByRaw('ABS(price - ?) asc', [$product->price])
->limit(12)
->get(['id', 'name', 'price', 'slug'])
->map(fn (Product $p) => [
'id' => $p->id,
'name' => $p->name,
'price' => (float) $p->price,
'slug' => $p->slug,
])
->all();
}
}
This snippet shows how an e-commerce catalog keeps its "related products" cache warm without blocking the request that changed inventory. The pattern is read-through caching combined with proactive warming: instead of recomputing the related-products list lazily on the next page view (which risks a slow first hit or a thundering herd after a bulk stock update), the recompute is pushed onto a queue and executed in the background.
In ProductObserver, Eloquent's model events act as the trigger. The observer's saved method inspects the dirty attributes via wasChanged and only reacts when stock or is_active actually moved, avoiding needless work when unrelated fields change. It then dispatches WarmRelatedProductsCache and applies a ->delay(...) to debounce rapid successive edits, so a burst of stock adjustments coalesces rather than firing a job per keystroke.
The queued class WarmRelatedProductsCache carries only the product id, not the whole model, which keeps the serialized payload small and guarantees the job reads fresh data at execution time. It implements ShouldQueue and sets $uniqueFor with ShouldBeUnique so that overlapping jobs for the same product collapse into one — the key defence against redundant recomputation. uniqueId scopes the lock to the product, and handle re-fetches the product, bails out gracefully if it was deleted, computes the neighbour set, and writes it under a stable cache key with a TTL that outlives the natural page cache.
The heavy lifting lives in RelatedProductsService, which is where the domain logic sits: it queries same-category, in-stock products, scores them, and returns a lean array of ids and display fields. Storing a plain array rather than full models keeps the cached value compact and framework-version tolerant. The relatedFor read path uses Cache::remember, so if the warm job has not yet run the value is still computed on demand — the warm job simply ensures that cold reads are rare. Together these files illustrate the trade-off of eventual consistency: the cache may briefly lag a stock change, which is acceptable for a recommendations widget, in exchange for fast, non-blocking writes and bounded recompute cost.
Related snips
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
json.array! @posts do |post|
json.cache! ['v1', post], expires_in: 1.hour do
json.id post.id
json.title post.title
json.excerpt post.excerpt
json.published_at post.published_at
Fragment caching for expensive JSON serialization
<?php
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;
Laravel service container and dependency injection
import { randomBytes, createHash } from "crypto";
import jwt from "jsonwebtoken";
import { RefreshTokenStore } from "./store";
const ACCESS_SECRET = process.env.ACCESS_SECRET!;
const ACCESS_TTL = "15m";
JWT access + refresh token rotation (conceptual)
class Comment < ApplicationRecord
belongs_to :post, touch: true
belongs_to :author, class_name: "User"
validates :body, presence: true, length: { maximum: 10_000 }
Granular Cache Invalidation with touch: true
{
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build"
},
Laravel mix/Vite for asset compilation
Share this code
Here's the card — post it anywhere.