kabuto/dependent-select-bundle
Kabuto DependentSelectBundle adds dependent select fields to Symfony forms, enabling cascading dropdowns whose options update dynamically via AJAX based on parent selections, with straightforward configuration and form integration.
Before anything else: check the stack fit. kabuto/dependent-select-bundle is a Symfony bundle, not a Laravel package. It ships no DependentSelectServiceProvider, no config/dependent-select.php, no publishable Blade views, and its integration points are Symfony-only constructs (Bundle subclass in AppKernel/bundles.php, DependencyInjection/Extension, compiler-pass service tags, bundle-owned routing resources). Laravel's container and package discovery cannot consume any of that. composer require kabuto/dependent-select-bundle into a Laravel app will either fail constraint resolution or install a pile of Symfony components you won't be able to wire up.
So your "getting started" is a 30-minute read, not an install:
composer.json first. Check require for php, symfony/form, symfony/framework-bundle, and (almost certainly) doctrine/orm. Symfony 2.x/3.x-era pins (~2.3|~3.0, php: >=5.5) mean it cannot coexist with Laravel 10/11 on PHP 8.1+ without --ignore-platform-reqs — which is a red flag, not a workaround.Your first use case should be the smallest one you actually have: one parent field driving one child, e.g. category_id → subcategory_id on an admin CRUD form, or country → region at checkout. Don't build a generic multi-field resolver yet — build that one pair end-to-end in Laravel idioms and validate it against a real form.
A working skeleton in ~40 lines:
// routes/api.php
Route::get('/options/{field}', [OptionController::class, 'show'])
->middleware(['auth'])
->whereIn('field', ['subcategory', 'region', 'city']);
// app/Http/Controllers/OptionController.php
class OptionController extends Controller
{
public function show(Request $request, string $field)
{
$parentId = $request->integer('parent');
$options = match ($field) {
'subcategory' => Subcategory::where('category_id', $parentId)
->orderBy('name')->get(['id as value', 'name as label']),
'region' => Region::where('country_id', $parentId)
->orderBy('name')->get(['id as value', 'name as label']),
default => abort(404),
};
return response()->json($options)->header('Cache-Control', 'private, max-age=300');
}
}
The whitelist (whereIn + match) is not optional — a generic "resolve children for field X" endpoint that accepts arbitrary field names and query params is a field-enumeration and query-injection surface.
Pattern 1 — Livewire (preferred on Laravel). This is the closest analogue to the Symfony form-type/event-listener model, because Livewire keeps the cascade server-side by default:
class CategoryForm extends Component
{
public int $categoryId = 0;
public int $subcategoryId = 0;
public function updatedCategoryId(): void
{
$this->subcategoryId = 0; // critical: reset stale selection
}
public function rules(): array
{
return [
'categoryId' => ['required', 'exists:categories,id'],
'subcategoryId' => [
'required',
Rule::exists('subcategories', 'id')->where('category_id', $this->categoryId),
],
];
}
public function subcategories()
{
return Subcategory::where('category_id', $this->categoryId)->orderBy('name')->get();
}
}
Note the Rule::exists(...)->where('category_id', ...): this is the server-side validation parity the whole pattern hinges on. A client-side cascade that isn't re-validated on submit lets a user POST a subcategoryId belonging to a different category. The Symfony bundle achieves this with a form event listener; in Livewire the validation rule is the enforcement.
Pattern 2 — Alpine + JSON endpoint (for non-Livewire surfaces such as Inertia/React-free Blade pages).
<div x-data="dependentSelect({
parentId: {{ $category->id }},
childValue: {{ old('subcategory_id', 0) }},
endpoint: '/api/options/subcategory'
})">
<select x-model.number="parentId" @change="reload">...</select>
<select name="subcategory_id" x-model.number="childValue" :disabled="loading">
<template x-for="o in options" :key="o.value">
<option :value="o.value" x-text="o.label"></option>
</template>
</select>
<span class="sr-only" aria-live="polite" x-text="statusMessage"></span>
</div>
function dependentSelect({ parentId, childValue, endpoint }) {
return {
parentId, childValue, endpoint, options: [], loading: false, ctrl: null,
statusMessage: '',
init() { if (this.parentId) this.reload(); },
async reload() {
this.childValue = 0; // clear stale child
this.options = [];
this.statusMessage = 'Loading options';
this.ctrl?.abort(); // cancel in-flight
this.ctrl = new AbortController();
this.loading = true;
try {
const res = await fetch(`${this.endpoint}?parent=${this.parentId}`,
{ signal: this.ctrl.signal, headers: { 'Accept': 'application/json' } });
this.options = await res.json();
this.statusMessage = `${this.options.length} options available`;
} catch (e) {
if (e.name !== 'AbortError') this.statusMessage = 'Failed to load options';
} finally { this.loading = false; }
},
};
}
Pattern 3 — Blade partial re-render. For simple admin forms where latency is acceptable, skip JS entirely: post the parent field back, re-render the child <select> server-side, keep the selected child if it still belongs to the parent. Zero JS, fully accessible, works with progressive enhancement. This is the pattern to reach for when the requirement is "single-level, internal tool."
Integration tips that carry over from the bundle's design:
OptionsProvider::forParent(int $id): Collection) injected into your controller or Livewire component, so adding a second cascade is a new provider, not a new endpoint.Cache::remember("subcategories:{$parentId}", 300, ...)) — these are read-mostly and small. The bundle does no caching; per-keystroke fetches against an unindexed repository is the classic failure mode for this class of package.subcategories.category_id) if you're copying the query pattern. Obvious, routinely skipped.[{value, label}].composer require this into Laravel. If you try it and get past resolution with --ignore-platform-reqs, you get a package that appears installed and fails at runtime when nothing registers the bundle. Treat the "successful install" as a false positive, not an environment quirk.childValue = 0 before fetching; the Livewire pattern resets in updatedCategoryId(). Miss this and users submit mismatched parent/child pairs that look valid.Rule::exists()->where(...) or an explicit FormRequest rule). Client-side cascades alone are a data-integrity hole and, with tenant-scoped data, a cross-tenant leak./api/options/region?parent=42 must verify the caller may see country 42's regions. In multi-tenant apps, scope the query by tenant inside the resolver — not in the controller after the fact, where it's easy to forget on a new field.AbortController; drop responses whose parent no longer matches the current value (a classic race where the slower of two requests wins).aria-live="polite" region announcing the new option count, a disabled/loading state on the child, and a keyboard-reachable empty state. Also plan a no-JS fallback (Pattern 3) — the client-side swap silently breaks for screen-reader users otherwise.mapped = false / properly populated by the form event, (b) the route not loading because the bundle's routing resource isn't imported in config/routes.yaml, (c) the JS helper depending on a specific Symfony form theme or jQuery version. Check those three before anything else.OptionsProvider interface (data source), a config map of field → provider (registration), and a form-level event listener or component hook (wiring). Those three seams are what let you add a cascade without touching controllers — the rest of the bundle is glue you can write better.How can I help you explore Laravel packages today?