Every public property on a Livewire component makes a round trip: Livewire sends it to the browser and accepts whatever comes back on the next request. That's fine for a search box. It's a problem for an order ID, an owner reference, or anything a user should never be able to change. A Livewire locked property closes that gap — #[Locked] tells Livewire to reject any client-side change to a property and throw if someone tries.
How a public property gets to the client and back#
A public property isn't state that lives safely on your server. Livewire serialises it into the HTML payload, ships it to the browser, and rehydrates the component from the incoming request on the next action. You already lean on that round-trip every time you use something like the #[Url] attribute to sync a property to the query string — the value lives in the browser and comes back on the next request.
The catch is that "comes back" means the client controls it. Open DevTools, edit the serialised payload, and the property you set in mount() arrives as something else entirely.
<?php
namespace App\Livewire;
use App\Models\Order;
use Livewire\Component;
class OrderNotes extends Component
{
public int $orderId; // Public, so it survives between requests — and is client-editable.
public string $note = '';
public function mount(int $orderId): void
{
$this->orderId = $orderId;
}
public function save(): void
{
// Whatever the client last sent as $orderId is what we write to.
Order::findOrFail($this->orderId)
->notes()
->create(['body' => $this->note]);
$this->note = '';
}
public function render()
{
return view('livewire.order-notes');
}
}
Nothing renders $orderId as an input. It doesn't matter. A user can still patch the payload, change 12 to 13, and attach their note to an order that isn't theirs.
Locking a property with #[Locked]#
Add the attribute. That's the whole fix.
<?php
namespace App\Livewire;
use App\Models\Order;
use Livewire\Attributes\Locked;
use Livewire\Component;
class OrderNotes extends Component
{
#[Locked]
public int $orderId;
public string $note = '';
public function mount(int $orderId): void
{
// Set once, on the server, from a trusted source.
$this->orderId = $orderId;
}
public function save(): void
{
$order = Order::findOrFail($this->orderId);
// Locking the ID is not authorization — still check the user can act.
$this->authorize('update', $order);
$order->notes()->create(['body' => $this->note]);
$this->note = '';
}
public function render()
{
return view('livewire.order-notes');
}
}
Now if a tampered request tries to change $orderId, Livewire throws Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException before save() ever runs. The request dies at hydration. Your method only executes with the value you set on the server.
The attribute works the same whether you write class-based components, single-file components, or Volt — it's the property that's protected, not the file style.
What a locked property protects and what it doesn't#
#[Locked] protects exactly one thing: the property's value across the client round-trip. It stops the browser from mutating it. That's the boundary — and it's worth being precise about, because it's easy to over-trust.
It does not sanitise or validate. It does not authorize. And it does nothing about values you assign. Anything running in the browser can still try to change a public property before it's sent back — that includes Alpine, which reads and writes your properties straight through $wire — but #[Locked] rejects those changes at the server.
Here's the trap. #[Locked] guards hydration, not your own code:
// ❌ The lock is pointless if you feed it request input yourself.
#[Locked]
public int $accountId;
public function mount(): void
{
// Reading straight from the request hands the client the keys.
$this->accountId = (int) request('account_id');
}
The lock stops the client from changing $accountId between requests. It can't stop you from assigning an attacker-controlled value to it in the first place. Derive locked values from trusted server state:
#[Locked]
public int $accountId;
public function mount(): void
{
// Trusted source — the authenticated user, not the request body.
$this->accountId = auth()->user()->currentAccount->id;
}
Models, IDs, and when you don't need #[Locked]#
If you store a whole Eloquent model in a public property, you don't need the attribute at all. Livewire persists only the model's key between requests and re-resolves it, verifying the key wasn't swapped:
<?php
namespace App\Livewire;
use App\Models\Order;
use Livewire\Component;
class OrderSummary extends Component
{
// No #[Locked] needed — Livewire signs the model's key
// and rejects any request that swaps the ID.
public Order $order;
public function mount(Order $order): void
{
$this->order = $order;
}
public function render()
{
return view('livewire.order-summary');
}
}
Reach for #[Locked] when you're holding a raw scalar that stands in for identity or ownership — an int $orderId, a string $token, a $tenantId. If you're passing a record ID between steps, say in a multi-step wizard that keeps its state in public properties, that ID is precisely the kind of value worth locking.
Gotchas and edge cases#
A few things that catch people out:
A locked property with no default and no value set in mount() still needs to be initialised before it hydrates. Type it (public int $orderId) and set it once in mount(); don't leave it dangling and assign it later from an action.
#[Locked] is not a substitute for $this->authorize(). Locking an ID guarantees the ID you receive is the one you set — it says nothing about whether this user is allowed to act on that record. Always authorize in the action.
Validation is a separate concern too. #[Locked] covers values the user shouldn't touch; for values they should submit, validate them. Moving rules into a Form Object with #[Validate] keeps that logic out of the component and next to the data it guards.
Finally, don't lock a property you actually bind with wire:model. Locking a field the user is supposed to edit will throw the moment they type. #[Locked] is for state the frontend reads but must never write.
Wrapping up#
Public Livewire properties are client-editable by default, whether or not you render them. Add #[Locked] to the scalars that carry identity or ownership, keep whole models in typed properties where Livewire already protects the key, and never assign request input to a locked property yourself.
Locking is one layer, not the whole wall. Authorize every action server-side, validate anything the user submits, and let Laravel's origin-aware CSRF protection guard the request itself. Do all three and a tampered payload has nowhere to go.
FAQ#
What does the #[Locked] attribute do in Livewire?
#[Locked] tells Livewire to reject any client-side change to a public property. The property still round-trips to the browser and persists between requests, but if an incoming request tries to modify it, Livewire throws a CannotUpdateLockedPropertyException and the action never runs. It's built for values like model IDs that the frontend reads but must never write.
When should I lock a Livewire property?
Lock any public property that acts as a security boundary and should never change from the frontend — a record ID, an owner or tenant reference, a token you set on the server. If a property is meant to be edited by the user through wire:model, don't lock it; locking a bound field will throw the moment they type. The rule of thumb: lock what the browser reads but should never write.
Can a locked property still be changed in PHP?
Yes. #[Locked] only blocks changes coming from the client. Your own server-side code can assign to the property freely, in mount() or in any action. That's also the catch — if you assign untrusted request input to a locked property yourself, the lock does nothing to protect you. Always derive locked values from trusted server state.
Do I need #[Locked] on an Eloquent model property?
No. When you type-hint an Eloquent model on a public property, Livewire persists only the model's key and re-resolves it on each request, verifying the key wasn't tampered with. That protection is automatic, so a public Order $order is already safe. You only need #[Locked] for raw scalars like an int $orderId that stand in for a model without being one.
Is #[Locked] enough to secure my Livewire component?
No, and treating it as a complete solution is the common mistake. #[Locked] guarantees a property's value survives the round-trip untampered — it doesn't authorize the action or validate input. Always call $this->authorize() in your actions, validate anything the user submits, and rely on Laravel's request-forgery protection at the HTTP layer. Locking is one layer in that stack, not a replacement for it.