Skip to content
Back to Blog

Invalidate Next.js 16 ISR from Laravel: The Cache Handshake

Bashar AyyashDecember 22, 2025Updated September 18, 202611 min read2,144 words
Invalidate Next.js 16 ISR from Laravel: The Cache Handshake
TL;DR

Laravel invalidates Next.js 16 ISR on yabasha.dev by dispatching RevalidateFrontendJob from PostObserver; the job POSTs a tag and URL list to /api/revalidate with a timing-safe X-Revalidate-Token, Next.js calls revalidateTag('posts') and pings IndexNow. Time-based ISR at 180–300 seconds is the fallback. The Cache Handshake adds acknowledgement, retries and a circuit breaker for when staleness costs money.

11 min read · 2,144 words

The version I run, the version I'd sell, and the line between them.

I spend weeks architecting client systems with care. For my own portfolio I was invalidating cache like a junior developer: php artisan cache:clear on a cron job, and hope. The irony wasn't lost on me.

Yabasha.dev is a Laravel 12 API with a Filament admin behind a Next.js 16 frontend on ISR. Making the two talk without me playing telephone operator every time I published turned into two answers, not one: the simple version this site runs, and the Cache Handshake — the version I'd ship where a stale page costs money.

<!-- lift --> Cross-system cache invalidation is a distributed transaction. Fire-and-forget is fine right up until a stale page has a price.

Why did my Laravel + Next.js cache go stale for hours?

A cron job running cache:clear invalidates on a schedule, not on a change. When the schedule missed a publish, the old page served until the next tick. Then the first webhook I wrote had the opposite problem — it fired instantly and nobody checked whether it landed. Four failure modes, one root cause: no signal from the system that knows to the system that renders.

Failure modeWhat happenedWhy the naive approach can't fix it
Schedule missCron cleared on the hour; the post went out at :05Time-based purging can't know a change happened
No acknowledgementWebhook returned 200 on receipt; revalidation may still failHTTP 200 says "received", not "regenerated"
Deploy windowEndpoint down during next build; the call failedNothing queued the intent
CascadeA category change must hit the category page, every post in it, /blog, the feed and the sitemapOne URL per webhook can't express a dependency graph

I was spending more time verifying cache state than writing. Not engineering — wishful thinking.

What is ISR in Next.js 16, and why can't Laravel see inside it?

Incremental Static Regeneration serves a statically rendered page and regenerates it either on a timer (revalidate: 180) or on demand, when code inside the Next.js runtime calls revalidatePath() or revalidateTag(). The cache lives in Next.js. Laravel has no native way in — the only door is an HTTP route you write yourself.

That door is the design problem. Next.js's revalidateTag documentation explains how to purge a tag; it says nothing about the caller being another service on another box that may be down, mid-deploy, or sending the same request twice. Time-based revalidation papers over that with a window. For a portfolio that publishes twice a month a 3-minute window is fine. For the same architecture under a client's newsroom it isn't — and this site exists to demonstrate the architecture I sell, not the shortcut.

ISR, SSR or SSG for a CMS-backed site?

ISR, when content changes on a human schedule and reads dwarf writes. The table is the argument:

StrategyRender happensFresh after a CMS editOrigin loadFits a Laravel-backed blog?
SSGAt buildOnly after a full rebuildNoneNo — every publish is a deploy
SSREvery requestImmediatelyEvery request hits the APIOverkill for read-heavy content
ISR, time-basedAt build + every N secondsUp to N seconds lateLow, periodicYes, if N is small and staleness is tolerable
ISR, on-demandAt build + when toldNext request after the signalLowestYes — if the signal is reliable

On-demand ISR moves the whole problem into one phrase: if the signal is reliable. Everything below is about how reliable it has to be. For the layer above this — how the Laravel 12 API, Filament 4 and the Next.js 16 app sit in one Bun monorepo — see Laravel 12 + Next.js 16 Monorepo Architecture: A Production Blueprint.

How does yabasha.dev invalidate Next.js ISR from Laravel today?

yabasha.dev invalidates Next.js ISR with one queued job. PostObserver::saved() dispatches RevalidateFrontendJob when a published post changes; the job POSTs { tag: "posts", urls: [...] } to /api/revalidate with an X-Revalidate-Token header and an 8-second timeout. Next.js verifies the token, calls revalidateTag("posts"), and pings IndexNow with the URLs. Laravel Horizon retries a failed job up to 3 times. That's the whole system.

The URL list is the dependency graph, built where the change happens:

