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
php artisan make:model Country -m
php artisan make:model State -m
php artisan make:model City -m// 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
php artisan tinker$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
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
<?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
<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:
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?
- Open
/signupand choose a country. The state select should enable and show its states. - Choose a state. The city select should fill.
- Change the country again. State and city should reset.
- 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.



