Skip to content

Address Forms ​

This guide shows how to build complete address forms with cascading dropdowns using Laravel Nusa. We'll cover both backend and frontend implementation with practical examples.

Complete Address Form Example ​

Backend Implementation ​

Controller ​

php
namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Creasi\Nusa\Models\{Province, Regency, District, Village};

class AddressController extends Controller
{
    public function index()
    {
        $provinces = Province::orderBy('name')->get(['code', 'name']);
        
        return view('address.form', compact('provinces'));
    }
    
    public function getRegencies(Request $request)
    {
        $request->validate([
            'province_code' => 'required|string|exists:nusa.provinces,code'
        ]);
        
        $regencies = Regency::where('province_code', $request->province_code)
            ->orderBy('name')
            ->get(['code', 'name']);
            
        return response()->json($regencies);
    }
    
    public function getDistricts(Request $request)
    {
        $request->validate([
            'regency_code' => 'required|string|exists:nusa.regencies,code'
        ]);
        
        $districts = District::where('regency_code', $request->regency_code)
            ->orderBy('name')
            ->get(['code', 'name']);
            
        return response()->json($districts);
    }
    
    public function getVillages(Request $request)
    {
        $request->validate([
            'district_code' => 'required|string|exists:nusa.districts,code'
        ]);
        
        $villages = Village::where('district_code', $request->district_code)
            ->orderBy('name')
            ->get(['code', 'name', 'postal_code']);
            
        return response()->json($villages);
    }
    
    public function store(Request $request)
    {
        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'phone' => 'required|string|max:20',
            'province_code' => 'required|string|exists:nusa.provinces,code',
            'regency_code' => 'required|string|exists:nusa.regencies,code',
            'district_code' => 'required|string|exists:nusa.districts,code',
            'village_code' => 'required|string|exists:nusa.villages,code',
            'address_line' => 'required|string|max:500',
            'postal_code' => 'nullable|string|size:5',
        ]);
        
        // Validate address consistency
        $village = Village::find($validated['village_code']);
        if (!$village || 
            $village->district_code !== $validated['district_code'] ||
            $village->regency_code !== $validated['regency_code'] ||
            $village->province_code !== $validated['province_code']) {
            return back()->withErrors(['address' => 'Address components are inconsistent.']);
        }
        
        // Auto-fill postal code if not provided
        if (!$validated['postal_code']) {
            $validated['postal_code'] = $village->postal_code;
        }
        
        // Store the address (example with user relationship)
        auth()->user()->addresses()->create($validated);
        
        return redirect()->route('address.index')->with('success', 'Address saved successfully!');
    }
}

Routes ​

php
// routes/web.php
use App\Http\Controllers\AddressController;

Route::middleware('auth')->group(function () {
    Route::get('/address', [AddressController::class, 'index'])->name('address.form');
    Route::post('/address', [AddressController::class, 'store'])->name('address.store');
    
    // AJAX endpoints for cascading dropdowns
    Route::get('/api/regencies', [AddressController::class, 'getRegencies']);
    Route::get('/api/districts', [AddressController::class, 'getDistricts']);
    Route::get('/api/villages', [AddressController::class, 'getVillages']);
});

Frontend Implementation ​

Blade Template ​