// app/Observers/PostObserver.php
protected function dispatchFrontendRevalidation(Post $post): void
{
    $urls = ['/blog', '/feed.xml', '/sitemap.xml', '/blog/'.$post->slug];

    if ($post->wasChanged('slug')) {
        $urls[] = '/blog/'.$post->getOriginal('slug');
    }

    RevalidateFrontendJob::dispatch('posts', array_values(array_unique($urls)));
}
// app/Jobs/RevalidateFrontendJob.php
public function handle(): void
{
    $token = config('services.frontend.revalidation_token');
    if (empty($token)) {
        return; // dormant in environments without a frontend
    }

    $response = Http::timeout(8)
        ->withHeaders(['X-Revalidate-Token' => $token])
        ->acceptJson()->asJson()
        ->post(rtrim(config('app.frontend_url'), '/').'/api/revalidate', [
            'tag'  => $this->tag,
            'urls' => $this->urls,
        ]);

    if (! $response->successful()) {
        report(new \RuntimeException("Frontend revalidate failed: HTTP {$response->status()}"));
    }
}
// apps/web/src/app/api/revalidate/route.ts (trimmed)
const token = request.headers.get("x-revalidate-token");
if (!verifyPasswordTiming(token, env.REVALIDATION_TOKEN)) {
  return NextResponse.json({ message: "Invalid token" }, { status: 401 });
}

if (!ALLOWED_TAGS.has(tag)) {
  return NextResponse.json({ message: `Tag '${tag}' is not allowed` }, { status: 400 });
}

revalidateTag(tag, "max");
after(() => pingIndexNow(parsed.urls)); // Bing, Yandex, Seznam, Naver, Yep

return NextResponse.json({ revalidated: true, tag, now: Date.now() });

Every fetch in lib/api.ts carries tags: ["posts"] and a time-based revalidate of 180 seconds on a post and 300 on the listing. Purging the tag makes the next request regenerate; the timer is the safety net if the purge never arrives. The same fetch layer feeds the assistant in How I Built an AI Agent for my Portfolio using Laravel & Next.js, so a stale cache would mean a stale answer.

How do you secure the Next.js revalidation endpoint?

Secure /api/revalidate with a secret in a header, compared in constant time, plus an allowlist of what the caller may purge. On yabasha.dev the token travels as X-Revalidate-Token; verifyPasswordTiming() compares it without leaking length or prefix through timing; a request that puts ?secret= in the query string gets a 410, because query strings land in access logs. The tag must be one of 12 allowed names, and the URL list is capped at 50 entries that must start with /.

That's a static shared secret, and I'll name the weakness: it lives in two .env files and rotates only when a human remembers. It is adequate here because the worst a leaked token can do is force a cache regeneration — expensive, not dangerous. The Handshake below replaces it with a short-lived signed JWT, which is the right call the moment the endpoint can do anything more than purge.

The anti-pattern to avoid is the one I started with: if (secret === process.env.SECRET). It's timing-leaky, it's usually in a query string, and it has no allowlist, so a leaked token purges everything.

What happens when revalidation fails today?

When the webhook fails on yabasha.dev, Horizon retries the job up to 3 times, the failure is reported to the log, and the 180-second time-based revalidation on the post fetch quietly repairs the page on the next tick. There is no acknowledgement and no callback. Laravel never learns whether Next.js regenerated anything — it learns only whether the HTTP call returned 2xx.

Situationyabasha.dev todayCost
Next.js 5xxHorizon retry ×3, then report()Up to 180 s stale on the post, 300 s on the listing
Next.js down during deploySame; the third failure is loggedSame — the timer catches it
Two edits in quick successionTwo purges; both regenerate from the APINone — purges are idempotent
Token missing in envJob returns early, silentlyEverything waits for the timer

For this site the cost column is acceptable: nobody loses money because a blog post is three minutes late. Read that column again for a bank's rate page or a newsroom's homepage, and the simple version stops being a shortcut and starts being a liability. That's the line where the Handshake begins.

<!-- lift --> A webhook that returns 200 has told you it was received, not that it worked. Those are different sentences.

When is the simple version not enough — and what does the Cache Handshake add?

The Cache Handshake treats invalidation as a two-phase commit with Redis as referee. Laravel writes the intent to a Redis list with a unique revalidation_id; a worker POSTs to Next.js with a signed JWT; Next.js regenerates and calls Laravel back with the same id; only that callback marks the record completed. Failures re-queue up to 3 times, and a circuit breaker opens after 10 failures for 300 seconds so a dead deploy isn't hammered.

This is a design, not what the portfolio runs — the portfolio doesn't need it. It's what I'd ship where staleness has a price. The pieces:

// app/Listeners/QueueRevalidation.php — write the intent, never call Next.js directly
Redis::rpush('revalidation:queue', json_encode([
    'revalidation_id' => $event->revalidationId,
    'slug'            => $event->post->slug,
    'dependencies'    => ['blog?category='.$event->post->category->slug, 'blog', ''],
]));

