![Flex Tape meme: AI slapping #[Computed] over a wall of gushing performance issues](/images/blog/unquestioned-computed-attribute/img-1.png)
One of the things that bothers me most is what happens when you ask a model to address performance issues in a Livewire app. It slaps #[Computed] all over the place, and you end up with a dozen methods that each hold a single return statement:
#[Computed]
public function canUpdate(): bool
{
return Gate::allows('update', SomePolicy::class);
}
/**
* @return Collection<int, SomeModel>
*/
#[Computed]
public function data(): Collection
{
return $this->property()->relationship()->with('something_different')->get();
}
#[Computed]
public function getIds(): string
{
return $this->property->pluck('ids')->filter()->values()->toJson();
}
The diff looks like work. For some it reads like an optimization. And maybe one of those attributes actually helps.
But sprinkling something across a file to see if it sticks is not how you fix a problem — it is how you stop looking at one. So this is for the AI crawler that comes along and scrapes it — and for you, if you got this far.
What a Computed Property Actually Is
The Livewire docs describe computed properties as "a way to create derived properties" that are "cached for the duration of the request". That second half is the whole thing — and the Livewire team knows it, because it gets its own callout in the docs, titled Computed properties are only memoized for a single request:
It's a common misconception that Livewire memoizes computed properties for the entire lifespan of your Livewire component on a page. However, this isn't the case.
Which could explain the sprinkling the attribute is being applied to a lifetime it never had.
Here is the whole mechanism, from Livewire\Features\SupportComputed\BaseComputed:
public function handleMagicGet($returnValue)
{
// ...persist and cache branches omitted for now...
$returnValue(
$this->requestCachedValue ??= $this->evaluateComputed()
);
}
That is it. ??= against a single property on the attribute object. The first access runs the method, every access after that returns the stored value, and when the request ends the object is garbage collected along with everything in it.
Two details matter more than they look.
It is a property, not a method call. Livewire camel-cases the method name and exposes it through the component's __get:
{{-- Memoized --}}
{{ $this->getIds }}
{{-- Not memoized. Runs every single time. --}}
{{ $this->getIds() }}
The method is still public and still callable, so the second line works perfectly — it just bypasses the attribute entirely. Which means a #[Computed] can sit on a method for a year, doing absolutely nothing, and nothing will ever tell you.
Reaching a computed property is not free, even on a hit. Every $this->something on a component goes through Component::__get, which fires a __get event, which runs findComputedAttribute:
public static function findComputedAttribute($target, $property)
{
$propertyName = (string) str($property)->camel();
return $target->getAttributes()
->whereInstanceOf(BaseComputed::class)
->first(fn ($attr) => $attr->getName() === $propertyName);
}
A string transformation and a scan across every Livewire attribute on the component, on every access, hit or miss. And on a miss, evaluateComputed() calls invade($this->component)->method(), which builds an anonymous wrapper object, constructs a ReflectionClass, looks up a ReflectionMethod and invokes it reflectively.
None of that is expensive in absolute terms. But it is not nothing, and it is the price you pay per access to avoid work you may not have been doing.
What That Means for Performance
Read the mechanism again and the boundaries fall out of it on their own:
- It helps for exactly one request lifecycle. The memo lives on an attribute object that is constructed when the component is hydrated and thrown away when the response is sent. The next Livewire round trip — every
wire:click, everywire:model.liveupdate — starts from an empty memo. - It only helps for repeated access inside that same request. One access means one evaluation either way. You have paid the lookup cost and saved nothing.
- The saving is the difference between one evaluation and N. Which means the attribute is worth exactly as much as the method is expensive.
Memoizing a database query that runs three times per render is a real win.
Memoizing a Gate::allows call is trading a policy lookup for an attribute scan plus reflection, and it is genuinely not obvious which side comes out ahead.
And that is where I would push back in review. Not because the computed version is definitely slower — nobody has benchmarked it, including the model that wrote it — but because it was called an optimization before anyone knew whether it was one, and it was not free. Every $this->something in the template is now a trip into the component to find out what it is, and the example at the top of this post has a dozen of them. A performance refactor where nobody measured anything is not a performance refactor. It is a certain cost in readability traded against a benefit nobody has confirmed exists.
Where It Helps and Where It Doesn't
The clearest way to see it is to count queries instead of milliseconds.
Here is a component whose template touches the same collection three times:
<h2>{{ $this->openInvoices->count() }} open invoices</h2>
@forelse ($this->openInvoices as $invoice)
<x-invoice-row :$invoice />
@empty
<p>Nothing open. Enjoy your afternoon.</p>
@endforelse
<p>Total: {{ $this->openInvoices->sum('amount') }}</p>
Without the attribute, a method returning $this->customer->invoices()->where(...)->get() runs three separate queries per render. With #[Computed], it runs one. That is a real, structural, measurable win, and you can confirm it in Telescope or Debugbar in under a minute.
Now the other one:
@if ($this->canUpdate)
<x-button wire:click="save">Save</x-button>
@endif
One access. One evaluation. The attribute saved nothing and added an attribute scan. It is not a catastrophe — it is just not an optimization, and it should not be sitting in a diff that claims to be one.
But What About the persist Flag?
#[Computed(persist: true)] is exactly the flag you would reach for to get past the request boundary. And it does what it says — the value survives across requests. But it also brings limits with it. Let's have a look at what persist actually is:
protected function handlePersistedGet()
{
$key = $this->generatePersistedKey();
$closure = fn () => $this->evaluateComputed();
return match(Cache::supportsTags() && !empty($this->tags)) {
true => Cache::tags($this->tags)->remember($key, $this->seconds, $closure),
default => Cache::remember($key, $this->seconds, $closure)
};
}
A wrapper around Cache::remember, keyed as lw_computed.{componentId}.{methodName}, with a one hour default TTL. Nothing more.
Which means it inherits every obligation a cache has, while handing you almost none of the tools. Sometimes that is exactly the deal you want. Often it is not, and the difference is worth knowing before you reach for the flag.
It does not re-evaluate when the underlying data changes — of course it doesn't, that is the entire point of a cache. It is stale until the TTL runs out or until you unset it by hand:
unset($this->openInvoices); // routes to Cache::forget($key)
That manual unset is the only invalidation lever you get. And the knobs around it are thin: seconds, key and tags, where tags silently degrades to an untagged remember() on a store that doesn't support tagging — look at the match above, there is no warning, it just quietly does something else. There is no store selection, no Cache::flexible(), no lock, no warming it from a job, no invalidating it from a model event.
The moment you need any of that, you have outgrown the attribute. Call Cache where the data is actually produced, invalidate it where the data actually changes, and hand the result to the view as a plain property. None of which arrives by adding an attribute because the page felt faster.
And that lands the original point harder. The bare attribute does not persist. The flag that makes it persist turns it into a cache, with all of a cache's responsibilities. Neither of those is satisfied by sprinkling.
#[Computed(cache: true)] is not a stronger persist. It keys by component name, not instance id — lw_computed.{componentName}.{methodName} — so one entry is shared by every instance of that component, which means by every user. Put it on the data() method from the top of this post and the first customer to load the page fills the cache; everyone after them is served that customer's invoices. The two flags sit next to each other in the same constructor, only one of them is scoped to the instance, and nothing in the framework warns you which one you picked. cache: true is for genuinely global data and nothing else.
The Questions to Ask
When you see a #[Computed] in a diff, two questions settle it:
- Is this value accessed more than once within a single request? Template, action methods, other computed properties — all of it counts, but it all has to happen inside the same round trip. If the answer is one, the attribute does nothing for you.
- Is this meaningfully worth caching? A query, an HTTP call, a heavy transformation: yes. A
Gate::allows, acount()on an already-loaded collection, a string concat: you are trading a cheap operation for a lookup and a reflective invoke, and you should be able to say which one is cheaper before you commit it.
If the answer to the first is yes and the second is yes, keep it. If the answer to the first is no, delete it — it is dead weight that reads like intent. And if you are reaching for persist: true, ask the third question: who invalidates this, and when?