blade
{{-- resources/views/address/form.blade.php --}}
@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <div class="card">
                <div class="card-header">Add New Address</div>
                
                <div class="card-body">
                    @if ($errors->any())
                        <div class="alert alert-danger">
                            <ul class="mb-0">
                                @foreach ($errors->all() as $error)
                                    <li>{{ $error }}</li>
                                @endforeach
                            </ul>
                        </div>
                    @endif
                    
                    <form method="POST" action="{{ route('address.store') }}" id="addressForm">
                        @csrf
                        
                        <div class="row">
                            <div class="col-md-6">
                                <div class="mb-3">
                                    <label for="name" class="form-label">Full Name</label>
                                    <input type="text" class="form-control @error('name') is-invalid @enderror" 
                                           id="name" name="name" value="{{ old('name') }}" required>
                                    @error('name')
                                        <div class="invalid-feedback">{{ $message }}</div>
                                    @enderror
                                </div>
                            </div>
                            
                            <div class="col-md-6">
                                <div class="mb-3">
                                    <label for="phone" class="form-label">Phone Number</label>
                                    <input type="tel" class="form-control @error('phone') is-invalid @enderror" 
                                           id="phone" name="phone" value="{{ old('phone') }}" required>
                                    @error('phone')
                                        <div class="invalid-feedback">{{ $message }}</div>
                                    @enderror
                                </div>
                            </div>
                        </div>
                        
                        <div class="row">
                            <div class="col-md-6">
                                <div class="mb-3">
                                    <label for="province" class="form-label">Province</label>
                                    <select class="form-select @error('province_code') is-invalid @enderror" 
                                            id="province" name="province_code" required>
                                        <option value="">-- Select Province --</option>
                                        @foreach($provinces as $province)
                                            <option value="{{ $province->code }}" 
                                                    {{ old('province_code') == $province->code ? 'selected' : '' }}>
                                                {{ $province->name }}
                                            </option>
                                        @endforeach
                                    </select>
                                    @error('province_code')
                                        <div class="invalid-feedback">{{ $message }}</div>
                                    @enderror
                                </div>
                            </div>
                            
                            <div class="col-md-6">
                                <div class="mb-3">
                                    <label for="regency" class="form-label">Regency/City</label>
                                    <select class="form-select @error('regency_code') is-invalid @enderror" 
                                            id="regency" name="regency_code" required disabled>
                                        <option value="">-- Select Regency --</option>
                                    </select>
                                    @error('regency_code')
                                        <div class="invalid-feedback">{{ $message }}</div>
                                    @enderror
                                </div>
                            </div>
                        </div>
                        
                        <div class="row">
                            <div class="col-md-6">
                                <div class="mb-3">
                                    <label for="district" class="form-label">District</label>
                                    <select class="form-select @error('district_code') is-invalid @enderror" 
                                            id="district" name="district_code" required disabled>
                                        <option value="">-- Select District --</option>
                                    </select>
                                    @error('district_code')
                                        <div class="invalid-feedback">{{ $message }}</div>
                                    @enderror
                                </div>
                            </div>
                            
                            <div class="col-md-6">
                                <div class="mb-3">
                                    <label for="village" class="form-label">Village</label>
                                    <select class="form-select @error('village_code') is-invalid @enderror" 
                                            id="village" name="village_code" required disabled>
                                        <option value="">-- Select Village --</option>
                                    </select>
                                    @error('village_code')
                                        <div class="invalid-feedback">{{ $message }}</div>
                                    @enderror
                                </div>
                            </div>
                        </div>
                        
                        <div class="mb-3">
                            <label for="address_line" class="form-label">Address Line</label>
                            <textarea class="form-control @error('address_line') is-invalid @enderror" 
                                      id="address_line" name="address_line" rows="3" required 
                                      placeholder="Street address, building number, etc.">{{ old('address_line') }}</textarea>
                            @error('address_line')
                                <div class="invalid-feedback">{{ $message }}</div>
                            @enderror
                        </div>
                        
                        <div class="row">
                            <div class="col-md-6">
                                <div class="mb-3">
                                    <label for="postal_code" class="form-label">Postal Code</label>
                                    <input type="text" class="form-control @error('postal_code') is-invalid @enderror" 
                                           id="postal_code" name="postal_code" value="{{ old('postal_code') }}" 
                                           maxlength="5" placeholder="Auto-filled from village">
                                    @error('postal_code')
                                        <div class="invalid-feedback">{{ $message }}</div>
                                    @enderror
                                    <div class="form-text">Will be auto-filled when you select a village</div>
                                </div>
                            </div>
                        </div>
                        
                        <div class="d-grid">
                            <button type="submit" class="btn btn-primary">Save Address</button>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
@endsection

