skip to content

A Livewire stock-level widget keeps an Eloquent collection of products in a public property; what does that cost on every update, and how would you restructure it?

level: seniorimportance: should knowfreq 30%

answer

  1. snapshot carries class plus keys
  2. re-queried by key, not by your query
  3. select() and with() are lost
  4. #[Computed] keeps the real query
  5. Laravel 13 serializable_classes is false

basics

~20 s

A public Eloquent collection is dehydrated to class and keys, then reloaded by key on later requests without its select(), eager loads or global scopes. Keep scalar filters public, move the list to #[Computed], and persist only arrays on Laravel 13.

solid answer

~50 s

Livewire sends a public Eloquent collection as its class plus the list of primary keys, and on a later request rebuilds it with one `whereKey($keys)` query that ignores your `select()`, `with()` eager loads and global scopes. On PHP 8.3 that query runs on every hydrate; on PHP 8.4+ Livewire 4 hydrates a lazy proxy, so it runs when the request first touches the property. Relations then lazy-load per row, the key list grows the payload, and the list stays pinned to the keys chosen at mount. Restructure: keep `warehouseId`, `threshold` and `search` as public scalars, move the list into a `#[Computed]` method that runs the real query once per request and only when rendered, and `unset()` it after writes. For expensive aggregates, `#[Computed(persist: true, seconds: 30)]` returning arrays; new Laravel 13 apps set `cache.serializable_classes` to `false`, so a cached model or collection cannot be restored and is recomputed.

code

php · 24 lines
php
<?php

use App\Models\Product;
use Livewire\Attributes\Computed;
use Livewire\Component;

new class extends Component {
    public int $warehouseId;
    public int $threshold = 10;

    #[Computed(persist: true, seconds: 30)]
    public function totals(): array
    {
        return Product::where('warehouse_id', $this->warehouseId)
            ->where('quantity', '<', $this->threshold)
            ->selectRaw('category, count(*) as n')
            ->groupBy('category')
            ->pluck('n', 'category')
            ->all();
    }
};
?>

<div>{{ array_sum($this->totals) }} low-stock items</div>

go deeper

for a junior

Know that public model collections are reloaded from the database on later requests, and that computed properties are the usual home for lists.

for a middle

Explain what the snapshot holds for an Eloquent collection and why select(), with() and scopes are lost when it is rebuilt by key.

for a senior

Diagnose the reload from query logs and the payload, restructure state into scalar inputs plus computed lists, and avoid the Laravel 13 cache trap.

for a principal

Weigh the snapshot model's database and payload cost for data-heavy dashboards against caching, and set conventions for what may live in public state.

## The setup A warehouse dashboard shows a **stock-level widget**. A first version loads the low-stock products in `mount()` and keeps them in a public property: ```php public $products; public function mount(int $warehouseId): void { $this->products = Product::select('id', 'sku', 'quantity') ->with('supplier') ->where('warehouse_id', $warehouseId) ->where('quantity', '<', 10) ->get(); } ``` It renders fine on page load. Then the team notices slow updates, extra queries and odd data after each click. ## What actually travels in the snapshot Livewire keeps no component object on the server between requests. Each response **dehydrates** public properties into a JSON snapshot; each later request **hydrates** a new instance from it. For an `Illuminate\Database\Eloquent\Collection`, the snapshot holds only: - the collection class and the model class (or its morph alias), and - the list of **primary keys**. On hydration Livewire rebuilds the collection with a single query by key (Eloquent's `newQueryForRestoration()`, i.e. `whereKey($keys)` without global scopes), reusing any model already resolved in that request. ## The costs, one by one | Symptom | Cause | |---|---| | A `whereKey` query on later requests | The collection is reloaded by key instead of reused | | Columns you excluded come back | Your `select()` is not replayed; the reload is a plain query by key | | One `supplier` query per row in the view | `with('supplier')` is not replayed, so relations lazy-load | | Rows that should now appear never do | The list is pinned to the keys chosen at mount | | A larger payload and a model class name in the HTML | Every key and the class (unless morph-mapped) is in the snapshot | | Soft-deleted or scoped-out rows reappearing | The reload skips global scopes | **Version detail**: on **PHP 8.4 and later**, Livewire 4 hydrates model and collection properties as **lazy proxies** (PHP's native lazy objects), so the reload runs only if the request touches the property. On **PHP 8.3**, the minimum for Laravel 13, it runs on every hydrate. The lazy proxy saves the query on requests that never read the list, but it does not bring back the lost constraints. ## The restructured widget 1. **Keep inputs, not results, in public state**: `public int $warehouseId`, `public int $threshold = 10`, `public string $search = ''`. They are small, round-trip exactly and describe what to show. 2. **Derive the list with `#[Computed]`**: the method runs your full query, `select`, `with` and scopes included, at most once per request, and not at all if the template does not read it. 3. **Bust after writes**: an action that restocks a product calls `unset($this->lowStock)` so the same request re-renders fresh data. 4. **Cache only what is expensive and safe**: for a costly aggregate, such as totals per category refreshed by the user every few seconds, use `#[Computed(persist: true, seconds: 30)]` and return an **array** of scalars. ```php #[Computed] public function lowStock() { return Product::select('id', 'sku', 'quantity', 'supplier_id') ->with('supplier:id,name') ->where('warehouse_id', $this->warehouseId) ->where('quantity', '<', $this->threshold) ->when($this->search, fn ($q) => $q->where('sku', 'like', "%{$this->search}%")) ->get(); } ``` ## The Laravel 13 caching trap Persisted and cached computed values are stored with Laravel's cache, which serializes PHP values. New **Laravel 13** applications ship `config/cache.php` with `'serializable_classes' => false`, so objects are not unserialized from the cache. When a persisted computed property returns a model or collection, Laravel restores an incomplete object; Livewire detects it, **re-evaluates the method** so the value is still correct, and in debug mode logs a warning. The result looks correct but the cache never helps. Return arrays or scalars, or list every class in the object graph in `cache.serializable_classes`. ## How to confirm the diagnosis - Watch the query log per Livewire request: a `where "id" in (...)` query you never wrote is the collection reload. - Inspect the snapshot in the page source or the network tab: a long key list under the property. - Count relation queries in the view: one per row means the eager load was lost. Security aspects of models in public properties, such as locking and authorizing them, are a separate concern handled with dedicated features; this refactor is about cost and correctness.

  • Does PHP 8.4's lazy-proxy hydration in Livewire 4 make a public Eloquent collection property safe to use?
    It removes the reload on requests that never read the property, but when the list is read the reload still runs by key, without your `select()`, eager loads or global scopes, and the key list still sits in the payload. It saves a query on some requests; it does not fix what the collection loses on each round trip.
  • In Livewire, when is a public Eloquent model property still a reasonable choice?
    For a single record the component is about, such as the `Warehouse` a page is bound to. One query by key is cheap, the model is identified by its key, and you usually want fresh data anyway. Lists that depend on filters are what belong in computed properties.
  • A persisted computed property returning a collection logs a Livewire warning in debug mode on Laravel 13; what is going on?
    Laravel 13's `cache.serializable_classes` is `false` in new apps, so the cached collection comes back as an incomplete object. Livewire notices, re-runs the method to keep the value correct and logs that it was not served from cache. Return an array or allow the classes explicitly.

saying these in an interview costs you the question

  • Livewire keeps the loaded collection in server memory between requests
  • The snapshot stores every model attribute, so later requests run no query
  • Eager loads and select() constraints are replayed when the collection is restored
  • #[Computed] results are stored in the snapshot like public properties
  • A persisted computed collection on Laravel 13 is always served from cache