Skip to content
LaravelBlog

livewire/livewire: Fix nested query parameters with the #[Url] attribute

LaravelBlogBot
LaravelBlogBot

Contributed by hamdyelbatal122

Part of the livewire v4.4.7 release

Pull request #10305 fixes nested query string parameters when using the #[Url] attribute with a dotted alias, such as #[Url(as: 'form.search')] on a Form object property. Before this fix, a URL like ?form[search]=hello was not read back into the component on initial page load, and could not be recovered from the referer URL on subsequent requests, because both the PHP and JavaScript sides performed flat array key lookups ($query['form.search']) against data that is actually nested (['form' => ['search' => ...]]). The change ships on the 4.x branch, so it is part of the version you land on when upgrading from v3.8.10, and it requires no changes to your code.

What was broken

Aliasing a property into a nested key writes bracket syntax into the URL:

?form[search]=hello

On the server, that parses into a nested array. The old lookup, however, did request()->query('form.search') — a literal top-level key that never exists in a nested query bag — so the property silently kept its default value. The same flat lookup ($query[$key] ?? $default) broke referer-based recovery, which Livewire uses to restore query string state on later requests. A related subtlety: a parameter that is present but empty (?form[search]=) should be treated as "present with no value" rather than "absent", and that distinction also failed for nested keys.

The JavaScript history plugin had the mirror-image problem. Its has() check used Object.keys(data).includes(key), and get(), set(), and remove() used flat data[key] access. Worse, fromQueryString() only converted bracket keys to dot notation when the raw decoded key started with the query key — but form[search] never starts with form.search, so the value was stored under the literal key form[search] and never matched.

What changed on the server

SupportQueryString\BaseUrl (the class behind #[Url] in v4) now uses dot-notation-aware helpers:

  • data_get(request()->query(), $this->urlName(), $default) reads nested values during page load.
  • Arr::has(request()->query(), $name) correctly detects "present but without a value", preserving the existing behavior of returning null instead of the default in that case.
  • data_get($query, $key, $default) reads nested values from the parsed referer URL.

New unit tests lock in both edge cases: with ?form[search]= and a string $search = 'default' property, the property becomes '', not 'default'; and reading the key form.search from the referer URL http://localhost/?form[search]=hello returns 'hello'.

What changed in the browser

The history plugin's query string utilities now route all access through dataGet, dataSet, and dataDelete, so has(), get(), set(), and remove() all understand form.search as a nested path. In fromQueryString(), bracket keys are converted to dot notation up front, and a bracket key is parsed as nested data when its dot-notated form equals the query key or lives beneath it (form.search or form.search.term). Keys that don't match are still stored flat, so unrelated query parameters are untouched. The plugin can therefore detect, read, write, and remove nested URL state correctly, keeping ?form[search]=... in sync as you type and after navigation.

What it looks like in practice

use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\Form;

class SearchForm extends Form
{
    #[Url(as: 'form.search')]
    public string $search = '';
}

class ProductTable extends Component
{
    public SearchForm $form;

    public function render()
    {
        return view('product-table');
    }
}

Visiting /products?form[search]=livewire now hydrates $this->form->search with 'livewire' on first paint. With <input type="text" wire:model.live="form.search">, typing keeps the address bar pinned to ?form[search]=..., and a full page refresh reads the value straight back out of the URL. The new browser test in the PR covers exactly this cycle: visit, assert the hydrated input value, type, refresh, then assert both the value and the URL survive.

Upgrade impact from v3.8.10

There is no public API change here — #[Url], its as: alias, except(), and history behavior all work as before. Two practical notes for upgraders:

  1. If you avoided dotted aliases because they did not hydrate reliably — or worked around the bug by manually parsing request()->query('form')['search'] in mount() or booted() — you can remove those workarounds and rely on the attribute.
  2. Values that previously fell back to defaults (because the lookup failed) will now be populated from the query string. If a component also assigns those properties manually, make sure the attribute-driven value is the one you expect to win, and remember that ?form[search]= now correctly means "empty string", not "missing".

Takeaways:

  • Nested #[Url] aliases such as as: 'form.search' now hydrate from ?form[search]=... on page load, stay in sync via the history plugin, and are recovered from the referer on subsequent requests.
  • The fix is two-sided: PHP lookups use data_get()/Arr::has(), and the JavaScript history plugin uses dataGet/dataSet/dataDelete.
  • "Present but empty" is now distinguished from "absent" for nested keys, matching the existing top-level behavior.
  • No API changes: existing #[Url] usage keeps working, and manual workarounds for nested parameters can be deleted.

Sources

More from this release

Related Articles