@push('scripts')
<script src="{{ asset('js/address-form.js') }}"></script>
@endpush

JavaScript Implementation ​

js
// public/js/address-form.js
class AddressForm {
    constructor() {
        this.provinceSelect = document.getElementById('province');
        this.regencySelect = document.getElementById('regency');
        this.districtSelect = document.getElementById('district');
        this.villageSelect = document.getElementById('village');
        this.postalCodeInput = document.getElementById('postal_code');
        
        this.loadingClass = 'loading';
        this.init();
    }
    
    init() {
        this.bindEvents();
        
        // If there are old values (validation errors), restore the form state
        if (this.provinceSelect.value) {
            this.loadRegencies(this.provinceSelect.value, () => {
                if (this.regencySelect.value) {
                    this.loadDistricts(this.regencySelect.value, () => {
                        if (this.districtSelect.value) {
                            this.loadVillages(this.districtSelect.value);
                        }
                    });
                }
            });
        }
    }
    
    bindEvents() {
        this.provinceSelect.addEventListener('change', (e) => {
            const provinceCode = e.target.value;
            this.resetDependentSelects(['regency', 'district', 'village']);
            
            if (provinceCode) {
                this.loadRegencies(provinceCode);
            }
        });
        
        this.regencySelect.addEventListener('change', (e) => {
            const regencyCode = e.target.value;
            this.resetDependentSelects(['district', 'village']);
            
            if (regencyCode) {
                this.loadDistricts(regencyCode);
            }
        });
        
        this.districtSelect.addEventListener('change', (e) => {
            const districtCode = e.target.value;
            this.resetDependentSelects(['village']);
            
            if (districtCode) {
                this.loadVillages(districtCode);
            }
        });
        
        this.villageSelect.addEventListener('change', (e) => {
            const selectedOption = e.target.selectedOptions[0];
            if (selectedOption && selectedOption.dataset.postalCode) {
                this.postalCodeInput.value = selectedOption.dataset.postalCode;
            }
        });
    }
    
    async loadRegencies(provinceCode, callback = null) {
        try {
            this.setLoading(this.regencySelect, true);
            
            const response = await fetch(`/api/regencies?province_code=${provinceCode}`);
            if (!response.ok) throw new Error('Failed to load regencies');
            
            const regencies = await response.json();
            this.populateSelect(this.regencySelect, regencies, 'Select Regency');
            this.regencySelect.disabled = false;
            
            if (callback) callback();
        } catch (error) {
            console.error('Error loading regencies:', error);
            this.showError('Failed to load regencies. Please try again.');
        } finally {
            this.setLoading(this.regencySelect, false);
        }
    }
    
    async loadDistricts(regencyCode, callback = null) {
        try {
            this.setLoading(this.districtSelect, true);
            
            const response = await fetch(`/api/districts?regency_code=${regencyCode}`);
            if (!response.ok) throw new Error('Failed to load districts');
            
            const districts = await response.json();
            this.populateSelect(this.districtSelect, districts, 'Select District');
            this.districtSelect.disabled = false;
            
            if (callback) callback();
        } catch (error) {
            console.error('Error loading districts:', error);
            this.showError('Failed to load districts. Please try again.');
        } finally {
            this.setLoading(this.districtSelect, false);
        }
    }
    
    async loadVillages(districtCode, callback = null) {
        try {
            this.setLoading(this.villageSelect, true);
            
            const response = await fetch(`/api/villages?district_code=${districtCode}`);
            if (!response.ok) throw new Error('Failed to load villages');
            
            const villages = await response.json();
            this.populateSelect(this.villageSelect, villages, 'Select Village', true);
            this.villageSelect.disabled = false;
            
            if (callback) callback();
        } catch (error) {
            console.error('Error loading villages:', error);
            this.showError('Failed to load villages. Please try again.');
        } finally {
            this.setLoading(this.villageSelect, false);
        }
    }
    
