Livewire 4 #[Computed(persist: true)] — Cache Heavy Queries Across Requests

Livewire 4 #[Computed(persist: true)] caches a method result in Laravel's cache for one hour, so heavy queries survive hydration. Tune, bust, pair with islands.

Steven Richardson
Steven Richardson
· 7 min read

Dashboards with three or four expensive widgets feel snappy on the first paint and then crawl. Every input event hydrates the Livewire component, and every hydration re-runs the query that built the widget. #[Computed] solves the per-request side. #[Computed(persist: true)] solves the harder one — surviving across requests without writing your own Cache::remember().

Per-request memoisation with #[Computed]#

Drop the #[Computed] attribute on a method and Livewire memoises the return value for the rest of the current request. Access it from anywhere in the component (or from Blade as $this->property) and you only pay the database hit once per hydration.

<?php

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

new class extends Component {
    #[Computed]
    public function monthlyRevenue(): int
    {
        return Order::whereMonth('created_at', now())->sum('total');
    }
};
<flux:heading>Revenue this month: ${{ number_format($this->monthlyRevenue / 100, 2) }}</flux:heading>

That is already a win over a public $monthlyRevenue populated in mount() — Livewire would otherwise serialise the value, rehydrate it on every request, and force you to refresh it manually when orders changed. But the memoisation only lasts for the lifetime of the current request. Hit the component again — a button press, a wire:poll, a model update — and the query runs again.

Developers coming from Livewire 3 trip up here. The Livewire 4 Form Objects with #[Validate] pattern sidesteps public-property hydration entirely for writes, but computed properties give you the same win for read-only values without restructuring the whole component.

Adding persist: true to cache across requests#

persist: true writes the return value to Laravel's cache after the first execution. Every later request — same component instance, same browser tab — pulls from cache instead of running the query.

use App\Models\Order;
use Livewire\Attributes\Computed;

#[Computed(persist: true)]
public function monthlyRevenue(): int
{
    return Order::whereMonth('created_at', now())->sum('total');
}

Default TTL is 3600 seconds (one hour). The cache key embeds $this->getId() — Livewire's unique component-instance ID — so two browser tabs each get their own cached copy, and the same dashboard rendered for a different user lives under its own key. That distinction matters: persist is per-instance, not per-application. If you want every component on every page to share one cached value, switch to #[Computed(cache: true)] — same mechanism, but a stable key derived from the method name (or key: 'homepage-revenue' if you want to control it).

Tuning the TTL with the seconds option#

One hour is too long for queue stats and too short for a list of static product categories. Pass seconds to override:

#[Computed(persist: true, seconds: 300)] // 5 minutes
public function queueDepth(): int
{
    return DB::table('jobs')->count();
}

#[Computed(persist: true, seconds: 86400)] // 24 hours
public function productCategories(): Collection
{
    return Category::orderBy('name')->get();
}

Pick the TTL based on staleness tolerance, not on how expensive the query is. A 50ms query that surfaces the wrong number to admins is worse than a 2-second query that's always fresh.

Pairing computed properties with islands#

Livewire 4 Islands only re-render the wrapped fragment when one of their inputs changes. Combined with computed properties, this means a button press in one island doesn't trigger every computed property in the parent component — only the ones that island actually reads.

<div>
    @island
        <flux:card>
            Revenue this month: ${{ number_format($this->monthlyRevenue / 100, 2) }}
        </flux:card>
    @endisland

    @island
        <flux:card>
            Queue depth: {{ $this->queueDepth }}
            <flux:button wire:click="$refresh">Refresh</flux:button>
        </flux:card>
    @endisland
</div>

Clicking refresh on the queue island sends an update, but only the queueDepth computed runs — the cached monthlyRevenue is never accessed during that island's render. If you have already wired up skeleton placeholders with @placeholder, the experience is what people expect from a real dashboard: granular refreshes with no full-component reload.

Busting the cache manually#

When the underlying data changes, you need to clear the cached value. PHP's unset() does it:

public function refundOrder(Order $order): void
{
    $order->refund();

    unset($this->monthlyRevenue);
}

