vlados/cascader
A cascading dropdown for Laravel Livewire and Alpine.js, inspired by Ant Design's Cascader. It renders a two-column desktop dropdown with search and a mobile bottom sheet, supports light and dark mode, two-level option arrays, and standard wire:model binding.
Verify the requirement matrix before you run composer require — this is a young package (0 stars, 0 dependents, 0.x-era maturity), so the cheapest failure is a version mismatch:
php -v # 8.2+ (8.3+ if you're on Laravel 13)
composer show livewire/livewire # 3.x or 4.x
Then the three install steps that actually matter:
composer require vlados/cascader
php artisan vendor:publish --tag=cascader-scripts
The blade component emits x-data="cascader(...)", so the Alpine factory must be registered before Alpine.start(). In a Vite/bundled app:
// resources/js/app.js
import { cascader } from './vendor/cascader/cascader';
document.addEventListener('alpine:init', () => {
Alpine.data('cascader', cascader);
});
Don't skip this and assume Livewire's bundled Alpine covers you — Livewire ships Alpine but not this factory. The plain-<script> fallback (auto-registration) only works if the script tag lands before alpine:init fires, which is easy to get wrong under wire:navigate or deferred loading.
Third step that people forget and then debug in production only: Tailwind's content globs.
// tailwind.config.js
content: [
'./resources/**/*.blade.php',
'./vendor/vlados/cascader/resources/views/**/*.blade.php',
],
Where to look first: after publishing, open the package's Blade view and the published cascader.js. The README excerpt doesn't spell out every prop, and with a package this young the view is the source of truth for prop names, defaults, and the options payload shape. Read it once before you build a wrapper around it.
First use case: a category/taxonomy picker on a create form. Server-side, produce a nested array (value / label / children), bind it with wire:model, and validate the resulting path server-side. That exercise surfaces every quirk you'll hit later: payload size, re-render state loss, and dark mode.
Single selection on a form, deferred binding. Prefer the default (deferred) wire:model over .live for a cascader. Every keystroke in the search box or hover in the panel is Alpine-local; making the pick live-bound forces a round trip that can clobber the open panel.
<form wire:submit="save">
<x-cascader
wire:model="categoryPath"
:options="$this->categoryTree"
placeholder="Select category" />
@error('categoryPath') <span class="text-red-600">{{ $message }}</span> @enderror
</form>
The bound value is conventionally the path of values, not the leaf alone — so validation is a path rule, not a scalar rule:
protected $rules = [
'categoryPath' => ['required', 'array', 'min:1'],
'categoryPath.*' => ['integer', 'exists:categories,id'],
];
Deriving the leaf when you persist:
$leafId = collect($this->categoryPath)->last();
Build the tree once, memoize it. A cascader payload is a tree serialized into the Livewire snapshot on every request. Two rules keep it sane: cache the tree (Cache::remember('category-tree', 3600, ...)) and keep it plain arrays — no Eloquent models, no enums.
public function getCategoryTreeProperty(): array
{
return Cache::rememberForever('category-tree', fn () =>
Category::orderBy('name')->get(['id', 'name', 'parent_id'])
->toTree() // or your own recursive map
->map(fn ($n) => $this->toNode($n))
->all()
);
}
private function toNode(Category $c): array
{
return array_filter([
'value' => $c->id,
'label' => $c->name,
'children' => $c->children->map(fn ($k) => $this->toNode($k))->all() ?: null,
]);
}
Dependent / filtered cascades. If the tree depends on another field (country → region → city), don't rebuild on every updated(). Rebuild only when the parent actually changes, and null out stale descendants in the same tick — otherwise the user keeps a city that belongs to the previous country.
public function updatedCountryId(): void
{
$this->reset('regionPath');
$this->dispatch('$refresh');
}
Multiple cascaders in a repeater. This is where a stateful Alpine widget bites hardest. Every row needs a stable identity or Livewire will recycle the wrong Alpine instance on add/remove:
@foreach ($rows as $index => $row)
<div wire:key="row-{{ $row['id'] }}">
<x-cascader wire:model="rows.{{ $index }}.path" :options="$tree" />
</div>
@endforeach
Dotted wire:model paths are required here; wire:model="rows.{{ $index }}.path" plus wire:key is the combination that survives reordering.
Isolating the widget from noisy siblings. Anything that re-renders the cascader's parent DOM kills the open panel. Wrap the cascader in its own component, or at minimum place it in a subtree that isn't touched by unrelated wire:model.live inputs (a search box elsewhere in the form).
Mobile is free, but test it. The sub-640px bottom sheet is a CSS breakpoint, not a separate component — nothing to configure. But Tailwind's sm: default (640px) means if your app overrides the screens scale, the sheet breakpoint moves with it.
Standardize with your own wrapper. Publish the views, then create resources/views/components/app/cascader.blade.php around the package component. One place for your placeholder text, dark-mode classes, error ring, and icon set — so a future package upgrade doesn't require touching 40 call sites.
A broken Alpine factory looks like a broken component. If the panel never opens, check the console for cascader is not defined before touching Blade. The three causes, in order of frequency: script imported after Alpine.start(); published file path wrong in the import (./vendor/cascader/cascader relative to resources/js/); or the script being tree-shaken out because nothing references it.
Never register the factory twice. If you both import it in your bundle and load it via <script>, you'll get a duplicate Alpine.data('cascader', ...) — usually harmless, occasionally a stale closure. Pick one path and remove the other.
Tailwind purge failures are production-only. It works in dev because Vite watches files, then the classes vanish in a production build. Symptom: correct markup, zero styling, dropdown rendered inline in the flow. Fix the content glob before you build anything else.
Dark mode requires Tailwind's strategy to match the package's. If the package views assume darkMode: 'class' and your app uses 'media' (or vice versa), you get half-styled panels. Check the package's classes for dark: usage and align your config.
Large trees bloat every Livewire request. The full tree is serialized into the snapshot and round-tripped on each interaction. Past a few thousand nodes, the payload—not the render—is the bottleneck. Mitigations: cache and prune (only ship branches two levels deep and load lazily), or replace the cascader with a modal picker for that one screen. Measure the snapshot size in the network tab; it's the fastest diagnostic.
Re-renders reset search and scroll. Alpine-local state (query string, open panel, active step on mobile) does not survive a Livewire re-render of the parent. If you need the panel to stay open across a server round trip, you're fighting the design — restructure so the cascader isn't re-rendered during selection.
Z-index and clipping inside modals. The mobile bottom sheet is fixed-positioned; inside a modal with overflow-hidden or a transformed ancestor, it can be clipped or sit underneath. Test at <640px inside your actual modal, not standalone.
Icons are silent when missing. The default resolver is FontAwesome. If your app uses Heroicons, you get empty icon slots with no error. Publish the config and point the resolver at your icon set before shipping — and add icons to your Tailwind safelist if they're injected dynamically.
Debugging bindings server-side. Livewire::test(MyForm::class)->set('categoryPath', [1, 4])->assertSet(...) catches value-shape mistakes without opening a browser. Worth one test given the package's maturity — assertSet proves the wire format, which is the part most likely to drift on a upgrade.
Pin the version and vendor the views. With 0 dependents and a single maintainer, treat upgrades as reviewed changes, not composer update drift: exact version constraint in composer.json, published views committed to your repo, and one Livewire smoke test asserting the model value round-trips. That way a breaking release shows up as a failing test or a readable diff, not a broken form in production.
PHP/Laravel version coupling. The Laravel 13 path requires PHP 8.3+. If your CI matrix or a legacy container runs 8.2, the install resolves to a different (older) build than your local machine — check composer.lock rather than assuming parity.
How can I help you explore Laravel packages today?