    populateSelect(select, options, placeholder, includePostalCode = false) {
        // Clear existing options
        select.innerHTML = `<option value="">${placeholder}</option>`;
        
        // Add new options
        options.forEach(option => {
            const optionElement = document.createElement('option');
            optionElement.value = option.code;
            optionElement.textContent = option.name;
            
            if (includePostalCode && option.postal_code) {
                optionElement.textContent += ` (${option.postal_code})`;
                optionElement.dataset.postalCode = option.postal_code;
            }
            
            // Restore selected value if it exists
            if (select.dataset.oldValue === option.code) {
                optionElement.selected = true;
            }
            
            select.appendChild(optionElement);
        });
    }
    
    resetDependentSelects(selectNames) {
        selectNames.forEach(name => {
            const select = document.getElementById(name);
            select.innerHTML = `<option value="">-- Select ${this.capitalize(name)} --</option>`;
            select.disabled = true;
        });
        
        // Clear postal code if village is reset
        if (selectNames.includes('village')) {
            this.postalCodeInput.value = '';
        }
    }
    
    setLoading(select, isLoading) {
        if (isLoading) {
            select.classList.add(this.loadingClass);
            select.disabled = true;
        } else {
            select.classList.remove(this.loadingClass);
        }
    }
    
    showError(message) {
        // You can implement a toast notification or alert here
        alert(message);
    }
    
    capitalize(str) {
        return str.charAt(0).toUpperCase() + str.slice(1);
    }
}

// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
    new AddressForm();
});

Vue.js Implementation ​

For modern frontend frameworks, here's a Vue.js component:

vue
<template>
  <form @submit.prevent="submitForm" class="address-form">
    <div class="form-group">
      <label for="province">Province</label>
      <select 
        id="province" 
        v-model="form.province_code" 
        @change="onProvinceChange"
        :disabled="loading.provinces"
        required
      >
        <option value="">-- Select Province --</option>
        <option 
          v-for="province in provinces" 
          :key="province.code" 
          :value="province.code"
        >
          {{ province.name }}
        </option>
      </select>
    </div>
    
    <div class="form-group">
      <label for="regency">Regency/City</label>
      <select 
        id="regency" 
        v-model="form.regency_code" 
        @change="onRegencyChange"
        :disabled="!form.province_code || loading.regencies"
        required
      >
        <option value="">-- Select Regency --</option>
        <option 
          v-for="regency in regencies" 
          :key="regency.code" 
          :value="regency.code"
        >
          {{ regency.name }}
        </option>
      </select>
    </div>
    
    <div class="form-group">
      <label for="district">District</label>
      <select 
        id="district" 
        v-model="form.district_code" 
        @change="onDistrictChange"
        :disabled="!form.regency_code || loading.districts"
        required
      >
        <option value="">-- Select District --</option>
        <option 
          v-for="district in districts" 
          :key="district.code" 
          :value="district.code"
        >
          {{ district.name }}
        </option>
      </select>
    </div>
    
    <div class="form-group">
      <label for="village">Village</label>
      <select 
        id="village" 
        v-model="form.village_code" 
        @change="onVillageChange"
        :disabled="!form.district_code || loading.villages"
        required
      >
        <option value="">-- Select Village --</option>
        <option 
          v-for="village in villages" 
          :key="village.code" 
          :value="village.code"
          :data-postal-code="village.postal_code"
        >
          {{ village.name }} {{ village.postal_code ? `(${village.postal_code})` : '' }}
        </option>
      </select>
    </div>
    
    <div class="form-group">
      <label for="postal_code">Postal Code</label>
      <input 
        id="postal_code" 
        v-model="form.postal_code" 
        type="text" 
        maxlength="5"
        placeholder="Auto-filled from village"
      >
    </div>
    
    <button type="submit" :disabled="submitting">
      {{ submitting ? 'Saving...' : 'Save Address' }}
    </button>
  </form>
</template>

