Today's work was a small piece of UX that's easy to underestimate: making an admin "browser" page scope itself to whatever you clicked to get there. The setup is one I keep running into — you have a list of connectors (think AD/LDAP directory connections), and each connector has its own users and its own groups. The old flow dumped you into a generic Users browser and made you re-pick the connector from a dropdown. Annoying. You already told the app which connector you cared about by clicking its row.
The fix is the kind of thing Livewire makes almost too easy, so I want to slow down and talk about why it works and where the edge cases hide.
The core idea: a URL-bound property
The whole feature hinges on one attribute. In Livewire, you can bind a public property straight to a query-string parameter:
use Livewire\Attributes\Url;
use Livewire\Component;
class UserBrowser extends Component
{
#[Url(as: 'connector')]
public string $selectedConnector = '';
// ...
}
#[Url(as: 'connector')] means two things at once. When $selectedConnector changes, the URL becomes ?connector=acme-ad without a full page load. And when someone arrives at ?connector=acme-ad, Livewire hydrates the property from the query string before mount() finishes. That second half is what makes deep links work — the page reads its own starting state from the URL.
So the "View Users" link on a connector row is nothing fancier than:
<a href="{{ route('admin.directory.users', ['connector' => $connector['name']]) }}">
View Users
</a>
Click it, land on the browser, and the property is already set to that connector. No dropdown dance.
The edge case everyone forgets: untrusted input
Here's the thing — ?connector= is user input. Anyone can type ?connector=lol-not-real into the address bar. If you trust it blindly, you get an empty browser, a stray exception, or worse, a UI that looks like it's scoped to something that doesn't exist.
A URL-bound property is a contract with the outside world, and the outside world lies. So mount() has to validate against the real, allowed set and fall back gracefully:
public function mount(): void
{
$names = collect(app(ConnectorSettings::class)->connectors)
->filter(fn (array $c) =>
($c['is_active'] ?? false)
&& in_array($c['type'] ?? '', ['ad', 'ldap'], true)
)
->pluck('name');
// Honour a ?connector= deep link, but only if it's real and active.
// Otherwise fall back to the first active connector.
if ($this->selectedConnector === '' || ! $names->contains($this->selectedConnector)) {
$this->selectedConnector = $names->first() ?? '';
}
}
Two failure modes, one guard: empty (no deep link, default to the first) and unknown (someone made up a name, also default to the first). Notice the allow-list is derived from config — only active connectors of the right type count. The query string can request anything; the component only honours what's genuinely on the menu.
This is the same instinct as validating a Form Request or authorizing a policy: never let the edge of the system set internal state without a check.
Stripping secrets out of a details modal
The second half of the day was a "View Details" modal on each connector row. Connectors carry connection config — host, port, base DN, sync settings — and they also carry credentials. The modal should show the former and never the latter.
I leaned on a #[Computed] property so the view never touches the raw config:
use Livewire\Attributes\Computed;
public ?int $detailsIndex = null;
public function showDetails(int $index): void
{
$this->detailsIndex = $index;
$this->modal('connector-details-modal')->show();
}
#[Computed]
public function detailsConnector(): ?array
{
if ($this->detailsIndex === null) {
return null;
}
$connector = app(ConnectorSettings::class)->connectors[$this->detailsIndex] ?? null;
if ($connector === null) {
return null;
}
// Never let credentials reach the Blade view (or the wire payload).
unset($connector['password'], $connector['client_secret']);
return $connector;
}
Why a computed property and not just a public array? Because anything you put in a public property gets serialized into the component's wire payload and shipped to the browser on every request. A #[Computed] value is resolved server-side at render time and isn't part of the persisted state — so the stripped array is the only shape the secret-free data ever takes. The unset() is the redaction; the computed property is what keeps the un-redacted version from ever leaving the server in the first place.
Small but worth saying out loud: stripping a secret in the view is too late. Strip it at the boundary where data becomes "stuff the client can see."
A test worth writing
The deep-link fallback is exactly the kind of logic that quietly rots. Pest makes the contract explicit:
it('falls back to the first active connector for an unknown deep link', function () {
config()->set('connectors', [
['name' => 'acme-ad', 'type' => 'ad', 'is_active' => true],
['name' => 'beta-ldap', 'type' => 'ldap', 'is_active' => true],
]);
Livewire::withQueryParams(['connector' => 'does-not-exist'])
->test(UserBrowser::class)
->assertSet('selectedConnector', 'acme-ad');
});
it('honours a valid connector deep link', function () {
config()->set('connectors', [
['name' => 'acme-ad', 'type' => 'ad', 'is_active' => true],
['name' => 'beta-ldap', 'type' => 'ldap', 'is_active' => true],
]);
Livewire::withQueryParams(['connector' => 'beta-ldap'])
->test(UserBrowser::class)
->assertSet('selectedConnector', 'beta-ldap');
});
Livewire::withQueryParams() is the bit that makes this honest — it simulates the deep-link arrival, so you're testing the same hydration path a real browser hits, not just calling mount() by hand.
Takeaway
Three ideas did the heavy lifting today, none of them new, all of them easy to get subtly wrong:
A URL-bound property turns "where you came from" into shareable, bookmarkable state for free — but it's untrusted input, so validate it against an allow-list and fall back, never trust it raw. And when you surface config in a modal, redact at the boundary with a computed property, not in the template, so secrets never ride along in the wire payload.
The UX win — click a connector, land already scoped to it — is the visible part. The invisible part is the two guards that keep a convenient query string from becoming a foot-gun.
Top comments (0)