livewire/livewire: Preserve structured strings in URL properties
Contributed by calebporzio
Part of the livewire v4.4.7 release
If you are upgrading from Livewire v3.8.10 to v4, pull request #10617 is one of those fixes you will likely never see working — because it prevents a crash. URL-bound properties no longer decode strings that merely look like JSON. Visiting a page with ?search=[123] used to hydrate a string property as the array [123], breaking pages — most visibly in Filament — with an Array to string conversion error. In v4, structured-looking text stays text, while genuine bracket-notation arrays and ordinary scalar coercion behave exactly as before.
Why ?search=[123] broke pages
PHP's query-string parser already distinguishes a scalar value from an array: ?search=[123] is a scalar, ?search[0]=123 is an array. Livewire's hydration layer, however, ran every scalar value through json_decode() before assigning it to the component, so the text [123] was decoded into a real PHP array on the way in.
Consider a typical URL-bound property:
class ProductTable extends Component
{
#[Url]
public string $search = '';
public function render()
{
return view('products.table', ['results' => Product::search($this->search)->get()]);
}
}
Visiting /products?search=[123] — for example, because a user pasted that into a search box that syncs to the URL:
- v3.8.10:
$searchhydrates as the array[123]. Rendering it — or Filament reading it as a search term — fails withArray to string conversion, and the page fails to load. - v4 with this PR:
$searchhydrates as the literal string'[123]', and the page loads normally.
The bug was surfaced through Filament (#20372) and first addressed by an alternative fix (#10602); #10617 is the reworked implementation that landed.
The fix: check the property type before decoding
Instead of trusting json_decode() unconditionally, BaseUrl now only intervenes where it matters: when a raw scalar decodes into an array, it asks whether the target property actually wants that array. It resolves the property — including nested form-object properties — and compares both candidates against the declared type. If the type rejects the array and accepts the string (a string property, or an uninitialized ?string), the original text is preserved. If the property is untyped, the code falls back to the type of the value it already holds: a default of '' keeps strings, a default of [] still accepts the decoded array.
Side by side, the behavior you get after upgrading:
| Query string | Property | v3.8.10 | v4 |
|---|---|---|---|
?search=[123] |
public string $search = '' |
[123] (array, crash) |
'[123]' |
?search[0]=123 |
public array $search = [] |
[123] |
[123] |
?page=2 |
public int $page = 1 |
2 |
2 |
A reusable type-matching utility
The decision is not buried inside the query-string feature. It lives in a new documented utility, Utils::propertyTypeMatchesValue(), which performs a strict, non-coercing comparison between a value's native PHP type and a property's declared type:
Utils::propertyTypeMatchesValue($component, 'search', '[123]'); // true (string property)
Utils::propertyTypeMatchesValue($component, 'search', [123]); // false
Utils::propertyTypeMatchesValue($component, 'filters', '[123]'); // null (untyped property)
It returns true when the value satisfies the type, false when it doesn't, and null when there is no type contract — a missing or untyped property — so callers decide the fallback instead of the utility guessing. It understands named scalars, mixed, nullable types, unions such as string|array, intersections such as Countable&Stringable, classes, interfaces, and self/static/parent. The policy ships in the internal Livewire\Drawer namespace with a dedicated test matrix covering each combination.
What still decodes exactly as before
Only structured-looking strings on string-capable properties are preserved; everything else keeps its existing behavior:
- Bracket-notation arrays:
?search[0]=123and?filters[status]=activestill hydrate as arrays, and partially present arrays still merge into existing values. - Opt-in structured decoding: declare a union type and JSON-shaped values still decode:
#[Url]
public string|array $filters;
With ?filters={"status":"active"}, this hydrates as ['status' => 'active'], just like before.
- Scalar coercion:
?page=2still lands asinton anintproperty; number and boolean coercion, plus the preservation of oversized integers as strings, are untouched.
Upgrade impact from v3.8.10
- Crashes disappear. Pages that failed on
Array to string conversionwhen a visitor's query value looked like JSON now load, and the value stays the string your property declared. - Audit accidental array hydration. If any code relied on
?x=[1,2]arriving as an array on a string-typed or string-initialized property, that no longer happens. Declare the propertystring|arrayto keep decoding, orjson_decode()the string yourself. - Untyped properties follow their current value.
public $search = ''keeps strings;public $search = []keeps accepting arrays. - No migration needed. There are no API changes;
#[Url], legacyqueryString()methods, and all existing attributes keep working. The change is covered by the unit suite (1,404 tests / 3,985 assertions) and the query-string browser suite (36 tests / 288 assertions).
Takeaways
- Structured-looking strings such as
[123]or{"id":1}now stay strings on string-capable#[Url]properties, fixingArray to string conversioncrashes, including Filament's. - Bracket-notation arrays, scalar number/boolean coercion, partial array merging, and big-integer handling are unchanged.
- Union types like
string|arraylet a property opt back into structured decoding. - The strict matcher
Utils::propertyTypeMatchesValue()centralizes the policy and returnsnullfor untyped or missing properties instead of guessing. - Upgrading from v3.8.10 requires no code changes, but audit anything that depended on JSON-looking query values becoming arrays on string properties.