<script>
export default {
  name: 'AddressForm',
  data() {
    return {
      form: {
        province_code: '',
        regency_code: '',
        district_code: '',
        village_code: '',
        postal_code: ''
      },
      provinces: [],
      regencies: [],
      districts: [],
      villages: [],
      loading: {
        provinces: false,
        regencies: false,
        districts: false,
        villages: false
      },
      submitting: false
    }
  },
  async mounted() {
    await this.loadProvinces();
  },
  methods: {
    async loadProvinces() {
      this.loading.provinces = true;
      try {
        const response = await fetch('/nusa/provinces');
        const data = await response.json();
        this.provinces = data.data;
      } catch (error) {
        console.error('Error loading provinces:', error);
      } finally {
        this.loading.provinces = false;
      }
    },
    
    async onProvinceChange() {
      this.resetDependentData(['regencies', 'districts', 'villages']);
      this.form.regency_code = '';
      this.form.district_code = '';
      this.form.village_code = '';
      this.form.postal_code = '';
      
      if (this.form.province_code) {
        await this.loadRegencies();
      }
    },
    
    async loadRegencies() {
      this.loading.regencies = true;
      try {
        const response = await fetch(`/nusa/provinces/${this.form.province_code}/regencies`);
        const data = await response.json();
        this.regencies = data.data;
      } catch (error) {
        console.error('Error loading regencies:', error);
      } finally {
        this.loading.regencies = false;
      }
    },
    
    async onRegencyChange() {
      this.resetDependentData(['districts', 'villages']);
      this.form.district_code = '';
      this.form.village_code = '';
      this.form.postal_code = '';
      
      if (this.form.regency_code) {
        await this.loadDistricts();
      }
    },
    
    async loadDistricts() {
      this.loading.districts = true;
      try {
        const response = await fetch(`/nusa/regencies/${this.form.regency_code}/districts`);
        const data = await response.json();
        this.districts = data.data;
      } catch (error) {
        console.error('Error loading districts:', error);
      } finally {
        this.loading.districts = false;
      }
    },
    
    async onDistrictChange() {
      this.resetDependentData(['villages']);
      this.form.village_code = '';
      this.form.postal_code = '';
      
      if (this.form.district_code) {
        await this.loadVillages();
      }
    },
    
    async loadVillages() {
      this.loading.villages = true;
      try {
        const response = await fetch(`/nusa/districts/${this.form.district_code}/villages`);
        const data = await response.json();
        this.villages = data.data;
      } catch (error) {
        console.error('Error loading villages:', error);
      } finally {
        this.loading.villages = false;
      }
    },
    
    onVillageChange() {
      const selectedVillage = this.villages.find(v => v.code === this.form.village_code);
      if (selectedVillage && selectedVillage.postal_code) {
        this.form.postal_code = selectedVillage.postal_code;
      }
    },
    
    resetDependentData(arrays) {
      arrays.forEach(array => {
        this[array] = [];
      });
    },
    
    async submitForm() {
      this.submitting = true;
      try {
        const response = await fetch('/address', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
          },
          body: JSON.stringify(this.form)
        });
        
        if (response.ok) {
          this.$emit('address-saved');
          this.resetForm();
        } else {
          throw new Error('Failed to save address');
        }
      } catch (error) {
        console.error('Error saving address:', error);
        alert('Failed to save address. Please try again.');
      } finally {
        this.submitting = false;
      }
    },
    
    resetForm() {
      this.form = {
        province_code: '',
        regency_code: '',
        district_code: '',
        village_code: '',
        postal_code: ''
      };
      this.resetDependentData(['regencies', 'districts', 'villages']);
    }
  }
}
</script>

This comprehensive address form implementation provides:

  • Cascading dropdowns that load data dynamically
  • Validation on both frontend and backend
  • Error handling with user-friendly messages
  • Auto-completion of postal codes
  • Performance optimization with loading states
  • Accessibility with proper labels and ARIA attributes
  • Framework flexibility with both vanilla JS and Vue.js examples

The form ensures data consistency by validating that all address components belong to the correct hierarchy and provides a smooth user experience with proper loading states and error handling.

Released under the MIT License.