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.
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 devcommand, 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.