sql-to-signal v1.0 · Livewire 3 & 4

One call.
Reactive data.

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.

OrderDashboard.php
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
    }
}
PHP 8.2+ Laravel 11 / 12 / 13 Livewire 3 / 4 Alpine.js

Why not just clone $query?

The clone pattern silently breaks inside Livewire. Here's exactly why.

Without toSignal() — clone pattern
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

With toSignal()
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

01

Livewire serialization

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.

Problem
// 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
With toSignal()
// Signal implements Wireable.
// Livewire dehydrates/hydrates it
// automatically between every request.
public Signal $orders; // ✓ just works
02

Multiple database hits for the same filter

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.

3 queries for the same filter
$q = $this->baseQuery();
$this->orders = $q->get()->toArray(); // query 1
$this->count  = (clone $q)->count();  // query 2
$this->first  = (clone $q)->first();  // query 3
1 query, everything derived
$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
03

Refreshing data in Livewire

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.

Rebuild query every time
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();
}
One line, same SQL
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">
04

Wiring data to Alpine.js

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.

Manual wiring — multiple variables
// 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 }}
}">
One object, everything included
// 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>
05

Model hydration after refresh

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.

Raw stdClass after toArray()
// 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
Full Eloquent model on refresh
// After Signal::refresh():
$order = $this->orders->first(); // App\Models\Order
$order->formattedTotal();  // ✓ method works
$order->customer->name;    // ✓ eager-loaded
$order->status_label;      // ✓ accessor works
06

Polling interval out of sync

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.

Magic number in JS
// 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.
Config-driven, zero extra wiring
// 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,
]);

Everything included

Built for the TALL stack. Zero config required.

Eloquent & Query Builder

Works on both Model::query() and DB::table(). Eloquent models stay fully hydrated through the Livewire cycle.

Livewire 3 & 4 Synthesizer

Declare public Signal $orders and it serializes/hydrates automatically. No casting, no JSON property, no extra work.

Alpine.js Ready

Pass the whole signal to Alpine with @js($signal). Access signal.data and signal.meta — polling interval included.

max_rows Safety Guard

Throws OverflowException if results exceed the configured limit — prevents accidentally serializing huge datasets through Livewire's wire cycle.

Per-call Config Override

Pass →toSignal([...]) to override polling interval, max_rows, or cache TTL for a single call — without touching global config.

One-line Refresh

Call $signal→refresh() to re-run the original SQL with the same bindings. Works perfectly with wire:poll.

Installation

Up and running in under a minute.

1 Install via Composer
composer require laravelldone/sql-to-signal
2 Publish config (optional)
php artisan vendor:publish --tag="sql-to-signal-config"
3 config/sql-to-signal.php
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
];

Documentation

Pick your integration path.

Livewire Component
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');
    }
}
Blade template — with auto-polling
<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>
Livewire wire payload (dehydrated)
{
    "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 }
}
Controller / Component
$signal = DB::table('orders')->where(...)->toSignal();
return view('dashboard', compact('signal'));
Blade + Alpine.js
<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>
What @js($signal) renders
{
    "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
    }
}
JS polling driven by meta
setInterval(
    () => fetch('/orders').then(r => r.json()).then(d => signal = d),
    signal.meta.polling_interval // config-driven, no hard-coding
);
Eloquent Builder
$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]
Query Builder
$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 { ... }
Per-call config override
$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.

max_rows guard
// 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]);

Signal API Reference

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

Changelog

Release history for laravelldone/sql-to-signal.

v1.1.1 Pagination Docs & API polish latest
view tag ↗
  • ~ Updated README with full pagination documentation and v1.1.0 API reference changes
v1.1.0 Livewire-compatible Pagination feature
view tag ↗
  • + toSignal(['per_page' => 15, 'page' => 1]) paginates at query time
  • + Signal carries full pagination meta: total, per_page, current_page, last_page, from, to
  • + New methods: isPaginated() getTotal() getPerPage() getCurrentPage() getLastPage() nextPage() prevPage() goToPage(int)
  • + refresh() on a paginated Signal re-runs the same page
  • + Eloquent models hydrated correctly on paginated queries
  • + Pagination meta survives full Livewire toLivewire/fromLivewire round-trip
  • ~ 37 tests · PHPStan level 8 · zero errors
v1.0.2 Security Patch security
view tag ↗
  • ! Arbitrary class instantiation fixmodel_class is now validated as a genuine Eloquent Model subclass before instantiation, preventing PHP object gadget attacks via tampered Livewire payloads
  • ! Arbitrary connection switching fixconnection_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.

v1.0.1 Documentation Update docs
view tag ↗
  • ~ Added detailed vs clone pattern comparison section to README with side-by-side examples
v1.0.0 Initial Release
view tag ↗
  • + →toSignal() macro on Query\Builder and Eloquent\Builder
  • + Livewire 3 & 4 synthesizer — Signal serializes/hydrates as a public property
  • + JsonSerializable — pass directly to @js() for Alpine.js
  • + max_rows overflow guard, per-call config override
  • + PHP 8.2+ · Laravel 11 / 12 / 13 · Livewire 3 / 4
upcoming Planned
  • · Filament v3 table integration — Signal as a table data source
  • · Built-in cache layer via cache.enabled config

Last synced with GitHub API at page load · view all tags ↗

Coming soon

More packages, same philosophy

All laravelldone packages target the TALL stack. Zero magic, zero boilerplate.

laravelldone/filament-signal

Filament Signal Table

Use a Signal as a data source directly in Filament v3 tables. Auto-polling, no custom query needed.

In Development

laravelldone/???

More coming...

Watch the GitHub org for new TALL stack packages focused on eliminating boilerplate.

Planned

laravelldone/???

Stay tuned

Follow on GitHub to be notified when new packages drop.

github.com/neon2027