Discover the 'Cache Handshake Protocol,' a robust solution for Laravel and Next.js ISR cache invalidation, ensuring reliability and observability.

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:clearon 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 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.
I chose this specific combination for brutal efficiency:
rpush/blpop give me the reliability without the ceremony.Cache invalidation is computer science's second hardest problem, and my setup had four critical failure modes:
I was spending more time manually verifying cache state than actually writing content.
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.
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
]);
}
}
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");
}
}
}
}
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']);
});
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 impact was immediate and profound:
PostUpdated — the event carries the slug and category graph.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.
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.
Last updated on December 23, 2025