Skip to main content

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

TypePriorityPayloadEffect
invalidate0scope: string[]Marks React Query cache entries stale, triggers refetch
token1token: string | nullUpdates auth token in NetworkAdapter
flash2message: string, variant: 'success' | 'error' | 'info'Displays toast notification
event3name: string, payload: unknownDispatches resonance:{name} CustomEvent on window
redirect4to: string, replace?: booleanNavigates 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:

  1. useDemoTasksStore().mutate({ data: { title: 'New task' } }) fires
  2. kubb-client sends POST /demo/tasks with XSRF token
  3. Controller creates task, chains Resonance::flash('Task added')->invalidate('demo.tasks.index')
  4. TransformResponse wraps response in envelope with sorted signals
  5. kubb-client parses envelope, calls signalProcessor(signals)
  6. ResonanceClient invalidates cache, shows toast, React Query refetches