Skip to content
LaravelBlog

livewire/livewire: Honor scoped route bindings for mount() method injection

LaravelBlogBot
LaravelBlogBot

Contributed by mateusjunges

Part of the livewire v4.4.7 release

Pull request #10341 fixes a subtle but important bug in how Livewire wires route models into full-page components: when a route used Laravel's scopeBindings(), a model type-hinted in mount() was still resolved with a global lookup, so your component could receive a record that belongs to a different parent resource. The fix ships in the 4.x line. If you're upgrading from v3.8.10, you gain correct, consistent resolution across public properties and mount() parameters — with no required code changes.

The scenario: scoped bindings meet mount()

Consider a nested full-page component where the child's route key is only unique within its parent — for example, channels that live under events and are addressed by slug:

Route::get('{event}/channels/{channel}', ShowChannel::class)
    ->name('channels.show')
    ->scopeBindings();

The scopeBindings() call tells Laravel to resolve {channel} through {event} rather than globally. The component then injects the models:

class ShowChannel extends Component
{
    public Event $event;
    public Channel $channel;

    public function mount(Channel $channel): void
    {
        $this->authorize('view', $channel);
    }
}

Visiting /second-event/general should resolve the "General" channel belonging to the second event. Before this fix, it often didn't.

What went wrong

Inside Livewire's drawer, ImplicitRouteBinding::resolveAllParameters() resolved mount() parameters before public properties. When Laravel's implicit binding resolved the mount(Channel $channel) signature, the parent {event} parameter was still a raw slug string in the route's parameter bag — not a resolved model. Laravel's scoped-binding branch only applies when the parent is already a resolved UrlRoutable instance, so the lookup fell back to a plain, unscoped resolveRouteBinding(). The first event in the database with a matching slug won.

The result: when a model was both a typed public property and a mount() parameter, both received the unscoped record. Authorization then ran against the wrong model — producing spurious 403s and 404s, or worse, silently rendering one event's channel content under another event's URL.

The fix: resolve bindables in URI order first

The PR adds a pre-pass, resolveRouteParametersInOrder(), that mirrors Laravel's own router: it collects bindable types from public properties and mount() parameters (keeping only UrlRoutable and BackedEnum classes), walks the route parameters in URI order, resolves each one, and seeds the resolved model back into the route with setParameter().

By the time mount() parameter resolution runs, {event} is already a resolved model, so Laravel applies the scoped lookup and the child resolves through its parent — including any custom route key like {channel:slug}. Four new unit tests lock in every combination: child as a mount() parameter, the same model as both prop and mount() parameter, parent and child both as mount() parameters, and parent as a mount() parameter with the child as a prop. Prop and mount() param for the same binding now also resolve to the same scoped record.

What changes for your components

Behavior only changes on routes using scopeBindings(), and only for the better:

  • Valid URLs now resolve the correct scoped record, so $this->authorize('view', $channel) runs against the channel that actually belongs to the parent in the URL.
  • Invalid combinations — a slug that doesn't exist under that parent — now 404 instead of resolving whatever matched globally.

If you worked around the old behavior by re-resolving the child through the parent, you can delete that code:

public function mount(Event $event, Channel $channel): void
{
    // Workaround no longer needed:
    // $channel = $event->channels()
    //     ->where('slug', request()->route('channel'))
    //     ->firstOrFail();

    $this->authorize('view', $channel); // now guaranteed scoped to {event}
}

Upgrading from v3.8.10

There are no API changes, new config, or migration steps — the change is internal to how the drawer sequences route binding resolution. A few things to keep in mind:

  • If a page previously "worked" by showing the wrong parent's record, expect it to now show the right record or 404. Audit any tests that asserted the old, unscoped result.
  • Non-scoped routes, enum bindings, and components that don't use model binding are unaffected.
  • Because this is a correctness fix with authorization implications, it's worth prioritizing in your upgrade even if your test suite doesn't cover nested bindings today.

Takeaways

  • scopeBindings() is now honored for models injected via mount(), not just typed public properties.
  • Route bindables are resolved in URI order first, so parent models are always available to scope their children.
  • Wrong-record resolution (and the 403s/404s it caused) is fixed; invalid parent/child combinations now 404.
  • No code changes are required to upgrade, and existing scoped-binding workarounds inside mount() can be removed.

Sources

More from this release

Related Articles