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

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 mode | What happened | Why the naive approach can't fix it |
|---|---|---|
| Schedule miss | Cron cleared on the hour; the post went out at :05 | Time-based purging can't know a change happened |
| No acknowledgement | Webhook returned 200 on receipt; revalidation may still fail | HTTP 200 says "received", not "regenerated" |
| Deploy window | Endpoint down during next build; the call failed | Nothing queued the intent |
| Cascade | A category change must hit the category page, every post in it, /blog, the feed and the sitemap | One URL per webhook can't express a dependency graph |
I was spending more time verifying cache state than writing. Not engineering — wishful thinking.
Continue Reading
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:
| Strategy | Render happens | Fresh after a CMS edit | Origin load | Fits a Laravel-backed blog? |
|---|---|---|---|---|
| SSG | At build | Only after a full rebuild | None | No — every publish is a deploy |
| SSR | Every request | Immediately | Every request hits the API | Overkill for read-heavy content |
| ISR, time-based | At build + every N seconds | Up to N seconds late | Low, periodic | Yes, if N is small and staleness is tolerable |
| ISR, on-demand | At build + when told | Next request after the signal | Lowest | Yes — 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.
| Situation | yabasha.dev today | Cost |
|---|---|---|
| Next.js 5xx | Horizon retry ×3, then report() | Up to 180 s stale on the post, 300 s on the listing |
| Next.js down during deploy | Same; the third failure is logged | Same — the timer catches it |
| Two edits in quick succession | Two purges; both regenerate from the API | None — purges are idempotent |
| Token missing in env | Job returns early, silently | Everything 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],
};| Concern | Simple version (live) | Cache Handshake (design) |
|---|---|---|
| Knows it worked? | No — knows the call returned 2xx | Yes — callback flips pending → acknowledged → completed |
| Auth | Static header token, timing-safe | HS256 JWT, 5-minute expiry, sub = revalidation id |
| Retry | Horizon ×3 | ×3 with re-queue, then failed + log |
| Runaway failure | Nothing stops it | Circuit opens after 10 failures for 300 s |
| Priority | All paths equal | Homepage 5 workers / 10 s; old post 1 worker / 60 s |
| Deploy windows | Timer catches it | APP_ENV=preview dry-run logs payloads instead of sending |
| Moving parts | 1 job, 1 route | Queue, worker, callback route, strategy, breaker |
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:clear | Tagged revalidation (today) | |
|---|---|---|
| Publish → live | Next cron tick, or never if it missed | The next request after the purge — no waiting on a window |
| Deploy windows | Cleared cache into a half-deployed app | Job retried by Horizon; timer as the net |
| Cascades | Everything, every hour | /blog, the feed, the sitemap and the post — nothing else |
| Search engines | Learned of changes on their own crawl | IndexNow pinged with the exact URLs |
| Where my attention goes | Cache state, weekly | Nowhere. 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 (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.
Related Articles

Laravel 12 and Next.js 16 in One Bun Monorepo: What yabasha.dev Runs

Graph Engineering Is Mostly Airflow With A New Coat Of Paint

The $18K Ceiling Breaker: Skills That Actually Move Your Number

Your Call Logs Are the Only Training Data That Actually Matters
Read more on the blog
Browse the latest articles or explore the full archive.