creasi/laravel-nusa
creasi/laravel-nusa brings Indonesia's administrative region data into Laravel apps with ready-to-use Eloquent models, migrations, and seeders for provinces, regencies, districts, and villages, making reliable local location data quick and easy to add.
Install and publish only what you need
composer require creasi/laravel-nusa
php artisan vendor:publish --tag=creasi-config # config/nusa.php -> config/creasi/nusa.php
php artisan vendor:publish --tag=creasi-migrations # optional address tables
php artisan vendor:publish --tag=creasi-lang # optional translations
The package auto-registers a separate SQLite connection named nusa pointing at the pre-compiled database/nusa.sqlite shipped in the package. There is no import, migration, or seed step required to start querying — that is the main reason to pick this over DIY region tables.
Where to look first
config/nusa.php (published to config/creasi/nusa.php) — connection name, route prefix, and any table/model overrides.routes/nusa.php — the HTTP API is loaded automatically under the nusa prefix; read this before exposing anything.src/Models — Province, Regency, District, Village with the hierarchy wired up.resources/static — pre-generated CSV/JSON/GeoJSON if you'd rather ship data to the frontend than query.id/) — better than the README for relation and API details.First use case: a cascading address picker
use Creasi\Nusa\Models\Province; // verify namespace against src/Models
$provinces = Province::orderBy('name')->get(['code', 'name']);
// code is the string primary key, never an auto-increment id
$regencies = Province::find('32')->regencies()->orderBy('name')->get(['code', 'name']);
The models are read-only reference data: string code primary keys, non-incrementing, no timestamps. Route model binding already resolves by code, so Route::get('/regency/{regency}', ...) works without a getRouteKeyName() override.
A quick sanity check that the connection is live:
php artisan tinker
>>> DB::connection('nusa')->table('provinces')->count();
If that fails, the SQLite file isn't readable from your deployment path or the connection config wasn't published — start there, not with model code.
Reference data, joined from your own tables
Store the code values on your own models (province_code, regency_code, district_code, village_code) and relate across the connection boundary:
// app/Models/Address.php
public function province()
{
return $this->belongsTo(\Creasi\Nusa\Models\Province::class, 'province_code', 'code');
}
Store codes, not names. Region names change (renames and pemekaran splits happen upstream), and codes are the join key the package guarantees. Denormalise a display name only as a cache you're willing to rebuild.
Validation with cross-connection exists
Laravel's exists rule accepts connection.table, which is exactly what a separate SQLite connection needs:
'province_code' => ['required', 'string', Rule::exists('nusa.provinces', 'code')],
'regency_code' => ['required', 'string', Rule::exists('nusa.regencies', 'code')],
Pair this with a Rule::exists('nusa.regencies', 'code')->where('province_code', $request->province_code) so a client can't submit a mismatched province/regency pair. Never trust the browser cascade alone.
Dropdowns: cache the whole tree, don't query per keystroke
Provinces/regencies/districts change on the order of months, not seconds. Load them once into cache as plain arrays and serve autocomplete from that:
$regions = Cache::rememberForever('nusa.regencies', fn () =>
Regency::orderBy('name')->get(['code', 'province_code', 'name'])->toArray()
);
Invalidate on deploy if you refresh the underlying data. This also sidesteps SQLite's single-writer/limited-concurrency behaviour under load.
Two integration surfaces — choose deliberately
/nusa/...): useful for a separate frontend or a non-PHP service. Check routes/nusa.php for the exact endpoints and, importantly, the middleware stack before you ship it.nusa:generate-static command if you're working inside the package repo; otherwise consume the published files.A common split: server-side validation via Eloquent, client-side pickers via the static JSON, and the API only for ad-hoc lookups.
Coordinates and maps
Records carry coordinates, which makes the models useful for distance sorting and map centring without a second geocoding service. If you don't need them, the package's nusa:dist command produces a distribution database without coordinates — smaller and faster to ship, but it permanently removes that capability from the copy, so decide early.
Testing
Point tests at fixtures or a cached copy rather than hammering the packaged SQLite file. Guard against RefreshDatabase-style resets targeting the nusa connection — you want to migrate your schema, not rebuild reference data you don't own. A small helper that returns a fixed province/regency pair keeps feature tests fast and independent of upstream data churn.
Upgrades
Pin the version. The package targets PHP ≥ 8.2 and Laravel 9–13, and with ~101 stars and zero dependents you are effectively the integration test. Read the changelog for data refreshes, since a routine release can move codes you've stored.
Two connections means no real foreign keys. You cannot add a database-level FOREIGN KEY from your addresses table to nusa.regencies — SQLite (and cross-connection constraints generally) won't enforce it. Integrity is entirely on your validation rules and application code. Write a test that asserts every stored regency_code still resolves after a data refresh.
Transactions don't span connections. A DB::transaction() on your default connection won't roll back anything touching nusa. That's fine because you should never write to the reference tables, but it matters if you build an import/backfill job that reads and writes in the same loop.
Config path trap. Publishing moves config/nusa.php to config/creasi/nusa.php. Depending on how the service provider registers it, you may be reading config('creasi.nusa.*') rather than config('nusa.*'). Check the published file and the provider before debugging "my config change does nothing" — this is the single most likely time-waster in the first hour.
code is a string, and JavaScript will ruin it. Province/regency codes with leading zeros passed through parseInt, numeric form inputs, or a JSON schema that infers integers will silently break lookups. Keep them as strings end to end, and use string columns in your migrations. Same applies to route model binding: find(32) won't match '32' reliably depending on driver coercion — always pass strings.
Don't run the maintainer commands in your app. composer upstream:up, nusa:import, and the Docker workflow documented in AGENTS.md are for contributing to the package, not for consuming it. The database is already compiled. Reaching for those on a fresh install is the most common onboarding mistake; they require the package repo and a Docker daemon.
Decide about the published address migrations. The creasi-migrations tag creates address tables in your database. Review them before running — if you already have an addresses table, or you want extra columns (label, recipient, geohash), skip the publish and model your own schema around the code columns. The reference data itself is not created by migrations.
Route prefix collisions and exposure. Routes load automatically under nusa. If you already own that prefix, or you don't want a public unauthenticated region API, adjust it in config or exclude the routes. Run php artisan route:list | grep nusa after install and again after route:cache — automatic route loading is easy to forget when auditing your public surface.
Read-only discipline. Treat nusa.* as immutable. Any attempt to seed, patch, or "fix" a region locally will be blown away on the next package update and will diverge from upstream codes. Corrections belong upstream (the data comes from cahyadsn's wilayah and postal-code sources), or in your own overlay table.
Deployment and file paths. The SQLite file lives under the package's database/ directory. Ensure it's readable by the PHP user, isn't excluded by a vendor pruning step in your deploy, and isn't on a filesystem that blocks SQLite locking (some network mounts). For high-read production traffic, prefer cached arrays over live queries.
Debugging checklist, in order: DB::connection('nusa')->getPdo() → table count → config('database.connections.nusa') → published config key. Coordinates present? Confirm you didn't deploy the nusa:dist variant. Empty relations? Check you're filtering by code, not id.
Extension points are limited by design. You get config, routes, lang, and optional migrations — not a documented model-swap or repository contract. If you need custom behaviour (search ranking, alternate labels, caching decorators), wrap the models in your own service class rather than extending them; that keeps you insulated if internal namespaces shift between releases.
How can I help you explore Laravel packages today?