"The cobbler's children have no shoes." We ship bulletproof APIs for clients, then watch our own portfolio sites crumble from stale ISR pages and cross-domain auth bugs. When yabasha.dev started serving stale content during every deployment, I stopped treating it like a side project and built a real protocol.
TL;DR
- Laravel Sanctum + httpOnly cookies eliminates CSRF mysteries across domains
- Cache Handshake Protocol: Laravel events → Horizon queues → Redis state tracking → Next.js revalidation
- Deployment choreography: Staggered deploys with health-check gates prevent race conditions
- Result: Zero stale-page incidents in 40+ deployments over 3 months
The Problem
Every deployment followed the same ritual:
- Push Next.js to production
- Realize half the ISR pages were stale because revalidation calls vanished during the 30-second deploy window
- Manually purge Redis and pray
Authentication was worse. Safari users got logged out because of cross-domain cookie quirks. CSRF tokens expired mid-session. I was babysitting cache state instead of building features.
The Stack
- Backend: Laravel 12 (API-first)
- Frontend: Next.js 16 (App Router + ISR)
- Cache/Queue: Redis 7 + Laravel Horizon
- Observability: Sentry + structured JSON logs
Authentication That Actually Works
No tokens in localStorage. No manual Authorization headers.
Laravel config (config/sanctum.php):
'stateful' => ['localhost:3000', 'yabasha.dev', '*.yabasha.dev'],
'expiration' => 720, // 12 hours
Next.js API client:
export async function apiClient(endpoint: string, options: RequestInit = {}) {
const res = await fetch(`${API_URL}${endpoint}`, {
...options,
credentials: 'include',
mode: 'cors',
});
if (res.status === 419) {
// CSRF expired: re-fetch token and retry ONCE
await getCsrfCookie();
return apiClient(endpoint, options);
}
return res;
}
Tradeoff: Requires disciplined CORS config. Benefit: XSS can't steal httpOnly cookies; no token refresh dance.
The Cache Handshake Protocol
Instead of fire-and-forget webhook calls, I built a distributed state machine:
Laravel Event → Horizon Queue → Redis Tracking → Next.js Revalidation
1. Emit Domain Event
// app/Events/ContentInvalidated.php
class ContentInvalidated
{
public function __construct(
public string $type,
public string $id,
public array $tags,
public string $revalidation_id,
) {}
}
2. Queue with Idempotency
// app/Jobs/RevalidateNextJsCache.php
class RevalidateNextJsCache implements ShouldBeUnique
{
public $tries = 5;
public $backoff = [10, 30, 60, 120, 300];
public function uniqueId(): string
{
return $this->revalidation_id; // Prevents duplicate jobs
}
public function handle(): void
{
Redis::hset('revalidation_state', $this->revalidation_id, json_encode([
'status' => 'inflight',
'attempt' => $this->attempts(),
]));
$response = Http::post(config('services.nextjs.url') . '/api/revalidate', [
'tags' => $this->tags,
]);
if ($response->failed()) {
// State updated before retry
throw new Exception("Revalidation failed");
}
Redis::hdel('revalidation_state', $this->revalidation_id);
}
}
3. Next.js Revalidation Endpoint
// app/api/revalidate/route.ts
export async function POST(request: NextRequest) {
const revalidation_id = request.headers.get('x-revalidation-id');
const { tags } = await request.json();
// Idempotency: Skip if already processed
const seen = await redis.get(`revalidations:processed:${revalidation_id}`);
if (seen) return NextResponse.json({ status: 'already_processed' });
for (const tag of tags) {
revalidateTag(tag);
}
await redis.set(`revalidations:processed:${revalidation_id}`, '1', { ex: 86400 });
return NextResponse.json({ status: 'success' });
}
Why this survives edge cases:
- Next.js API down? Horizon retries with exponential backoff
- Response lost? Idempotency prevents double-work
- Job fails? Redis shows exact failure state
Deployment Choreography
Deploying both apps simultaneously caused cascading 500s. Solution:
- Deploy API first → Wait for health check
-
Deploy frontend → Set
MAX_REVALIDATION_RETRY=0to pause invalidations - Resume invalidations → Process queued backlog
# CI hook after frontend deploy
curl -X POST <https://api.yabasha.dev/api/revalidation/resume>
This eliminates the deploy-window race condition.
The State Machine
Most guides treat revalidation as fire-and-forget. I model it as a state machine:
pending → inflight → completed
↓ (fail & retry)
failed → dead-letter
Decision matrix:
| Use Sync Webhook | Use Queue + State |
|---|---|
| < 10 pages to purge | > 10 tags or wildcard |
| Dev/staging | Production |
| Can tolerate silent failure | Must audit every change |
Results
Before: 2-3 stale-content incidents/week; manual Redis purges
After: Zero incidents in 40+ deployments over 3 months
Measurable outcomes:
- Queue failure rate: ~0.3% (auto-retries resolve 95%)
- Time-to-live for changes: p95 < 15 seconds
- Deployment incident rate: Down from 30% to 0%
Implementation Checklist
- [ ] Configure Sanctum with
statefuldomains - [ ] Create
ContentInvalidatedevent +RevalidateNextJsCachejob - [ ] Implement
ShouldBeUniquewithrevalidation_id - [ ] Set up Horizon with
maxJobsbackpressure - [ ] Build idempotent Next.js revalidation API
- [ ] Add Redis state tracking in job
handle()andfailed() - [ ] Create deploy gate: pause/resume invalidations
- [ ] Install Sentry in both apps with shared trace IDs
The Broader Lesson
Operational excellence is a habit, not a budget. You don't need a platform team to implement idempotency keys or backpressure. You need to decide your own platform is worth the effort.
Continue reading the full blueprint for:
- Complete monorepo setup with Bun workspaces
- 5 failure modes & production mitigations
- Redis backpressure configuration
- Exact deployment scripts
- State machine decision trees
Top comments (0)