As a Tech Lead building my own platform, I live by an old craftsman proverb: "Measure twice, cut once." We spend weeks architecting client systems with precision, yet when it came to my own portfolio at Yabasha.dev, I was brute-forcing cache invalidation like a junior developer deploying php artisan cache:clear on a cron job. The irony wasn't lost on me.
I built Yabasha.dev as a living showcase β not just static pages, but a dynamic playground where I could demonstrate real full-stack architecture. The stack seemed obvious: Laravel 12 for its elegant API and Filament admin panel, Next.js 16 for blistering performance with ISR, and Redis as the connective tissue. What wasn't obvious was how to make these two beasts talk to each other without me playing telephone operator every time I published a new article.
The Decoupling Dilemma
The architecture is clean on paper. Laravel manages content. Next.js renders it. ISR promises the best of both worlds: static speed with dynamic freshness. But here's the rub β ISR is a black box. Next.js holds all the cards for revalidation, and Laravel has no native way to whisper "hey, that blog post changed" across the wire.
My first iteration was naive: a simple webhook from Laravel to Next.js's /api/revalidate. It worked until it didn't. A 500 error during deployment meant stale content for hours. No retry logic. No idempotency. No visibility. I was flying blind, hoping my cache invalidated properly. That's not engineering; that's wishful thinking.
The Hybrid Power Stack
I chose this specific combination for brutal efficiency:
-
Backend: Laravel 12 API with Filament admin
- Why: Developer experience matters. Filament gives me a production-ready admin in hours, not days. Laravel's event system is my nervous system.
-
Frontend: Next.js 16 App Router with ISR
- Why: I'm optimizing for Web Vitals and user experience. ISR lets me regenerate pages on-demand without rebuilding the entire site. The App Router's granular caching is chef's kiss.
-
Orchestration: Redis + Laravel Queues
-
Why: I need atomic operations and guaranteed delivery. Redis Streams would be overkill; simple lists with
rpush/blpopgive me the reliability without the ceremony.
-
Why: I need atomic operations and guaranteed delivery. Redis Streams would be overkill; simple lists with
The Silent Failure Mode
Cache invalidation is computer science's second hardest problem, and my setup had four critical failure modes:
- No Acknowledgment: Laravel would fire a webhook and pray. Next.js might receive it, might process it, might fail silently. I had zero observability.
- Race Conditions: If I updated a post twice in quick succession, two revalidation requests would race. The loser would sometimes revalidate stale data, creating a cache inconsistency nightmare.
- Deployment Windows: During a Next.js deployment, the revalidation endpoint would be down. Laravel's webhook would fail, and I'd have no retry mechanism.
- Cascading Invalidations: When I updated a category, I needed to revalidate the category page, all posts in that category, and the homepage. My naive webhook couldn't express this graph of dependencies.
I was spending more time manually verifying cache state than actually writing content.
The Solution Was With Cache Handshake Protocol ποΈ
The breakthrough was treating cache invalidation like a distributed transaction. I built a Cache Handshake Protocol β a two-phase commit between Laravel and Next.js with Redis as the referee.
Phase 1: Intent & Queuing
When content changes, Laravel emits a ContentUpdated event with a unique revalidation_id. A listener queues a job, but here's the key: the job doesn't call Next.js directly. It writes to a Redis list called revalidation:queue and creates a hash revalidation:{id}:status with initial state pending.
// app/Events/PostUpdated.php
class PostUpdated
{
use SerializesModels;
public function __construct(
public Post $post,
public string $revalidationId
) {}
}
// app/Listeners/QueueRevalidation.php
class QueueRevalidation implements ShouldQueue
{
public function handle(PostUpdated $event): void
{
$payload = [
'revalidation_id' => $event->revalidationId,
'type' => 'post',
'slug' => $post->slug,
'dependencies' => [
'blog?category=' . $post->category->slug,
'blog',
'' // homepage
]
];
Redis::rpush('revalidation:queue', json_encode($payload));
// Create handshake record
Redis::hmset("revalidation:{$event->revalidationId}:status", [
'state' => 'pending',
'attempts' => 0,
'created_at' => now()->timestamp
]);
}
}
Phase 2: Processing & Acknowledgment
A Laravel queue worker runs every 10 seconds, pulling jobs with blPop for atomicity. It calls Next.js's revalidation API with a signed JWT containing the revalidation_id.
// app/Jobs/ProcessRevalidation.php
class ProcessRevalidation implements ShouldQueue
{
public function handle(): void
{
$job = Redis::blPop(['revalidation:queue'], 5);
if (!$job) return;
$payload = json_decode($job[1], true);
$revalidationId = $payload['revalidation_id'];
// Increment attempt counter
Redis::hIncrBy("revalidation:{$revalidationId}:status", 'attempts', 1);
try {
// Sign the request
$token = JWT::encode([
'sub' => $revalidationId,
'exp' => now()->addMinutes(5)->timestamp
], config('app.revalidation_secret'), 'HS256');
$response = Http::withToken($token)
->post(config('app.nextjs_url') . '/api/revalidate', $payload);
if ($response->failed()) {
throw new RevalidationFailedException(
"Next.js returned {$response->status()}"
);
}
// Move to 'acknowledged' state
Redis::hset(
"revalidation:{$revalidationId}:status",
'state',
'acknowledged'
);
} catch (\\\\Exception $e) {
$attempts = Redis::hget(
"revalidation:{$revalidationId}:status",
'attempts'
);
if ($attempts < 3) {
// Re-queue with exponential backoff
Redis::rpush('revalidation:queue', $job[1]);
Redis::expire("revalidation:{$revalidationId}:status", 3600);
} else {
Redis::hset("revalidation:{$revalidationId}:status", 'state', 'failed');
Log::error("Revalidation {$revalidationId} failed permanently");
}
}
}
}
Phase 3: Completion & Verification
Next.js receives the request, revalidates the paths, then calls back to Laravel with the same revalidation_id to complete the handshake.
// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_URL!,
token: process.env.UPSTASH_REDIS_TOKEN!,
});
export async function POST(request: NextRequest) {
const payload = await request.json();
const { revalidation_id, slug, dependencies } = payload;
// Verify JWT and complete revalidation
try {
// Revalidate primary path
revalidatePath(`/blog/${slug}`);
// Revalidate dependencies in parallel
await Promise.all(
dependencies.map((path: string) => revalidatePath(`/${path}`))
);
// Write completion marker to Redis
await redis.set(
`revalidation:complete:${revalidation_id}`,
'1',
{ ex: 3600 }
);
// Callback to Laravel
await fetch(`${process.env.LARAVEL_URL}/api/revalidation/complete`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.REVALIDATION_SECRET}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ revalidation_id }),
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Revalidation failed:', error);
return NextResponse.json(
{ error: 'Revalidation failed' },
{ status: 500 }
);
}
}
The final piece: Laravel marks the handshake complete when it receives the callback, giving me full observability.
// routes/api.php
Route::middleware('auth:sanctum')->post('/revalidation/complete', function (Request $request) {
$revalidationId = $request->input('revalidation_id');
Redis::hset(
"revalidation:{$revalidationId}:status",
'state',
'completed'
);
// Log success, emit metrics, etc.
Log::info("Cache handshake completed", [
'id' => $revalidationId,
'duration' => now()->timestamp - Redis::hget(
"revalidation:{$revalidationId}:status",
'created_at'
)
]);
return response()->json(['status' => 'acknowledged']);
});
Dynamic Model Selection & Circuit Breakers
Here's where this gets interesting. Not all revalidations are equal. Updating old blog post doesn't need the urgency of fixing a typo on my homepage. I implemented a revalidation priority model that adjusts worker count and retry logic based on path patterns.
// app/Services/RevalidationStrategy.php
class RevalidationStrategy
{
public function getPriority(string $path): array
{
return match(true) {
$path === '' => ['workers' => 5, 'timeout' => 10, 'retry' => 5],
str_starts_with($path, 'blog/') => ['workers' => 2, 'timeout' => 30, 'retry' => 3],
default => ['workers' => 1, 'timeout' => 60, 'retry' => 2],
};
}
public function shouldCircuitBreak(string $revalidationId): bool
{
$failures = Redis::get("circuit:failures:nextjs") ?? 0;
if ($failures > 10) {
// Stop hammering a potentially down service
Redis::setex("circuit:open:nextjs", 300, '1');
Log::critical("Circuit breaker opened for Next.js revalidation");
return true;
}
return false;
}
}
I also added a dry-run mode that simulates revalidations during Next.js deployments. When I builds a preview deployment, it sets APP_ENV=preview, and my Laravel listener dumps revalidation payloads to logs instead of calling the API. No more surprise failures during deploy windows.
The Workflow Transformation
The impact was immediate and profound:
- I create content in Filament β write a technical deep-dive, hit "Publish."
-
Laravel emits
PostUpdatedβ the event carries the slug and category graph. - The handshake protocol activates β Redis queues the job, worker picks it up in ~5 seconds.
- Next.js revalidates atomically β all paths regenerate in parallel.
- Acknowledgment returns to Laravel β I see a green checkmark in Filament's activity log.
- Metrics populate in Grafana β I can track revalidation latency, success rates, and path patterns.
Before: I'd manually hit revalidation endpoints, check the logs, sometimes forget, and have stale content for hours.
After: I literally don't think about caching. It's a solved problem.
The cognitive load drop is the real win. I can focus on building features instead of babysitting cache state. The system is observable, resilient, and most importantly β boring. Boring infrastructure is good infrastructure.
Build Systems That Disappear
The Cache Handshake taught me a broader lesson: the best automation isn't flashy. It's invisible. It handles edge cases you haven't thought of yet. It fails gracefully. It provides observability when you need it and gets out of the way when you don't.
This pattern isn't just for Laravel and Next.js. The core idea β treat cross-system cache invalidation as a distributed transaction with acknowledgment β applies to any decoupled architecture. The Redis-backed state machine, the signed JWTs, the circuit breakers... these are the details that separate demo-grade from production-ready.
My portfolio isn't just a showcase of UI polish. It's a demonstration that I think in systems, tolerate complexity where it matters, and eliminate it everywhere else.
If you're wrestling with ISR reliability or event-driven architectures, you can see this system running live on Yabasha.dev where the content is always fresh.
I'm obsessed with systems that amplify human creativity. If you're building something similar β or just want to argue about cache invalidation strategies β lets chat. I'm always down for a spirited architecture debate.
Top comments (0)