Skip to main content

Resonance

Signal-based cache invalidation for Laravel + React Query


Your Laravel backend already knows what data changed after a mutation. Resonance lets it tell the frontend -- and automatically handles cache invalidation for the data your backend returns.

Write your controller logic. Declare what changed. Resonance generates type-safe React Query hooks from your Laravel routes, and the signal system handles cache invalidation, toast notifications, redirects, and auth token rotation -- all driven by the backend response.

You can still manage cache manually for anything outside the signal flow. Resonance handles the common case; it doesn't lock you out of React Query's full API.

POC Status

Resonance is in its early stages. I'm exploring whether this approach to backend-driven cache invalidation resonates with Laravel + React developers. Feedback, ideas, and contributions are welcome.

Demo repo: github.com/jhavenz/resonance-demo | Read the Substack article

Standing on Giants

React Query's caching model is exceptional -- a global store that deduplicates requests, background-refetches stale data, and shares cache entries across your entire component tree. Inertia elegantly bridges Laravel and frontend frameworks with zero-config page props. TanStack Router's file-based routing and loader system drives modern React SPAs.

Resonance connects these tools into a single loop: backend declares what changed, frontend executes.

The Round Trip

Here's what happens when a user creates a task -- from click to updated UI:

1. The route exists in Laravel:

Route::post('/demo/tasks', [TaskController::class, 'store'])
->name('demo.tasks.store');

Resonance generates a useDemoTasksStore() mutation hook from this route definition. The route name drives the hook name.

2. The controller handles the request and declares what changed:

public function store(StoreTaskRequest $request)
{
$task = $request->user()->tasks()->create($request->validated());

return Resonance::flash('Task added')
->invalidate('demo.tasks.index')
->response($task);
}

Two signals queued: a toast message, and a cache invalidation scope. The controller doesn't know or care how the frontend handles these.

3. Middleware wraps the response:

{
"data": { "id": 1, "title": "Buy groceries", "completed": false },
"meta": {
"signals": [
{ "type": "invalidate", "scope": ["demo.tasks.index"] },
{ "type": "flash", "message": "Task added", "variant": "success" }
]
}
}

Every Resonance response carries this envelope. Signals are sorted by priority -- invalidation fires before toasts, toasts before redirects.

4. The frontend uses the generated hook:

import { useDemoTasksStore } from '@/gen/hooks/useDemoTasksStore';

function AddTaskForm() {
const { mutate } = useDemoTasksStore();

return (
<form onSubmit={(e) => {
e.preventDefault();
mutate({ data: { title: 'Buy groceries' } });
}}>
{/* ... */}
</form>
);
}

No onSuccess callback. No queryClient.invalidateQueries(). No manual cache key management. The signal processor reads the response envelope, invalidates the matching cache entries, and shows the toast. Every component subscribed to useDemoTasksIndex() re-renders with fresh data.

Resonance automatically handles cache invalidation for the data your backend returns through signals. The responsibility lives in the controller, next to the business logic that knows what changed.

What's in These Docs

  • Signal Lifecycle -- How signals queue, serialize, and execute across the request/response boundary
  • How Invalidation Works -- URL-based query keys, predicate matching, and route-name-to-URL conversion
  • Dev Orchestrator -- The bun run dev command, code generation pipeline, and process coordination

Quick Start

bun run dev

Default test credentials: [email protected] / password

Demo pages at /demo/tasks, /demo/posts, /demo/profile, /demo/chat.