boring-o11y / wirestan
PHPStan rules for Livewire components: catches client-tamperable state, missing authorization, cached data leaking across users, and validation that never runs.
Package info
github.com/boring-o11y/wirestan
Type:phpstan-extension
pkg:composer/boring-o11y/wirestan
Requires
- php: ^8.4
- nikic/php-parser: ^4.18 || ^5.0
Requires (Dev)
- phpstan/phpstan: ^2.3
- phpunit/phpunit: ^10.0 || ^11.0 || ^12.0
Suggests
- larastan/larastan: Recommended host extension; these rules are designed to run alongside it
- phpstan/extension-installer: Auto-registers extension.neon so you don't have to include it by hand
- phpstan/phpstan: Required to use these rules
Provides
None
Conflicts
None
Replaces
None
README
PHPStan / Larastan rules for Livewire components.
Every public property on a Livewire component is part of the client-editable
state: the browser can set any of them with $wire.set('prop', value), and
Livewire will happily hydrate the new value on the next request. That is exactly
what you want for a wire:model-bound input — and exactly what you don't want
for $bookingId, $tenantId, $role, or anything else the server seeded in
mount() and then trusted.
Public methods are just as exposed. The browser can call them with any arguments, and Livewire binds a model parameter from whatever id it sends.
These mistakes fail silently: nothing throws, the component works in the browser, and the IDOR only shows up when someone goes looking for it.
wirestan catches them in CI: state the client can tamper with, actions that never
check authorization, cached data shared between users, and validation that never
runs. It also catches Livewire mistakes that only show up at runtime, like resetting a
property that can't be reset or writing to a #[Reactive] one. See Rules
for the full list.
Requirements
- PHP
^8.4 - PHPStan
^2.0(works great alongside Larastan) - Livewire 3 or 4 in the analysed project
Installation
composer require --dev boring-o11y/wirestan
Registering the rules
If you use phpstan/extension-installer
(recommended), there is nothing else to do — the rules register
automatically.
Otherwise, include the bundled extension.neon from your phpstan.neon
(or phpstan.neon.dist):
includes: - vendor/boring-o11y/wirestan/extension.neon
That's it — the rules now run as part of your normal analysis:
vendor/bin/phpstan analyse
Analysing single-file components
Livewire 4's make:livewire generates resources/views/components/⚡name.blade.php,
so a paths setting that only lists app never analyses those components. Add the
views directory:
parameters: paths: - app - resources/views
These components are anonymous classes, so messages name them like
Livewire\Component@anonymous/…/⚡booking.blade.php:7.
Rules
| Rule | Identifier | In one line |
|---|---|---|
| LockedPublicPropertyRule | boringO11yWirestan.lockedPublicProperty |
Never-reassigned public properties must be #[Locked] |
| PublicReservedPropertyRule | boringO11yWirestan.publicReservedProperty |
$rules, $listeners and other framework settings must not be public |
| ComputedCacheScopeRule | boringO11yWirestan.computedCacheScope |
Cached #[Computed] values must not depend on the user or on state their cache key ignores |
| ModelParamWithoutAuthorizationRule | boringO11yWirestan.modelParamWithoutAuthorization |
Actions that bind a model from a client-supplied id must authorize it |
| ValidationNeverEnforcedRule | boringO11yWirestan.validationNeverEnforced |
Components with validation rules must call validate() |
| LockedUrlConflictRule | boringO11yWirestan.lockedUrlConflict |
A property can't be both #[Locked] and #[Url] |
| LegacyComputedAccessorRule | boringO11yWirestan.legacyComputedAccessor |
getFooProperty() accessors are client-callable: use #[Computed] |
| AuthorizeTargetRule | boringO11yWirestan.authorizeTarget |
#[Authorize] must target something that resolves |
| ReactiveMutationRule | boringO11yWirestan.reactiveMutation |
#[Reactive] properties can't be written |
| ComputedMisuseRule | boringO11yWirestan.computedMisuse |
#[Computed] methods must be usable as computed properties |
| ResetTargetRule | boringO11yWirestan.resetTarget |
reset() and pull() must not target what they can't restore |
| NonPersistedStateRule | boringO11yWirestan.nonPersistedState |
Non-public state set in mount() is gone on the next request |
| AsyncMutationRule | boringO11yWirestan.asyncMutation |
#[Async] actions must not write public properties |
| ReservedActionNameRule | boringO11yWirestan.reservedActionName |
Actions can't be named after $wire's own methods |
LockedPublicPropertyRule
Reports public properties of Livewire\Component subclasses that are never
reassigned outside the lifecycle seed methods and are missing
#[Livewire\Attributes\Locked].
use Livewire\Component; class ShowBooking extends Component { public int $bookingId = 0; // ← flagged public string $note = ''; // fine: reassigned by saveNote() public function mount(int $bookingId): void { $this->bookingId = $bookingId; } public function saveNote(string $note): void { $this->note = $note; } }
Livewire public property ShowBooking::$bookingId is never reassigned outside
lifecycle methods. It must be marked #[Livewire\Attributes\Locked] — otherwise
the client can mutate it via $wire.set.
The fix is one attribute:
use Livewire\Attributes\Locked; #[Locked] public int $bookingId = 0;
What counts as a mutation
Writes inside the seed methods — mount, __construct, boot, booted,
hydrate, dehydrate — are server-controlled initialisation, not user-driven
mutation, so they do not exempt a property. A write anywhere else does.
Recognised as a mutation: plain assignment, compound assignment (.=, +=, …),
reference assignment, ++/--, array-element writes ($this->rows[] = …),
list destructuring ([$this->a, $this->b] = …), unset($this->rows[$key]),
changes through in-place functions like sort($this->rows), assignment by a foreach
or a by-reference loop over the property, writes to an object it holds
($this->dto->name = …), and $this->reset('name') or $this->pull('name') with
literal property names. Methods from traits count.
What the rule deliberately skips
- Properties already marked
#[Locked], obviously. staticandreadonlyproperties — not client-editable state.Livewire\Formproperties. A Form is mutated through nested fields (wire:model="form.email") and never reassigned wholesale, so it reads as immutable — but locking the root breaks every nested update at runtime withCannotUpdateLockedPropertyException. This rule doesn't look inside Form classes either, and their fields are client-editable:$wire.set('form.postId', 2)works. Add#[Locked]yourself to any Form field the server controls.- Properties bound in a view. Locking a
wire:modelinput throwsCannotUpdateLockedPropertyException, so the rule reads the component's views. See How views are found. - Properties with an input attribute:
#[Validate],#[Rule],#[Modelable]and#[Session]properties are set by the client, and locking them throws too.#[Reactive]ones can't be set by the client at all. - Properties with validation rules in
$rules,rules(), or a literalvalidate()orvalidateOnly()call, for the same reason. - Properties with an update hook such as
updatedTitle(), including nested ones such asupdatedItemsName(). Livewire calls those when the client updates the property. - Models and Eloquent collections, by native type. Livewire ignores client writes to
these:
set('post', 2)keeps the record andset('post.id', 2)throws. #[Url]properties. The visitor sets these from the query string, so they are input, and locking one is whatLockedUrlConflictRulereports. The same goes for properties named in the legacy$queryStringorqueryString(), which Livewire binds to the URL the same way.- Framework-reserved properties:
$listeners,$rules,$messages,$validationAttributes,$validationCustomValues,$queryString,$paginationTheme. The fix for these isprotected, not#[Locked], whichPublicReservedPropertyRulereports. Configurable, see below. - The entire component, when a non-seed method performs an opaque
mass-assignment that could touch any public property:
$this->fill(),fillData(),mergeData(),resetExcept(), a no-argument or non-literal-argument$this->reset(), or a dynamic write$this->{$name} = …. Immutability can no longer be proven statically, so the rule stays quiet rather than emit false positives.
How views are found
The rule looks for a component's Blade source the way Livewire does:
- A single-file component's view is the rest of its file.
- A multi-file component's view is the
.blade.phpfile next to its.phpfile. - Otherwise it reads what
render()returns: an inline template, a literalview('x'),view()->make('x')orView::make('x'). - With no
render(), it readslivewire/<kebab-name>.blade.php, using the part of the class name afterLivewire\.
It follows @include, @includeIf, @includeWhen, @includeUnless, @includeFirst
and @each when the view name is a literal.
These count as binding a property: wire:model with any modifiers, $set, $toggle,
$wire.set, $wire.entangle, @entangle, $wire.upload, $wire.x = …,
x-model="$wire.x", and wire:click="x++". A binding whose name is computed, such as
wire:model="{{ $field }}", could be any property, so the rule stays quiet on the whole
component. Commented-out bindings don't count.
If it can't read every view, such as a pages::show whose namespace isn't configured
or a view name held in a variable, the rule still reports the property and adds a tip
about it. It can't see:
- A binding inside a Blade component's own template, such as a literal
wire:modelincomponents/email-input.blade.php. One passed through as<x-input wire:model="x">is fine. - A child component's
wire:model="$parent.x", which binds a property of the parent.
Views are read from resources/views under the directory you run PHPStan in. Change
that with viewPaths:
parameters: wirestan: viewPaths: - %currentWorkingDirectory%/resources/views - %currentWorkingDirectory%/modules/billing/views
A namespaced view, such as billing::invoice or Livewire's pages::show, is read from
the directories you list for its namespace, in order. Mirror what your app passes to
loadViewsFrom() or View::addNamespace():
parameters: wirestan: viewNamespaces: billing: - %currentWorkingDirectory%/modules/billing/views pages: - %currentWorkingDirectory%/resources/views/pages
On PHPStan 2.3 or later, editing a view re-analyses only the components that read it.
Older versions can't track this, so run vendor/bin/phpstan clear-result-cache after
changing a binding.
Configuration
Override the reserved-property list from your phpstan.neon:
parameters: wirestan: reservedProperties: - listeners - rules - messages - validationAttributes - validationCustomValues - queryString - paginationTheme - myOwnConvention
PublicReservedPropertyRule
Reports public $rules, $messages, $validationAttributes,
$validationCustomValues, $listeners, $queryString and $paginationTheme.
Livewire has no exclusion list for these, so a public one is client-editable like
any other property: $wire.set('rules', {title: 'nullable'}) switches off your
validation. Make them protected. #[Locked] and static properties are skipped.
class CreatePost extends Component { public array $rules = ['title' => 'required']; // ← flagged protected array $rules = ['title' => 'required']; // fine }
ComputedCacheScopeRule
Reports cached #[Computed] methods whose result depends on something the cache
key doesn't include. Which cache applies decides what counts:
| Attribute | Shared by | Flagged when the method reads |
|---|---|---|
cache: true, or persist: true with a key |
Every visitor | auth(), request(), session(), cookie(), the Auth, Request, Session, Cookie or Gate facades, or any public property |
persist: true |
One component instance | A public property the client can change |
#[Computed(cache: true)] public function myOrders() { return Order::whereBelongsTo(auth()->user())->get(); // ← flagged: every visitor gets the first caller's orders } #[Computed(persist: true)] public function results() { return Post::search($this->search)->get(); // ← flagged: stale once $search changes }
The check follows $this-> helpers and other computed properties defined on the
class or its traits.
Livewire uses key as a literal string, so key: 'posts-{search}' is still one
entry shared by everyone.
Not flagged for persist: true: #[Locked] properties, model properties (Livewire
protects their identity), and methods the class busts with unset($this->results).
Non-public properties are ignored for both cache types, because they aren't persisted
between requests and usually hold fixed values or services.
ModelParamWithoutAuthorizationRule
Reports public actions and #[On] handlers with a route-bindable parameter and no
authorization check. Livewire resolves the parameter from whatever id the client
sends, and the client can dispatch events itself, so any record can be named.
public function delete(Post $post) // ← flagged { $post->delete(); } #[Authorize('delete', 'post')] // fine public function archive(Post $post) { $post->archive(); }
Any of these counts as a check, in the action or in a $this-> helper it calls:
#[Authorize]on the action itself. Livewire ignores it on a helper, because it only runs when the client calls that method directly.$this->authorize(),->can(),->cannot(), or anyGate::call.abort(),abort_if()orabort_unless().- Throwing
AuthorizationException. - An ownership comparison against the current user, such as
$post->user_id !== auth()->id()or$post->owner->is(auth()->user()).
A check in boot(), booted() or hydrate() covers every action, because those run
on every request. A check in mount() doesn't, because it only runs on the first one.
The rule can't see method bodies on parent classes or route middleware. If your guard lives on a parent class, list its name:
parameters: wirestan: authorizationMethods: - ensureOwnership
If a route's can: middleware already covers the record, ignore the finding by its
identifier.
ValidationNeverEnforcedRule
Reports components with validation rules and at least one action, but no call to
validate() or validateOnly(). Rules alone don't stop anything: #[Validate]
records errors when a property updates, the action still runs afterwards, and
rules() does nothing until validate() is called.
class CreatePost extends Component // ← flagged { #[Validate('required')] public string $title = ''; public function save() { Post::create(['title' => $this->title]); } }
The rule checks that a call exists somewhere in the component, not that every action
makes one. It stays quiet on abstract classes and on components with a custom base
class, because the call may live where it can't see. It doesn't follow rules declared
on Livewire\Form objects.
LockedUrlConflictRule
Reports properties that are both #[Locked] and #[Url], on components and forms.
The lock only blocks client updates. #[Url] still writes the query-string value
during mount, so the visitor chooses a value the code treats as server-controlled.
#[Locked] #[Url] public int $tenantId = 0; // ← flagged: ?tenantId=99 sets it to 99
LegacyComputedAccessorRule
Reports public getFooProperty() accessors. They are ordinary public methods, so the
browser can call them and read the return value. Convert them to #[Computed], which
Livewire refuses to call from the client. Making an accessor protected isn't a fix,
because Livewire only recognises public ones.
public function getTotalProperty() // ← flagged { return $this->order->total; } #[Computed] // fine, still read as $this->total public function total() { return $this->order->total; }
AuthorizeTargetRule
Reports #[Authorize] targets that don't resolve. Livewire looks the target up at
runtime as a class, then a method parameter, then a property on the component. A typo
reaches the gate as null, and an untyped parameter reaches it as the raw client
value, so the check silently runs against the wrong thing.
#[Authorize('update', 'psot')] // ← flagged: no such class, parameter or property public function save() {} #[Authorize('delete', 'comment')] // ← flagged: needs a type hint public function remove($comment) {} #[Authorize('delete', 'comment')] // fine public function destroy(Comment $comment) {}
Valid property targets are public properties, including nested paths such as
post.author, and computed properties.
ReactiveMutationRule
Reports writes to #[Reactive] properties. The value belongs to the parent, and any
change throws CannotMutateReactivePropException, including in mount(), boot(),
hydrate() and render().
#[Reactive] public int $count = 0; public function mount(): void { $this->count = 1; // ← flagged } public function clear(): void { $this->reset(); // ← flagged: resets $count too }
Writes in __construct() and dehydrate() aren't flagged, because neither throws. A
fill() whose keys aren't literal isn't flagged either, because its targets are unknown.
ComputedMisuseRule
Reports #[Computed] methods that can't work as computed properties:
public string $total = ''; #[Computed] public function total(): int // ← flagged: the property answers $this->total first { return 1; } #[Computed] public function rows(Repo $repo) // ← flagged: throws ArgumentCountError { return $repo->all(); } public function export() { return $this->rows(); // ← flagged: runs it again, use $this->rows }
Livewire doesn't inject dependencies into computed methods, so a class-typed parameter
fails like any other. A same-named property of any visibility hides the method, unless
it's static. #[Computed] on a Livewire\Form is flagged too: Livewire ignores it there,
and reading $form->name throws. $this->rows(5) isn't flagged, because calling it is
the only way to pass an argument.
ResetTargetRule
Reports reset(), pull() and resetExcept() calls that throw, either straight away
or on the next read of the property.
public string $title; // typed, no default public Post $post; // typed, no default protected string $token = ''; public function clear() { $this->reset('titel'); // ← flagged: ReflectionException, no such property $this->reset('token'); // ← flagged: Error, non-public property $this->reset('title'); // ← flagged: unset, so the next read throws $this->reset(); // ← flagged: unsets $title and $post }
Livewire restores each property from a fresh instance, so a typed property with no
default has nothing to restore and gets unset. Livewire\Form properties are exempt,
because Livewire resets their fields instead. So is a property the same method assigns,
or the constructor sets. Only literal names are checked, and calls on Form objects aren't.
NonPersistedStateRule
Reports non-public properties set in mount() and read on later requests. Only public
properties travel in the snapshot, so the value is gone after the first request.
protected Client $client; public function mount(Client $client) { $this->client = $client; // ← flagged } public function send() { $this->client->post(); // Error: must not be accessed before initialization }
Set it in boot() or hydrate() instead, which run on every request. A write in any
method that runs after mount counts as re-seeding it, such as $this->client ??= … in
a helper. A value only read during mount, by mount() or a non-public helper only it
calls, isn't flagged. The rule stays quiet on properties declared on a parent class and
on components with a dynamic $this->{$name} = … write.
AsyncMutationRule
Reports #[Async] actions that write public properties, directly or through a $this->
helper. Async actions run in parallel from the same starting state, so concurrent calls
overwrite each other, and Livewire's docs say never to use them this way.
#[Async] public function increment() { $this->count++; // ← flagged: five quick clicks can add up to one } #[Async] public function track() { Analytics::track('viewed', $this->url); // fine }
Writes to non-public properties and to a model's attributes are ignored, because neither
is in the snapshot. The rule reads the attribute only, so it doesn't see wire:click.async.
ReservedActionNameRule
Reports actions named like one of $wire's own methods, such as upload, get, set,
call, on, watch or commit. wire:click="upload" runs $wire.upload, and $wire
resolves its own names before calling the server, so the action never runs and nothing
reports why.
public function upload() // ← flagged { $this->photo->store('photos'); } public function uploadPhoto() // fine { $this->photo->store('photos'); }
The list comes from Livewire 4, so a few names, such as dirty and island, are only
reserved from that version on. Overrides of methods from a parent class, such as
dispatch(), aren't flagged.
Adopting on an existing codebase
The rules will light up on any Livewire codebase that predates them. Generate a baseline so it only fails on new violations, then burn the entries down:
vendor/bin/phpstan analyse --generate-baseline
Each LockedPublicPropertyRule entry is a real decision. Add #[Locked] where the
property is server-controlled. Leave a genuine wire:model input unlocked, because
locking it throws CannotUpdateLockedPropertyException. The rule reads your views to
recognise those, but it can't see every binding.
To silence one of those, or any other individual finding, use the error identifier:
parameters: ignoreErrors: - identifier: boringO11yWirestan.lockedPublicProperty path: app/Livewire/SomeComponent.php
Development
composer install composer test # phpunit composer phpstan # analyse the package itself
Developing the package needs PHPStan 2.3 or later. Tests use PHPStan's RuleTestCase
against fixtures in tests/Fixtures, with
minimal Livewire\* and Illuminate\* stubs in tests/Stubs so the package has
no runtime dependency on Livewire itself.
License
MIT — see LICENSE.