Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Dependent Select Bundle Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. Open 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.
  2. Check the license before you copy a single line into your codebase. The realistic outcome here is code reuse as a template, so the license is the operative question, not the runtime.
  3. Read the three files that matter: the form type (or form event listener) that populates the child field, the controller that serves the AJAX response, and the JS helper that swaps options. That trio is the package.
  4. Confirm the Doctrine coupling. If the bundle resolves child options via a configured entity repository, that logic is Eloquent-incompatible and needs rewriting regardless of the framework question.

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.

Implementation Patterns

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:

  • Mirror the "data provider" abstraction, not the Doctrine implementation. The bundle's value is separating option resolution from form wiring. In Laravel, that's a small interface (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.
  • Whitelist field → query mappings in a match/config array, never accept the entity or column from the request.
  • Cache the option sets (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.
  • Index the foreign key (subcategories.category_id) if you're copying the query pattern. Obvious, routinely skipped.
  • Keep the AJAX controller thin — a form type/component should own validation, the endpoint should only return [{value, label}].

Gotchas and Tips

  • Do not 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.
  • Stale child selections are the #1 user-visible bug. Every implementation must reset (or re-validate) the child value when the parent changes. The Alpine pattern above sets childValue = 0 before fetching; the Livewire pattern resets in updatedCategoryId(). Miss this and users submit mismatched parent/child pairs that look valid.
  • Server-side validation is non-negotiable. Whatever you copy from the bundle, the child field must be re-validated on submit against the parent (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.
  • Authorize the endpoint, not just the page. /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.
  • Request debouncing and in-flight cancellation matter on any keystroke-driven variant. Use AbortController; drop responses whose parent no longer matches the current value (a classic race where the slower of two requests wins).
  • Accessibility is not solved by the bundle's JS helper. Dependent selects need an 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.
  • Beyond two levels the cost is yours, not the package's. Country → state → city → neighbourhood, fan-out to multiple children, and searchable/typeahead selects with thousands of rows are all outside what a minimal bundle like this handles. Budget the effort separately; don't assume the pattern generalizes for free.
  • Debugging the original bundle (if you're running it inside an actual Symfony app): the usual suspects are (a) the child field not being 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.
  • Extension points to replicate if you keep the abstraction: an 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.
  • Provenance discipline: 0 stars / 0 dependents means no community bug reports, no compatibility testing against current PHP/Symfony, and no security feed. If you vendor or copy any code, it must pass the same static analysis, CS, and test gates as first-party code — don't exempt a snippet because it arrived via Composer.
  • Document the decision. Add a line to your dependency-governance record noting the evaluation and the framework-mismatch rationale, so the same package doesn't get re-proposed for the next cascading-select ticket.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings