Muhammad Yasir Amin

Loading websiteโ€ฆ

More from Muhammad Yasir Amin

Build Along With Me

Practical walkthroughs on Laravel, React, APIs and deployment workflows.

Subscribe on YouTube

Laravel 13 Dependent Dropdown with JavaScript Fetch: Country, State and City

๐Ÿ› ๏ธ Laravel Tutorials5 min read

Laravel 13 Dependent Dropdown with JavaScript Fetch: Country, State and City

Muhammad Yasir Amin

Muhammad Yasir Amin

Senior Full Stack Engineerโ€ขNov 12, 2026
Laravel 13 Dependent Dropdown with JavaScript Fetch: Country, State and City

A dependent dropdown (also called a cascading select) loads the options of one select box based on the value chosen in another, for example states after a country. In Laravel 13 you build it with a JSON endpoint per level and a few lines of JavaScript using the Fetch API, with no jQuery needed. This tutorial builds a country, state and city form, including server-side validation that checks the chosen state really belongs to the chosen country.

Targets: Laravel 13 (PHP 8.3 to 8.5), Blade and plain JavaScript.

Step 1: Create the models and migrations

Go
php artisan make:model Country -m
php artisan make:model State -m
php artisan make:model City -m
PHP
// countries
$table->id();
$table->string('name');
$table->timestamps();

// states
$table->id();
$table->foreignId('country_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->timestamps();

// cities
$table->id();
$table->foreignId('state_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->timestamps();

Put each snippet inside the up() method of its own migration, in the Schema::create callback. Add protected $fillable = ['name']; (and the foreign key for states and cities) to the models, then run php artisan migrate.

Step 2: Add sample data

Code
php artisan tinker
JavaScript
$pk = App\Models\Country::create(['name' => 'Pakistan']);
$punjab = $pk->states()->create(['name' => 'Punjab']);
$punjab->cities()->createMany([['name' => 'Lahore'], ['name' => 'Multan']]);

For that to work, define the relationships: Country::states() as hasMany(State::class) and State::cities() as hasMany(City::class), and set $fillable to include country_id and state_id where needed.

Step 3: Create the JSON routes

Ruby
use App\Http\Controllers\LocationController;
use Illuminate\Support\Facades\Route;

Route::get('/signup', [LocationController::class, 'form']);
Route::post('/signup', [LocationController::class, 'store'])->name('signup.store');
Route::get('/countries/{country}/states', [LocationController::class, 'states']);
Route::get('/states/{state}/cities', [LocationController::class, 'cities']);

Step 4: Write the controller

JavaScript
<?php

namespace App\Http\Controllers;

use App\Models\City;
use App\Models\Country;
use App\Models\State;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;

class LocationController extends Controller
{
    public function form(): View
    {
        return view('signup', ['countries' => Country::orderBy('name')->get(['id', 'name'])]);
    }

    public function states(Country $country): JsonResponse
    {
        return response()->json($country->states()->orderBy('name')->get(['id', 'name']));
    }

    public function cities(State $state): JsonResponse
    {
        return response()->json($state->cities()->orderBy('name')->get(['id', 'name']));
    }

    public function store(Request $request): RedirectResponse
    {
        $data = $request->validate([
            'country_id' => ['required', 'exists:countries,id'],
            'state_id' => [
                'required',
                Rule::exists('states', 'id')->where('country_id', $request->input('country_id')),
            ],
            'city_id' => [
                'required',
                Rule::exists('cities', 'id')->where('state_id', $request->input('state_id')),
            ],
        ]);

        // Save $data wherever it belongs, for example on the user's profile.

        return back()->with('status', 'Location saved.');
    }
}

The Rule::exists(...)->where(...) lines are the important part. JavaScript only improves the user experience; a user can still send any ID. The server must verify that the state belongs to the country and the city belongs to the state.

Step 5: Build the Blade form

Shell
<form method="POST" action="{{ route('signup.store') }}">
    @csrf

    <select name="country_id" id="country">
        <option value="">Select country</option>
        @foreach ($countries as $country)
            <option value="{{ $country->id }}">{{ $country->name }}</option>
        @endforeach
    </select>

    <select name="state_id" id="state" disabled>
        <option value="">Select state</option>
    </select>

    <select name="city_id" id="city" disabled>
        <option value="">Select city</option>
    </select>

    <button type="submit">Save</button>
</form>

Step 6: Add the JavaScript

This script goes at the bottom of the same view (or in a file loaded with Vite). It fills the next select when the previous one changes and resets the selects below it:

JavaScript
async function fillSelect(select, url, placeholder) {
    select.innerHTML = '<option value="">' + placeholder + '</option>';
    select.disabled = true;

    if (!url) return;

    const response = await fetch(url, { headers: { 'Accept': 'application/json' } });
    const items = await response.json();

    items.forEach((item) => {
        const option = document.createElement('option');
        option.value = item.id;
        option.textContent = item.name;
        select.appendChild(option);
    });

    select.disabled = false;
}

const country = document.getElementById('country');
const state = document.getElementById('state');
const city = document.getElementById('city');

country.addEventListener('change', () => {
    fillSelect(city, null, 'Select city');
    fillSelect(state, country.value ? '/countries/' + country.value + '/states' : null, 'Select state');
});

state.addEventListener('change', () => {
    fillSelect(city, state.value ? '/states/' + state.value + '/cities' : null, 'Select city');
});

The script builds options with textContent, not innerHTML, so names from the database cannot inject HTML.

How do I check that it works?

  1. Open /signup and choose a country. The state select should enable and show its states.
  2. Choose a state. The city select should fill.
  3. Change the country again. State and city should reset.
  4. Edit the form HTML in the browser to send a mismatched state ID. The server should return a validation error.

Common mistakes

  • Trusting the browser: always validate the parent-child match on the server.
  • Not resetting child selects: when the country changes, clear both the state and city selects.
  • Using innerHTML with database values: build options with DOM methods.
  • Forgetting the Accept header: it makes error responses JSON-friendly.
  • Loading all data at once: for large lists load only the needed level, as shown.

FAQ

Can I do this with Livewire instead?

Yes. Livewire can update the child options in a PHP component without custom JavaScript. The server-side validation rules stay the same.

How do I keep the selection after a validation error?

Pass the old values into the page with old('country_id') and load the dependent options on page load for those values.

Do I need jQuery?

No. The Fetch API in modern browsers covers this use case.

References

Article Index & Topic Tags
LaravelJavascriptDropdownAjax

YouTube Channel

Build Along With Me

Practical walkthroughs on Laravel, React, APIs and deployment workflows.

Muhammad Yasir Amin
Available for Contracts
Author & Solutions Architect

Senior Full Stack Engineer

Independent Senior Full Stack Engineer working on Laravel, React and Next.js applications, APIs and delivery workflows.

Laravel & PHP ApplicationsReact & Next.js InterfacesREST APIs & IntegrationsDatabase Queries & DebuggingDocker Development WorkflowsClear Scope & Handover
Discuss Your Next Step

What Does Your Application Need Next?

Share your current system, the problem you want to solve and what a useful result looks like. We can identify the right starting point and agree on a practical scope.

Technical Audit

Find the Next Useful Fix

Application Development

Scoped, Reviewable Milestones

Ongoing Support

Agreed Capacity and Priorities

Share your goals, current stack and requirements before the call.