laravelldone / sql-to-signal
Call ->toSignal() on Eloquent/Query Builder chains to get a reactive Signal object for Livewire 3/4 and Alpine.js
Requires
- php: ^8.2
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
- livewire/livewire: ^3.0|^4.0
- spatie/laravel-package-tools: ^1.9
Requires (Dev)
- laravel/pint: ^1.0
- mockery/mockery: ^1.6
- nunomaduro/larastan: ^2.0|^3.0
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^2.0|^3.0|^4.0
- pestphp/pest-plugin-laravel: ^2.0|^3.0|^4.0
README
Call ->toSignal() on any Eloquent or Query Builder chain and get back a reactive Signal object — ready to wire into Livewire 3/4 components and Alpine.js without any boilerplate.
$signal = User::where('active', true)->toSignal();
Why not just clone $query?
The clone pattern is the typical workaround when you need to reuse a query builder — but it falls apart quickly in Livewire and Alpine.js contexts.
Reusing a query in a Livewire component
Without toSignal() — clone pattern
class OrderDashboard extends Component { // You can't store a QueryBuilder as a public property. // Livewire can't serialize it — it will throw or silently drop it. // So you have to rebuild the query from scratch on every request. public array $orders = []; // you lose Collection methods public int $count = 0; public ?array $first = null; private function baseQuery(): Builder { // Duplicated every time: if filters change you must update in multiple places 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 query $this->first = (clone $q)->first(); // hit 3 ← extra query } public function refresh(): void { // Rebuild everything again — same 3 queries $q = $this->baseQuery(); $this->orders = $q->get()->toArray(); $this->count = (clone $q)->count(); $this->first = (clone $q)->first(); } }
Problems:
cloneonly works within the same request — you can't put aBuilderin a Livewire property- The query definition is repeated or called through a private helper — easy to drift out of sync
- 3 separate database hits to get the same data
toArray()discards the model — you get raw stdClass, no Eloquent methods on rows
With toSignal()
class OrderDashboard extends Component { public Signal $orders; // serializes/hydrates automatically between requests public function mount(): void { // One query, one database 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 { // Re-runs the exact same SQL — no need to rebuild the query $this->orders = $this->orders->refresh(); } }
Total: {{ $orders->count() }} {{-- no extra query --}} First: {{ $orders->first()->id }} {{-- no extra query --}}
Passing data to Alpine.js
Without toSignal()
// Controller / Livewire component $rows = DB::table('orders')->where(...)->get()->toArray(); $count = DB::table('orders')->where(...)->count(); // cloned query, second hit $interval = config('dashboard.polling_interval'); // manually forwarded return view('dashboard', compact('rows', 'count', 'interval'));
<div x-data="{ rows: {{ json_encode($rows) }}, count: {{ $count }}, interval: {{ $interval }} }">
Gotchas:
- Two database hits for the same filter
- You manually
json_encodeeach piece - Polling interval is a magic number hard to change in one place
- No single object you can pass to a sub-component or an API response
With toSignal()
$signal = DB::table('orders')->where(...)->toSignal(); return view('dashboard', compact('signal'));
<div x-data="{ signal: @js($signal) }"> {{-- signal.data, signal.meta.count, signal.meta.polling_interval --}} {{-- all in one place, one query, zero manual wiring --}} </div>
Summary
clone $query |
->toSignal() |
|
|---|---|---|
| Survives Livewire serialization | No — Builder can't be a public property | Yes — Signal hydrates/dehydrates cleanly |
| Database hits for count + first | Extra query each | Zero — derived from the same Collection |
| Refresh in Livewire | Rebuild query from scratch | $signal->refresh() |
| Alpine.js wiring | Manual json_encode per variable |
@js($signal) — data + meta in one shot |
| Model hydration on refresh | Lost — raw stdClass |
Preserved — Eloquent models rebuilt |
| Polling interval in sync | Hard-coded in JS | Carried in signal.meta.polling_interval |
Requirements
- PHP 8.2+
- Laravel 11, 12, or 13
- Livewire 3 or 4
Installation
composer require laravelldone/sql-to-signal
The service provider is auto-discovered. To publish the config file:
php artisan vendor:publish --tag="sql-to-signal-config"
Configuration
config/sql-to-signal.php:
return [ 'cache' => [ 'enabled' => false, 'ttl' => 60, // seconds ], // Passed as meta for Alpine.js polling wiring 'polling_interval' => 2000, // milliseconds // true = getData() returns a Collection, false = plain array 'as_collection' => true, // Max rows allowed in a Signal (null = unlimited) 'max_rows' => 1000, ];
Basic Usage
Query Builder
use Illuminate\Support\Facades\DB; $signal = DB::table('orders') ->where('status', 'pending') ->orderBy('created_at', 'desc') ->toSignal(); // $signal is a Signal instance $signal->getQuery(); // "select * from `orders` where `status` = ? order by `created_at` desc" $signal->getBindings(); // ["pending"] $signal->count(); // 3 $signal->getData(); // Illuminate\Support\Collection { // 0 => { "id": 1, "status": "pending", "total": 120.00, ... }, // 1 => { "id": 2, "status": "pending", "total": 89.50, ... }, // 2 => { "id": 3, "status": "pending", "total": 45.00, ... }, // }
Eloquent Builder
$signal = Order::query() ->with('customer') ->where('status', 'pending') ->toSignal(); $signal->getModelClass(); // "App\Models\Order" $signal->first(); // App\Models\Order { #id: 1, #status: "pending", ... } $signal->pluck('total'); // Illuminate\Support\Collection [120.00, 89.50, 45.00]
Override config per call
$signal = Product::active()->toSignal([ 'polling_interval' => 5000, 'max_rows' => 50, ]); $signal->toArray(); // [ // "data" => [ ... up to 50 products ... ], // "meta" => [ // "count" => 12, // "model_class" => "App\Models\Product", // "polling_interval" => 5000, // <-- overridden // "pagination" => null, // null when not paginated // ] // ]
Using in Livewire
Declare a Signal as a public property — it serializes/hydrates automatically via the built-in Livewire synthesizer:
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(); // Re-runs the original SQL with the same bindings. // No need to rebuild the query from scratch. } public function render() { return view('livewire.order-dashboard'); } }
<div> <button wire:click="refresh">Refresh</button> @foreach ($orders->getData() as $order) <div>{{ $order->id }} — {{ $order->status }}</div> @endforeach <p>Total: {{ $orders->count() }}</p> </div>
What Livewire sends over the wire (dehydrated payload):
{
"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 },
"pagination_meta": null
}
For a paginated Signal, pagination_meta carries the full page state:
{
"pagination_meta": {
"total": 87, "per_page": 15, "current_page": 2,
"last_page": 6, "from": 16, "to": 30
}
}
On the next request Livewire hydrates this back into a full Signal — no database hit until you call refresh().
Auto-polling with Livewire
<div wire:poll.5000ms="refresh"> @foreach ($orders->getData() as $order) <div>{{ $order->id }} — {{ $order->status }}</div> @endforeach </div>
Every 5 seconds Livewire calls refresh(), re-executes the query, and re-renders only the changed rows.
Using with Alpine.js
Signal implements JsonSerializable, so you can pass it directly to @js or an API endpoint:
<div x-data="{ signal: @js($orders) }"> <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>
@js($orders) renders:
{
"data": [
{ "id": 1, "status": "pending", "total": "120.00" },
{ "id": 2, "status": "pending", "total": "89.50" },
{ "id": 3, "status": "pending", "total": "45.00" }
],
"meta": {
"count": 3,
"model_class": "App\\Models\\Order",
"polling_interval": 2000
}
}
Use signal.meta.polling_interval to drive a JS polling interval without hard-coding it:
setInterval(() => fetch('/orders').then(r => r.json()).then(d => signal = d), signal.meta.polling_interval);
Pagination
Unlike Livewire's built-in WithPagination (which only works inside render()), Signal pagination works anywhere — including mount().
public function mount(): void { $this->orders = Order::pending() ->orderBy('created_at', 'desc') ->toSignal(['per_page' => 15, 'page' => 1]); } public function nextPage(): void { $this->orders = $this->orders->nextPage(); } public function prevPage(): void { $this->orders = $this->orders->prevPage(); } public function goToPage(int $page): void { $this->orders = $this->orders->goToPage($page); }
@foreach ($orders->getData() as $order) <div>{{ $order->id }} — {{ $order->status }}</div> @endforeach <div> Page {{ $orders->getCurrentPage() }} of {{ $orders->getLastPage() }} · {{ $orders->getTotal() }} total </div> <button wire:click="prevPage" @disabled($orders->getCurrentPage() === 1)>← Prev</button> <button wire:click="nextPage" @disabled($orders->getCurrentPage() === $orders->getLastPage())>Next →</button>
Pagination meta is carried in toArray() and survives the Livewire wire round-trip:
{
"data": [ ... ],
"meta": {
"count": 15,
"polling_interval": 2000,
"pagination": {
"total": 87,
"per_page": 15,
"current_page": 1,
"last_page": 6,
"from": 1,
"to": 15
}
}
}
Pass it to Alpine.js for client-side pagination controls without any extra wiring:
<div x-data="{ signal: @js($orders) }"> <span x-text="`Page ${signal.meta.pagination.current_page} of ${signal.meta.pagination.last_page}`"></span> </div>
Pagination API
| Method | Return type | Description |
|---|---|---|
isPaginated() |
bool |
true when created with per_page |
getTotal() |
int |
Total rows across all pages |
getPerPage() |
int |
Rows per page |
getCurrentPage() |
int |
Current page number |
getLastPage() |
int |
Last page number |
nextPage() |
Signal |
Signal for the next page (clamped at last page) |
prevPage() |
Signal |
Signal for the previous page (clamped at page 1) |
goToPage(int $page) |
Signal |
Signal for an arbitrary page |
Signal API
| Method | Return type | Description |
|---|---|---|
getData() |
Collection |
Full result set for the current page |
getQuery() |
string |
Base SQL with ? placeholders (no LIMIT/OFFSET) |
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 query; re-runs the same page if paginated |
count() |
int |
Row count for the current page |
isEmpty() |
bool |
true when the current page is empty |
first() |
mixed |
First row/model on the current page, or null |
pluck(key, value?) |
Collection |
Delegates to Collection::pluck() |
toArray() |
array |
['data' => [...], 'meta' => [...]] |
toLivewire() |
array |
Full serialized payload for Livewire transport |
Signal::fromLivewire($value) |
Signal |
Reconstructs a Signal from a Livewire payload |
Safety: max_rows
To prevent accidentally serializing large datasets through Livewire's JSON cycle, an OverflowException is thrown when the result count exceeds max_rows:
// Table has 1 500 rows — this throws immediately $signal = Report::query()->toSignal(['max_rows' => 500]); // OverflowException: Signal result set exceeds the configured max_rows limit // of 500. Got 1500 rows.
Scope your query before calling toSignal(), or set max_rows to null to disable the limit entirely:
// Safe — scoped $signal = Report::thisMonth()->toSignal(['max_rows' => 500]); // Unlimited — use with care $signal = Report::query()->toSignal(['max_rows' => null]);
License
MIT — see LICENSE.