Chain →toSignal() on any
Eloquent or Query Builder query and get back a reactive
Signal — wired into
Livewire 3/4 and Alpine.js with zero boilerplate.
class OrderDashboard extends Component
{
public Signal $orders; // serializes between Livewire requests
public function mount(): void
{
$this->orders = Order::pending()
->toSignal(); // one query, zero boilerplate
}
public function refresh(): void
{
$this->orders = $this->orders->refresh(); // re-runs same SQL
}
}
clone $query?The clone pattern silently breaks inside Livewire. Here's exactly why.
class OrderDashboard extends Component
{
// Can't store Builder as public property.
// Livewire can't serialize it.
public array $orders = [];
public int $count = 0;
public ?array $first = null;
private function baseQuery(): Builder
{
// Duplicated every time
return DB::table('orders')
->where('status', $this->status)
->where('user_id', auth()->id())
->orderBy('created_at', 'desc');
}
public function mount(): void
{
$q = $this->baseQuery();
$this->orders = $q->get()->toArray(); // hit 1
$this->count = (clone $q)->count(); // hit 2 ← extra
$this->first = (clone $q)->first(); // hit 3 ← extra
}
}
✗ 3 separate DB hits for the same filter
✗ Builder can't be a Livewire public property
✗ toArray() discards Eloquent models
class OrderDashboard extends Component
{
// Serializes & hydrates automatically.
public Signal $orders;
public function mount(): void
{
// One query, one DB hit.
// count / first / pluck come for free.
$this->orders = DB::table('orders')
->where('status', $this->status)
->where('user_id', auth()->id())
->orderBy('created_at', 'desc')
->toSignal();
}
public function refresh(): void
{
$this->orders = $this->orders->refresh();
}
}
✓ 1 DB hit, count & first derived for free
✓ Signal hydrates cleanly as Livewire property
✓ Eloquent models preserved on refresh
Livewire serializes every public property to JSON between requests. A raw
Builder instance
cannot be serialized — Livewire will either throw or silently discard it. The common
workaround is to store the result as a plain array,
but that strips all Eloquent model methods and relationships from every row.
// Builder can't be a public property.
// Livewire throws on the next request.
public Builder $query; // ✗ breaks
// Forced to store as plain array —
// all Eloquent methods are gone.
public array $orders = []; // ✗ stdClass rows
// Signal implements Wireable.
// Livewire dehydrates/hydrates it
// automatically between every request.
public Signal $orders; // ✓ just works
Every call to count(),
first(), or
pluck()
on a cloned builder fires a separate SQL query — even though the data is already in memory
from the first get().
A typical dashboard with three stats means three round-trips to the database for one
logical dataset.
$q = $this->baseQuery();
$this->orders = $q->get()->toArray(); // query 1
$this->count = (clone $q)->count(); // query 2
$this->first = (clone $q)->first(); // query 3
$this->orders = Order::pending()->toSignal();
// All derived from the same Collection —
// zero extra queries.
$signal->count(); // ✓ no query
$signal->first(); // ✓ no query
$signal->pluck('x');// ✓ no query
When the user triggers a refresh — via a button, wire:poll,
or a Livewire event — the clone pattern forces you to rebuild the entire query from scratch.
If the query definition ever changes you must update every place that rebuilds it.
Signal stores the original
SQL and bindings internally, so refresh()
re-executes exactly that query — no rebuilding, no drift.
public function refresh(): void
{
// Must rebuild — duplicated logic.
// Change the filter in one place and
// forget the other → silent divergence.
$q = DB::table('orders')
->where('status', $this->status)
->where('user_id', auth()->id())
->orderBy('created_at', 'desc');
$this->orders = $q->get()->toArray();
$this->count = (clone $q)->count();
}
public function refresh(): void
{
// Re-runs the original SQL with the
// same bindings stored inside Signal.
// The query is defined exactly once.
$this->orders = $this->orders->refresh();
}
// Works perfectly with wire:poll too:
// <div wire:poll.5000ms="refresh">
Passing query results to Alpine requires manually
json_encode()-ing
each variable and forwarding it from the controller to the view. Any meta value —
like a polling interval — has to be forwarded separately and kept in sync by hand.
@js($signal)
passes the entire dataset in one object: rows live in signal.data,
derived stats and config in signal.meta.
// Controller
$rows = DB::table('orders')->where(...)->get()->toArray();
$count = DB::table('orders')->where(...)->count(); // extra hit
$interval = config('dashboard.polling_interval'); // forwarded by hand
return view('dashboard', compact('rows', 'count', 'interval'));
// Blade
<div x-data="{
rows: {{ json_encode($rows) }},
count: {{ $count }},
interval: {{ $interval }}
}">
// Controller
$signal = DB::table('orders')->where(...)->toSignal();
return view('dashboard', compact('signal'));
// Blade
<div x-data="{ signal: @js($signal) }">
<!-- signal.data → rows -->
<!-- signal.meta.count → derived -->
<!-- signal.meta.polling_interval → from config -->
</div>
When you call toArray()
on a query result to survive Livewire serialization, each row becomes a plain
stdClass.
You lose casting, accessors, relationships, and every Eloquent method on the model.
Signal
stores the model class name alongside the raw SQL. On
refresh()
it re-hydrates the query through Eloquent, so every row comes back as a full model instance.
// After serialization round-trip:
$order = $this->orders[0]; // stdClass
$order->formattedTotal(); // ✗ method does not exist
$order->customer->name; // ✗ relationship gone
$order->status_label; // ✗ accessor gone
// After Signal::refresh():
$order = $this->orders->first(); // App\Models\Order
$order->formattedTotal(); // ✓ method works
$order->customer->name; // ✓ eager-loaded
$order->status_label; // ✓ accessor works
When you need JS to poll a backend endpoint, the interval is typically a magic number
hard-coded in the Blade template or a separate JS file. It's disconnected from your
Laravel config, easy to forget to update, and inconsistent across components.
Signal carries polling_interval
inside signal.meta — sourced
directly from config('sql-to-signal.polling_interval')
and overridable per call. One config key, one source of truth.
// Hard-coded — out of sync with config
setInterval(() => fetchOrders(), 5000);
// Or wired manually through a separate variable:
setInterval(() => fetchOrders(), {{ $interval }});
// Requires extra controller variable every time.
// Always in sync with config — no extra variable.
setInterval(() => fetchOrders(), signal.meta.polling_interval);
// Override per call if needed:
$signal = Order::pending()->toSignal([
'polling_interval' => 10_000,
]);
Built for the TALL stack. Zero config required.
Works on both Model::query() and DB::table(). Eloquent models stay fully hydrated through the Livewire cycle.
Declare public Signal $orders and it serializes/hydrates automatically. No casting, no JSON property, no extra work.
Pass the whole signal to Alpine with @js($signal). Access signal.data and signal.meta — polling interval included.
Throws OverflowException if results exceed the configured limit — prevents accidentally serializing huge datasets through Livewire's wire cycle.
Pass →toSignal([...]) to override polling interval, max_rows, or cache TTL for a single call — without touching global config.
Call $signal→refresh() to re-run the original SQL with the same bindings. Works perfectly with wire:poll.
Up and running in under a minute.
composer require laravelldone/sql-to-signal
php artisan vendor:publish --tag="sql-to-signal-config"
return [
'cache' => [
'enabled' => false,
'ttl' => 60, // seconds
],
'polling_interval' => 2000, // ms — passed to Alpine.js via signal.meta
'as_collection' => true, // getData() returns Collection or array
'max_rows' => 1000, // null = unlimited
];
Pick your integration path.
use Livewire\Component;
use Laravelldone\SqlToSignal\Signal;
class OrderDashboard extends Component
{
public Signal $orders;
public function mount(): void
{
$this->orders = Order::pending()->toSignal();
}
public function refresh(): void
{
$this->orders = $this->orders->refresh();
}
public function render()
{
return view('livewire.order-dashboard');
}
}
<div wire:poll.5000ms="refresh">
@foreach ($orders->getData() as $order)
<div>{{ $order->id }} — {{ $order->status }}</div>
@endforeach
<p>Total: {{ $orders->count() }}</p> {{-- no extra query --}}
<p>First: {{ $orders->first()->id }}</p> {{-- no extra query --}}
</div>
{
"data": [{ "id": 1, "status": "pending" }, "..."],
"query": "select * from `orders` where `status` = ?",
"bindings": ["pending"],
"model_class": "App\\Models\\Order",
"connection_name": "mysql",
"config": { "polling_interval": 2000, "max_rows": 1000 }
}
$signal = DB::table('orders')->where(...)->toSignal();
return view('dashboard', compact('signal'));
<div x-data="{ signal: @js($signal) }">
<template x-for="row in signal.data" :key="row.id">
<div x-text="row.id + ' — ' + row.status"></div>
</template>
<p>Total: <span x-text="signal.meta.count"></span></p>
</div>
{
"data": [
{ "id": 1, "status": "pending", "total": "120.00" },
{ "id": 2, "status": "pending", "total": "89.50" }
],
"meta": {
"count": 2,
"model_class": "App\\Models\\Order",
"polling_interval": 2000
}
}
setInterval(
() => fetch('/orders').then(r => r.json()).then(d => signal = d),
signal.meta.polling_interval // config-driven, no hard-coding
);
$signal = Order::query()
->with('customer')
->where('status', 'pending')
->toSignal();
$signal->getModelClass(); // "App\Models\Order"
$signal->first(); // App\Models\Order { ... }
$signal->pluck('total'); // Collection [120.00, 89.50, 45.00]
$signal = DB::table('orders')
->where('status', 'pending')
->orderBy('created_at', 'desc')
->toSignal();
$signal->getQuery(); // "select * from `orders` where `status` = ? ..."
$signal->getBindings(); // ["pending"]
$signal->count(); // 3
$signal->getData(); // Illuminate\Support\Collection { ... }
$signal = Product::active()->toSignal([
'polling_interval' => 5000,
'max_rows' => 50,
]);
// signal.meta.polling_interval === 5000
// OverflowException if Product::active() returns > 50 rows
An OverflowException is thrown when the result set exceeds max_rows. This prevents accidentally serializing large datasets through Livewire's JSON wire cycle.
// Throws: table has 1500 rows, limit is 500
$signal = Report::query()->toSignal(['max_rows' => 500]);
// OverflowException: Signal result set exceeds max_rows limit of 500. Got 1500.
// Safe — scope first
$signal = Report::thisMonth()->toSignal(['max_rows' => 500]);
// Unlimited — use with care
$signal = Report::query()->toSignal(['max_rows' => null]);
All public methods on the Signal class.
| Method | Returns | Description |
|---|---|---|
| getData() | Collection | Full result set |
| getQuery() | string | Raw SQL with ? placeholders |
| getBindings() | array | Ordered binding values |
| getModelClass() | string|null | Eloquent model class, or null for raw queries |
| getConnectionName() | string|null | Database connection name |
| refresh() | Signal | Re-runs the original SQL, returns fresh Signal |
| count() | int | Row count — no extra query |
| isEmpty() | bool | true when result set is empty |
| first() | mixed | First row/model, or null |
| pluck(key, value?) | Collection | Delegates to Collection::pluck() |
| toArray() | array | ['data' => [...], 'meta' => [...]] |
| toLivewire() | array | Full serialized payload for Livewire transport |
| Signal::fromLivewire($v) | Signal | Reconstructs a Signal from a Livewire payload |
Release history for laravelldone/sql-to-signal.
toSignal(['per_page' => 15, 'page' => 1]) paginates at query time
total, per_page, current_page, last_page, from, to
isPaginated() getTotal() getPerPage() getCurrentPage() getLastPage() nextPage() prevPage() goToPage(int)
refresh() on a paginated Signal re-runs the same page
toLivewire/fromLivewire round-trip
model_class is now validated as a genuine
Eloquent Model subclass before instantiation,
preventing PHP object gadget attacks via tampered Livewire payloads
connection_name is validated against
database.connections config before use,
preventing refresh() SQL being redirected
to an unintended database connection
Upgrade recommended for all users running v1.0.0 or v1.0.1.
→toSignal() macro on Query\Builder and Eloquent\Builder
JsonSerializable — pass directly to @js() for Alpine.js
max_rows overflow guard, per-call config override
cache.enabled config
Last synced with GitHub API at page load · view all tags ↗
All laravelldone packages target the TALL stack. Zero magic, zero boilerplate.
laravelldone/filament-signal
Use a Signal as a data source directly in Filament v3 tables. Auto-polling, no custom query needed.
In Developmentlaravelldone/???
Watch the GitHub org for new TALL stack packages focused on eliminating boilerplate.
Plannedlaravelldone/???
Follow on GitHub to be notified when new packages drop.
github.com/neon2027