unset() clears both the in-request memo and the persisted cache entry. That's the part the docs bury in a callout — it's easy to read the persist section and assume you need Cache::forget() with the right key. You don't. unset($this->property) is the single supported API for busting the cache.

If you need to bust from outside the component (a queued job updating revenue overnight, an admin command), reach for an event:

// In the job
event(new RevenueUpdated());

// In the component
use Livewire\Attributes\On;

#[On('revenue-updated')]
public function clearRevenueCache(): void
{
    unset($this->monthlyRevenue);
}

When NOT to persist a computed property#

Persist is wrong for any value that needs to be fresh on every request: form-state derivations, per-action results, anything keyed off auth()->user() that might rotate within the hour. Persist is also wrong when the cached value carries data you would not want stored in the configured cache driver — a file or database cache containing PII on a shared host is not a great look. Stick with plain #[Computed] (or no computed at all) for those.

Gotchas and Edge Cases#

A few things that bite in production:

  • Computed properties are not supported on Livewire\Form objects. Accessing them via $form->property throws. Keep computed properties on the component itself.
  • The cache key includes $this->getId(), not the user ID. If your component is bound to user data, that is already implicit — but if you reuse the same component across users via a shared cached value, switch to #[Computed(cache: true, key: ...)] carefully.
  • Persist uses Laravel's default cache driver. If that is array (the default in phpunit.xml), tests will not see persistence at all. Either swap the driver to redis in the test that needs it, or assert against the memo behaviour with a single hydration cycle.
  • Do not persist per-user secrets under a shared cache key. The default per-instance key handles authenticated dashboards fine; do not paper over it with cache: true and a static key when the value is sensitive.
  • wire:poll plus a long TTL looks broken. If you are polling every five seconds but persisting for an hour, you will watch the UI tick while the number never moves. Shorten seconds to match your poll interval, or drop persist and let request-scoped memoisation handle it.

Wrapping Up#

#[Computed(persist: true)] is the smallest change with the biggest impact for any Livewire dashboard hauling expensive queries. Add the attribute, pick a seconds value that matches your staleness budget, and remember unset() is the one true cache-busting API. From here, the natural next step is shifting heavy work off the request thread entirely — start with Livewire 4 islands for granular re-renders, then read up on scaling Laravel queues in production once your widgets are pulling from queued aggregations rather than live counts. For table-heavy dashboards, Flux UI server-driven tables pair beautifully with persisted computed counts in the header.

FAQ#

What does the persist option do on a Livewire computed property?

The persist: true argument tells Livewire to write the method's return value into Laravel's cache after the first run. Every subsequent request for the same component instance reads from cache instead of executing the method again. Without it, #[Computed] only memoises within a single request — Livewire re-runs the method on every hydration.

How long does a Livewire persisted computed value stay cached?

By default, persisted computed values live in cache for 3600 seconds — one hour. You can override the TTL by passing a seconds argument: #[Computed(persist: true, seconds: 300)] caches the value for five minutes instead. The TTL is per-component-instance, so reloading the page reuses the cached value until it expires.

How do I invalidate a Livewire computed property cache?

Call PHP's unset() on the property: unset($this->monthlyRevenue). This clears both the in-request memoisation and the persisted cache entry in one call. You do not need to call Cache::forget() manually — Livewire handles it. Trigger unset() whenever an action mutates the underlying data, or react to an event from outside the component using #[On('event-name')].

When should I use a computed property instead of a public property in Livewire?

Use a computed property for derived or read-only values, especially when the source is a database query or an expensive calculation. Public properties get hydrated on every request, so storing a heavy query result there wastes memory and risks staleness. Computed properties run lazily, memoise per request, and — with persist: true — survive across requests.

Can computed properties work with Livewire islands?

Yes, and the pairing is the point. Each island only triggers the computed properties it actually reads during its own render. Refreshing one island does not re-run every computed in the parent component, so a dashboard with five widgets only pays for the widget the user interacted with — even before persist enters the picture.

Does Livewire memoise computed properties across multiple requests?

Not by default. Plain #[Computed] only memoises within a single request — Livewire re-runs the method on every hydration. To cache across requests, add persist: true (per-component-instance) or cache: true (shared across every component instance in the app). Both write to Laravel's cache; pick the scope that matches the data.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.