Livewire 4 #[Url]: Sync Component State to the Query String

The Livewire #[Url] attribute syncs component state to the query string so search, filters, and sorting stay shareable, bookmarkable, and back-button friendly.

Steven Richardson
Steven Richardson
· 6 min read

You build a product list with a search box and a sort dropdown. A user narrows it to "wireless keyboards, cheapest first", refreshes the page, and everything resets. Worse, they can't send that exact view to a colleague, because the URL never changed. Livewire's #[Url] attribute fixes both problems in one line: it binds a component property to the query string, reading it on load and writing it on every change.

Bind a property to the URL with the #[Url] attribute#

Start with a normal Livewire component. A search property feeds a computed query, and wire:model.live updates results as the user types.

<?php

namespace App\Livewire;

use App\Models\Product;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;

class ProductIndex extends Component
{
    use WithPagination;

    #[Url] // Binds $search to the ?search= query parameter
    public string $search = '';

    #[Computed]
    public function products()
    {
        return Product::query()
            ->when($this->search, fn ($query) => $query->where('name', 'like', "%{$this->search}%"))
            ->paginate(12);
    }

    public function render()
    {
        return view('livewire.product-index');
    }
}
<div>
    <input type="text" wire:model.live.debounce.300ms="search" placeholder="Search products…">

    <ul>
        @foreach ($this->products as $product)
            <li wire:key="{{ $product->id }}">{{ $product->name }}</li>
        @endforeach
    </ul>

    {{ $this->products->links() }}
</div>

That single #[Url] attribute does two jobs. Type "keyboard" and the address bar becomes /products?search=keyboard. Load that URL fresh in a new tab and Livewire reads the query string back into $search, so the input and the filtered results are already there. The .debounce.300ms keeps you from firing a request on every keystroke. If you want the underlying query itself to stay cheap, wrap it in a computed property that caches heavy queries, and pair the input with typo-tolerant search using Laravel Scout and Typesense when LIKE stops scaling.

Clean up the URL: aliases and nullable params#

?search=keyboard is fine, but ?q=keyboard is tidier, and you often want a sort parameter too. Use as: to rename the query parameter, and add more #[Url] properties for each piece of state you want shareable.

#[Url(as: 'q')] // Shows ?q= instead of ?search=
public ?string $search = null;

#[Url(as: 'sort')]
public string $sortBy = 'name';

Now the URL reads /products?q=keyboard&sort=price. Notice the nullable typehint on $search. By default an empty parameter like ?q= hydrates as an empty string, but the ? in ?string tells Livewire to resolve ?q= to null instead. That matters when your query logic checks when($this->search, ...) — a real null reads cleaner than an empty string and keeps your "no filter applied" branch unambiguous.

If you're coming from the old protected $queryString array, the Livewire 3 to 4 migration guide covers the move from that array to per-property attributes — the queryString() method still exists for dynamic cases, but #[Url] is the path you want for static bindings.

Control browser history and pagination with #[Url]#

By default Livewire uses history.replaceState(), so it rewrites the current history entry rather than pushing a new one. Press the browser back button and you leave the page entirely instead of stepping back through previous filters. Add history: true to push a real history entry on each change so back and forward walk through filter states.

#[Url(history: true)]
public ?string $search = null;

public function updatedSearch(): void
{
    // A new search must start on page 1, not page 5
    $this->resetPage();
}

That updatedSearch() hook is the part people forget. Livewire's WithPagination trait stores the current page in the query string too, so a user can be on page 5, type a new search that returns two pages, and land on an empty page 5. Calling resetPage() whenever a filter changes snaps them back to page 1. The same applies to the sort property — give it an updatedSortBy() hook that resets the page as well. If you've swapped numbered pages for infinite scroll with wire:intersect, you reset the loaded collection instead, but the trigger is identical: state changed, start over.

One nice payoff: because the filter state lives in the URL, it survives wire:navigate SPA-style page visits. A user can click away to a product detail page and hit back to find their exact filtered list intact.

Gotchas and Edge Cases#

history: true plus wire:model.live on a text input is a trap. Every keystroke becomes a history entry, so the back button needs a dozen presses to escape the search box. Reserve history: true for discrete controls — sort dropdowns, category filters, tab switches — and leave the live-typing search box on the default replaceState behaviour, or at least debounce it hard.

URL-bound properties are user-controllable input, so treat them like any other request data. A sortBy that flows straight into orderBy($this->sortBy) lets anyone pass ?sort=any_column, which leaks intent at best and throws at worst. Whitelist it:

public function updatedSortBy(): void
{
    if (! in_array($this->sortBy, ['name', 'price', 'created_at'], true)) {
        $this->sortBy = 'name';
    }

    $this->resetPage();
}

Two more things bite people. First, multiple paginators on one page fight over the same ?page= entry — give each its own name with ->paginate(12, pageName: 'products') so they track independently. Second, if you ask "how do I keep a property out of the URL when it's empty", that's already the default: Livewire only writes a parameter once it differs from its initial value, so an empty search that matches your empty default never appears. You only need except: '' when you set a non-empty default in mount() and still want the parameter dropped when the user clears it.

Wrapping Up#

Reach for #[Url] whenever a piece of UI state should be shareable or survive a refresh: search, filters, sort, active tab, pagination. Add the attribute, alias the parameter, make it nullable where empty should mean "nothing", and reset pagination on every filter change. From here, the natural next step is to build it into a fully server-driven table with pagination and sorting where every column header and filter writes to the URL.

FAQ#

How do I store Livewire component state in the URL?

Add the #[Url] attribute above any public property in your component. Livewire then mirrors that property into the query string — writing the value as it changes and reading it back when the page loads. It works for search terms, filters, sort columns, and pagination, which makes the resulting view both bookmarkable and shareable.

What does the #[Url] attribute do in Livewire?

#[Url] creates a two-way binding between a component property and a query string parameter. On page load it initialises the property from any matching parameter in the URL, and on every update it rewrites the URL to reflect the current value. By default it uses history.replaceState() so it updates the address bar without polluting browser history.

How do I change the query string parameter name in Livewire?

Pass the as: argument to the attribute, like #[Url(as: 'q')]. A property named $search will then appear in the URL as ?q= instead of ?search=. This is handy for shortening long property names or obscuring internal naming, and the property name in your PHP code stays unchanged.

How do I make the browser back button work with Livewire filters?

Add history: true to the attribute, for example #[Url(history: true)]. This switches Livewire from replaceState to pushState, so each change creates a new browser history entry and the back and forward buttons step through previous filter states. Use it on discrete controls like sort and category selects rather than a live-typing search box, where it would create a history entry per keystroke.

How do I keep a Livewire property out of the URL when it's empty?

That is the default behaviour — Livewire only adds a parameter to the query string once its value differs from the value it was initialised with, so an empty property matching its empty default stays out of the URL entirely. You only need to intervene when you set a non-empty default inside mount(); in that case add except: '' to drop the parameter whenever the value is an empty string.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.