Boring Observability GitHub

Fail CI when a Livewire property the browser can set should have been #[Locked]

PHPStan rules for Livewire. A public property the browser can set but the server trusts, like $tenantId seeded in mount(), fails CI until it is marked #[Locked].

$ composer require --dev boring-o11y/wirestan

boring-o11y/wirestan · MIT · PHP 8.2+, PHPStan 2, Livewire 3 or 4

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].

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.

Every configuration key, and how to run the test suite, is in the README on GitHub. Bugs and questions go in its issues.

Our other packages

  • Horizon Delayed Jobs

    A Retries page for the Horizon dashboard. It lists the jobs waiting out a backoff or a delay, which Horizon does not show, with a button to run one now.

  • httptheus

    Prometheus metrics for your application's outbound HTTP, with a Grafana dashboard to read them. Duration, host, endpoint and outcome of every transfer, and no bodies, headers or database.

  • Unique Job Middleware

    Checks a ShouldBeUnique job's lock again when a worker picks it up, and skips the job if another one holds it. Fires an event for each skip, so you can count them.