DEV Community

Cover image for The "Cache Handshake": How Laravel Events Control Next.js 16 ISR
Bashar Ayyash
Bashar Ayyash

Posted on • Originally published at yabasha.dev

The "Cache Handshake": How Laravel Events Control Next.js 16 ISR

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/blpop give me the reliability without the ceremony.

The Silent Failure Mode

Cache invalidation is computer science's second hardest problem, and my setup had four critical failure modes:

  1. No Acknowledgment: Laravel would fire a webhook and pray. Next.js might receive it, might process it, might fail silently. I had zero observability.
  2. 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.
  3. 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.
  4. 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
        ]);
    }
}

Enter fullscreen mode Exit fullscreen mode

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");
            }
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

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 }
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

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']);
});

Enter fullscreen mode Exit fullscreen mode

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;
    }
}

Enter fullscreen mode Exit fullscreen mode

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:

  1. I create content in Filament β€” write a technical deep-dive, hit "Publish."
  2. Laravel emits PostUpdated β€” the event carries the slug and category graph.
  3. The handshake protocol activates β€” Redis queues the job, worker picks it up in ~5 seconds.
  4. Next.js revalidates atomically β€” all paths regenerate in parallel.
  5. Acknowledgment returns to Laravel β€” I see a green checkmark in Filament's activity log.
  6. 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)