DEV Community

Cover image for Deep-Linkable Livewire: Scoping a Browser to the Thing You Clicked
Nasrul Hazim Bin Mohamad
Nasrul Hazim Bin Mohamad

Posted on

Deep-Linkable Livewire: Scoping a Browser to the Thing You Clicked

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 = '';

    // ...
}
Enter fullscreen mode Exit fullscreen mode

#[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>
Enter fullscreen mode Exit fullscreen mode

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() ?? '';
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

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)