Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Thai Addresses Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. config/thai-addresses.php — table names, route registration, middleware. Read this before running migrations; table names are cheap to change now and expensive later.
  2. The published migrations — this is the actual schema contract. Check column names (name_th vs name_en), nullable flags, and whether indexes exist.
  3. The package models (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.

Implementation Patterns

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.

Gotchas and Tips

  • 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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib