soap/thai-addresses
Laravel package providing complete Thai address data — provinces, districts, subdistricts, and postal codes — with seeders, models, and lookup helpers. Easily import and query hierarchical Thai geographic data for forms, validation, and address autocomplete in PHP apps.
Install the package and publish its config/migrations/seeders:
composer require soap/thai-addresses
php artisan vendor:publish --provider="Soap\ThaiAddresses\ThaiAddressesServiceProvider"
Seed the reference dataset (provinces → districts/amphoes → subdistricts/tambons → postal codes):
php artisan migrate
php artisan db:seed --class="Soap\ThaiAddresses\Database\Seeders\ThaiAddressesSeeder"
Where to look first:
config/thai-addresses.php — table names, route registration, middleware. Read this before running migrations; table names are cheap to change now and expensive later.name_th vs name_en), nullable flags, and whether indexes exist.Province, District/Amphure, Subdistrict/Tambon) — these are what you query. Note the naming inconsistencies between District/Amphure and Subdistrict/Tambon; both spellings appear in Thai address literature and in this package's history.First use case: cascading dropdowns on a checkout/registration form — province → district → subdistrict — deriving the postal code from the selected subdistrict. This covers the large majority of real usage and is the fastest way to sanity-check that the seed actually populated what you expect.
Quick smoke test after seeding:
Province::count(); // expect ~77
Subdistrict::count(); // expect thousands
Subdistrict::where('postal_code', '10110')->first(); // known Bangkok zip
If the counts are zero, the seeder didn't run — check the class namespace against what's actually in database/seeders after publishing.
Cascading selects — built-in API vs. your own controller. The package can register /api/provinces, /api/districts/{provinceId}, /api/subdistricts/{districtId}. Use them for prototypes and internal tools. For production, prefer your own thin controller so you control caching headers, auth, throttling, and response shape (your frontend probably wants {id, label} not the raw model).
Eloquent for server-side forms. Query the package models directly:
$provinces = Province::orderBy('name_th')->get();
$districts = District::where('province_id', $provinceId)->orderBy('name_th')->get();
$subdistricts = Subdistrict::where('district_id', $districtId)->get(['id', 'name_th', 'postal_code']);
Eager-load relations when rendering address tables (->with('province', 'district')) or you'll N+1 across every row.
Store IDs, derive the postal code. Persist province_id, district_id, subdistrict_id on your address model and treat postal_code as a denormalized cache of the subdistrict's zip. That survives package data updates, lets you re-derive zips after a re-seed, and makes the address verifiable later.
Schema::create('addresses', function (Blueprint $table) {
$table->id();
$table->foreignId('province_id')->constrained('provinces');
$table->foreignId('district_id')->constrained('amphures');
$table->foreignId('subdistrict_id')->constrained('tambons');
$table->string('postal_code', 5);
// ...
});
Match the constrained() table names to whatever you configured before migrating.
Validate the hierarchy server-side. The client cascade is a convenience, not a guarantee. On submit, assert district.province_id === $provinceId and subdistrict.district_id === $districtId, then read the postal code from the selected subdistrict row rather than trusting a posted value.
Form Request with exists rules. Cheap and effective:
'province_id' => ['required', 'exists:provinces,id'],
'district_id' => ['required', 'exists:amphures,id'],
'subdistrict_id' => ['required', 'exists:tambons,id'],
Then do the parent-child relationship check in a withValidator or a dedicated rule.
Cache the static reference data. Provinces and districts almost never change; cache them indefinitely and bust on re-seed. Subdistricts are the big table — cache per-district lists rather than the whole thing if memory is tight, or the whole thing with a long TTL if you're serving dropdowns globally.
$provinces = Cache::rememberForever('thai.provinces', fn () => Province::orderBy('name_th')->get());
Wire cache invalidation into whatever deploy step runs the seeder.
Scheduled data refresh. Thai administrative boundaries change occasionally — subdistricts split, districts get reassigned. Put the seeder behind a scheduled command in a low-traffic window, or version your own snapshot and re-seed only on deploy. Either way, don't let the data silently drift for years.
Testing. Don't run the full seeder in tests — thousands of inserts per test is a slow CI suite. Insert a handful of known rows in a fixture or setUp, and test your cascade validation logic, not the package's dataset.
The seeder is large. Thousands of subdistrict rows. Wrap it in a transaction, consider DB::disableForeignKeyConstraints() around the run, and on shared hosting be ready to bump max_execution_time or chunk it. Run it once in staging and time it before committing to a deploy hook.
Rename tables before migrating, not after. If provinces/districts/subdistricts collide with existing tables, fix it in config/thai-addresses.php first. Renaming later means editing FKs, model $table properties, and every query that references them.
Check route registration. If the package auto-registers /api/* routes, disable them in production when you expose your own. Duplicate or unexpected routes show up in route:list and are easy to miss until a frontend request hits the wrong handler.
Thai vs. English columns vary. The dataset generally ships name_th and name_en, but don't assume both exist on every model or migration version — read the published migration, not the README. Render name_th for Thai users, name_en for exports and integrations.
Postal codes aren't strictly 1:1 with subdistricts. A minority of tambons share a zip across districts, and some large subdistricts span multiple zips. Always read the chosen row's postal_code rather than computing it.
Don't --force publish over edited migrations. Track package migrations as read-only. Add your own follow-up migrations for anything you need (indexes, extra columns, FK adjustments) so upgrades stay clean.
Add indexes yourself. The shipped schema may lack indexes on province_id, district_id, and postal_code. Cascading dropdown queries hit those columns constantly — add them in your own migration immediately after the package's.
Schema::table('amphures', fn (Blueprint $t) => $t->index('province_id'));
Schema::table('tambons', fn (Blueprint $t) => $t->index(['district_id', 'postal_code']));
Debugging a wrong dropdown: run the raw query (District::where('province_id', $id)->toSql() + bindings) before blaming the package. Nine times out of ten it's a mismatched ID type (string vs. int from a form post) or a province_id referencing a province from a different dataset you seeded earlier.
Extension points are limited by design. The models are plain Eloquent classes — extend them if you need scopes or accessors, but be aware package updates can change the base class's schema assumptions. Prefer composition (a repository/service around the models) over subclassing when you need custom behavior you'll maintain long-term.
Upgrading v3.1.1 → v3.2.0. Semver-minor, and the visible changes are Dependabot bumps and a README edit — no breaking changes signalled. But the changelog does not say whether the address dataset itself changed. If data currency matters to you: diff the seed files (vendor/soap/thai-addresses/database/seeders) against the previous version, spot-check a few known postal codes in staging, and re-run the seeder only if the data moved. If you've layered custom data on top, diff before re-seeding.
Single-maintainer risk. Four stars, one author. If this becomes business-critical, mirror the seed data into your own repo or a private package so a stalled upstream doesn't block you. Pin the version in composer.json and upgrade deliberately.
Scope check. This is administrative hierarchy + postal codes. No lat/long, no polygons, no geocoding. If you need maps or distance-based shipping, this is a lookup table alongside a separate geo provider — not a replacement for one.
How can I help you explore Laravel packages today?