Signal Lifecycle
A signal is born in a PHP controller, serialized into a JSON envelope, and executed on the frontend -- all within a single request/response cycle.
The Request Flow
Laravel Controller
|
v Resonance::flash('Task added')
| ->invalidate('demo.tasks.index')
| ->response($task)
|
ResonanceManager (queues signals in memory)
|
v
TransformResponse Middleware
| drainSignals() -> sort by priority -> serialize
v
JSON Envelope { data, meta: { signals, timestamp, trace_id } }
|
v HTTP Response
kubb-client (or NetworkAdapter)
| parses envelope, extracts signals
v
ResonanceClient.processSignals()
|
+---> invalidate: queryClient.invalidateQueries()
+---> token: networkAdapter.setToken()
+---> flash: toaster(message, variant)
+---> event: window.dispatchEvent()
+---> redirect: router.navigate()
Signal Types
| Type | Priority | Payload | Effect |
|---|---|---|---|
invalidate | 0 | scope: string[] | Marks React Query cache entries stale, triggers refetch |
token | 1 | token: string | null | Updates auth token in NetworkAdapter |
flash | 2 | message: string, variant: 'success' | 'error' | 'info' | Displays toast notification |
event | 3 | name: string, payload: unknown | Dispatches resonance:{name} CustomEvent on window |
redirect | 4 | to: string, replace?: boolean | Navigates via TanStack Router |
Priority Ordering
Signals sort by priority before serialization. The ordering is intentional:
- Invalidation first (0) -- Cache gets marked stale before UI feedback. By the time the toast shows, the refetch is in flight.
- Token (1) -- Auth refreshes before subsequent refetches use new credentials.
- Flash (2) -- User sees feedback while data refreshes.
- Events (3) -- Custom handlers after core signals.
- Redirect last (4) -- Navigation after everything else completes. Uses
queueMicrotask()for an extra guarantee that invalidations initiate before the page changes.
Backend: Queuing Signals
The ResonanceManager is a singleton in the service container. Controllers queue signals via the Resonance facade:
return Resonance::flash('Profile updated', 'success')
->invalidate('demo.profile.show', 'user')
->redirect('/dashboard')
->response($profile);
Each method pushes a signal and returns $this for chaining. The response() method calls drainSignals(), which sorts by priority, serializes, and clears the queue. Signals fire exactly once per request -- no duplicate toasts, no double invalidations.
Middleware: The Envelope
TransformResponse middleware intercepts every successful response on routes with the resonance middleware group:
private function transformResponse(Response $response): JsonResponse
{
if ($response instanceof RedirectResponse) {
$this->manager->redirect($response->getTargetUrl());
return $this->envelope(null);
}
if ($response instanceof JsonResponse) {
$data = $response->getData(true);
if (!$this->isResonanceEnvelope($data)) {
return $this->envelope($data);
}
return $response;
}
return $this->envelope($response->getContent());
}
The redirect interception is key -- return redirect()->route('tasks.index') becomes a signal, not a 302. The browser never follows the redirect; TanStack Router handles navigation client-side.
Non-successful responses (422, 500, etc.) pass through with standard HTTP semantics. The kubb-client handles error responses separately.
Frontend: Processing Signals
The kubb-client parses the envelope and hands signals to the processor for mutation responses (POST/PUT/PATCH/DELETE):
const isMutation = method !== 'GET';
if (isMutation && signalProcessor && json.meta?.signals?.length) {
signalProcessor(json.meta.signals);
}
return { data: json.data, status: response.status, statusText: response.statusText };
The ResonanceClient routes each signal to its handler:
processSignals(signals: ResonanceSignal[]): void {
for (const signal of signals) {
switch (signal.type) {
case 'invalidate':
handleInvalidate(signal.scope);
break;
case 'redirect':
handleRedirect(signal.to, signal.replace);
break;
case 'flash':
handleFlash(signal.message, signal.variant);
break;
case 'event':
handleEvent(signal.name, signal.payload);
break;
case 'token':
handleToken(signal.token);
break;
}
}
}
Error Responses
Error handling lives in the kubb-client, not in the signal system. Non-2xx responses are handled directly:
if (!response.ok) {
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('text/html') && response.status >= 500) {
// 500 with HTML (Whoops/dd output) -> error store for devtools
errorStore.add({ type: 'error', html: await response.text() });
throw new Error(`Server error: ${response.status}`);
}
// JSON error responses can still carry signals (e.g., flash on 401)
const errorData = await response.json().catch(() => null);
if (errorData?.meta?.signals) {
signalProcessor?.(errorData.meta.signals);
}
throw new Error(errorData?.message ?? response.statusText);
}
A 422 validation error can still carry flash messages. The backend can say "validation failed" and "here's why" in the same response.
Complete Cycle
Creating a task:
useDemoTasksStore().mutate({ data: { title: 'New task' } })fireskubb-clientsendsPOST /demo/taskswith XSRF token- Controller creates task, chains
Resonance::flash('Task added')->invalidate('demo.tasks.index') TransformResponsewraps response in envelope with sorted signalskubb-clientparses envelope, callssignalProcessor(signals)ResonanceClientinvalidates cache, shows toast, React Query refetches