Redis::hmset("revalidation:{$event->revalidationId}:status", [
    'state' => 'pending', 'attempts' => 0, 'created_at' => now()->timestamp,
]);
// app/Jobs/ProcessRevalidation.php — signed request, bounded retries
$token = JWT::encode(['sub' => $id, 'exp' => now()->addMinutes(5)->timestamp], $secret, 'HS256');

$response = Http::withToken($token)->post("{$nextjs}/api/revalidate", $payload);

if ($response->failed()) {
    $attempts < 3
        ? Redis::rpush('revalidation:queue', $raw)                 // re-queue
        : Redis::hset("revalidation:{$id}:status", 'state', 'failed');
    return;
}

Redis::hset("revalidation:{$id}:status", 'state', 'acknowledged');
// app/api/revalidate/route.ts — regenerate, then close the loop
revalidatePath(`/blog/${slug}`);
await Promise.all(dependencies.map((p: string) => revalidatePath(`/${p}`)));

await fetch(`${process.env.LARAVEL_URL}/api/revalidation/complete`, {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.REVALIDATION_SECRET}` },
  body: JSON.stringify({ revalidation_id }),
});
// app/Services/RevalidationStrategy.php — not all paths deserve the same budget
return match (true) {
    $path === ''                    => ['workers' => 5, 'timeout' => 10, 'retry' => 5], // homepage
    str_starts_with($path, 'blog/') => ['workers' => 2, 'timeout' => 30, 'retry' => 3],
    default                         => ['workers' => 1, 'timeout' => 60, 'retry' => 2],
};
ConcernSimple version (live)Cache Handshake (design)
Knows it worked?No — knows the call returned 2xxYes — callback flips pending → acknowledged → completed
AuthStatic header token, timing-safeHS256 JWT, 5-minute expiry, sub = revalidation id
RetryHorizon ×3×3 with re-queue, then failed + log
Runaway failureNothing stops itCircuit opens after 10 failures for 300 s
PriorityAll paths equalHomepage 5 workers / 10 s; old post 1 worker / 60 s
Deploy windowsTimer catches itAPP_ENV=preview dry-run logs payloads instead of sending
Moving parts1 job, 1 routeQueue, worker, callback route, strategy, breaker
<!-- lift --> Not all revalidations are equal: a typo on the homepage and an edit to a two-year-old post should not share a retry budget.

Is there example code?

The live version is four files in the yabasha.dev monorepo: apps/backend/app/Observers/PostObserver.php (builds the URL list), apps/backend/app/Jobs/RevalidateFrontendJob.php (the call), apps/web/src/app/api/revalidate/route.ts (token check, allowlist, revalidateTag, IndexNow) and apps/web/src/lib/api.ts (the tagged fetches). The Handshake snippets above are the design in full; the Laravel side is an event, a listener, a job and a strategy class, and the Next.js side is one route plus one callback.

What did fixing this actually change?

Cron cache:clearTagged revalidation (today)
Publish → liveNext cron tick, or never if it missedThe next request after the purge — no waiting on a window
Deploy windowsCleared cache into a half-deployed appJob retried by Horizon; timer as the net
CascadesEverything, every hour/blog, the feed, the sitemap and the post — nothing else
Search enginesLearned of changes on their own crawlIndexNow pinged with the exact URLs
Where my attention goesCache state, weeklyNowhere. It's boring, and boring infrastructure is good infrastructure

The workflow now: write in Filament, publish, Laravel dispatches the job, Next.js purges the tag, the next visitor gets the new page, and Bing already knows. I don't think about caching.

The catch

The Handshake is more machinery than a portfolio needs — that's both the point and the warning. Ship the simple version until a stale page costs something, and know exactly which four properties you're missing when it does: acknowledgement, bounded retries, a breaker, and priority. It's the same discipline as the agent work in Building Production AI Agents That Work: What Failed First: the demo and the production version differ in what happens when the call fails, not in what happens when it succeeds.

So the question isn't whether your webhook works. It's this: when it fails at 03:00 during a deploy, who finds out first — you, or your reader? 🎯

Building the same stack for a client? That's the architecture on /services/laravel-nextjs.

Bashar Ayyash
AUTHOR

Bashar Ayyash (Yabasha)

AI Systems Architect for regulated industries — evals, harness design, AI security.

Bashar Ayyash is an AI engineer and dev lead in Amman, Jordan. 20 years shipping software, 4 years inside Alrajhi Bank building production RAG and agent systems with evals, guardrails and monitoring — in Arabic and English. He writes at yabasha.dev and builds open-source tooling for AI-assisted development.

Newsletter

Practical AI + full-stack insights for MENA builders. No spam.

Read more on the blog

Browse the latest articles or explore the full archive.