# wirestan

> A PHPStan rule for Livewire 3 and 4 that flags public properties seeded in mount() and never changed after, until they are marked #[Locked].

Source: https://boring-observability.dev/open-source/wirestan
Install: `composer require --dev boring-o11y/wirestan`
Requires: PHP 8.2+, PHPStan 2, Livewire 3 or 4
Code and full documentation: https://github.com/boring-o11y/wirestan
License: MIT

---

Every public property on a Livewire component can be set from the browser. Anyone with the devtools open can run `$wire.set('bookingId', 42)`, and Livewire will hydrate the new value on the next request.

For a property bound to an input with `wire:model`, that is the point. For `$bookingId`, `$tenantId` or `$role`, seeded in `mount()` and trusted from then on, it is an insecure direct object reference. Livewire's fix is the `#[Locked]` attribute. Forgetting it fails silently: nothing throws, the component works, and the hole stays open until somebody goes looking.

## The rule

wirestan adds a PHPStan rule that flags a public property on a Livewire component when nothing but the lifecycle methods ever assigns it and it is not marked `#[Locked]`.

```php
class ShowBooking extends Component
{
    public int $bookingId = 0;   // flagged: only mount() sets it

    public string $note = '';    // fine: saveNote() changes it

    public function mount(int $bookingId): void
    {
        $this->bookingId = $bookingId;
    }

    public function saveNote(string $note): void
    {
        $this->note = $note;
    }
}
```

The fix is the one attribute, `#[Locked]`, on `$bookingId`. With `phpstan/extension-installer` the rule registers itself; otherwise it is one line in `phpstan.neon`. It runs alongside Larastan.

## Built to stay quiet when it cannot be sure

Writes in `mount()`, `boot()`, `hydrate()` and the other seed methods do not count as changes, because the server makes them. Any other write does, including `+=`, array appends and `$this->reset('name')`.

Livewire Form objects are skipped, because locking one breaks every nested `wire:model`. So are static and readonly properties and Livewire's reserved ones, like `$listeners` and `$rules`. When a component uses `$this->fill()` or a dynamic property write, which could change any property, the rule skips the whole component rather than guess.

## On a codebase that predates it

Expect it to find things. Generate a PHPStan baseline so CI only fails on new violations, then go through the baselined entries one at a time: each is either a property that needs `#[Locked]` or an input you can confirm is meant to be editable.

