farzai / thailand-address
Thai provinces, districts and subdistricts with postal codes and ISO 3166-2 codes, from official sources. Plain PHP, with first-class Laravel support.
Requires
- php: ^8.2
- ext-mbstring: *
- illuminate/collections: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0|^5.0
Suggests
- laravel/framework: Service provider, facade, validation rules and Eloquent casts are discovered automatically
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-15 02:17:27 UTC
README
ข้อมูลจังหวัด อำเภอ/เขต ตำบล/แขวง และรหัสไปรษณีย์ของประเทศไทย จากแหล่งข้อมูลทางการ (กรมการปกครอง, ไปรษณีย์ไทย, กระทรวงดิจิทัลฯ) ใช้กับ Laravel ได้ทันที หรือใช้กับ PHP ธรรมดาก็ได้
Thailand's 77 provinces, 928 districts (amphoe and Bangkok khet) and 7,436
subdistricts (tambon and Bangkok khwaeng), keyed by their DOPA codes, with Thai
and English names, ISO 3166-2 province codes and postal codes. Every row comes
from an official source pinned in data/sources.json, and
tools/generate.php imports it from a published snapshot of the Thai address dataset.
- An API that reads like Laravel: Laravel collections, lazy relations
(
$subdistrict->district->province),*OrFaillookups. - Validation rules, including "this district is in that province".
- Eloquent casts that store a code and give you the model.
- Works in plain PHP too; Laravel is optional.
Requirements
- PHP 8.2 or newer, with
ext-mbstring illuminate/collections12 or 13, installed with the package- Laravel 12 or 13 for the Laravel integration (optional)
Install
composer require farzai/thailand-address
In Laravel, package discovery registers the service provider and the
ThailandAddress facade alias. Nothing needs publishing.
Laravel
Validation
Validate a Thai address form with rule objects:
use Illuminate\Validation\Rule; $request->validate([ 'province' => ['required', Rule::thaiProvince()], 'district' => ['required', Rule::thaiDistrict()->inProvince('province')], 'subdistrict' => ['required', Rule::thaiSubdistrict()->inDistrict('district')], 'postal_code' => ['required', Rule::thaiPostalCode()->forSubdistrict('subdistrict')], ]);
String rules do the same checks:
$request->validate([ 'province' => 'required|thai_province', 'district' => 'required|thai_district:province', 'subdistrict' => 'required|thai_subdistrict:district', 'postal_code' => 'required|thai_postal_code:subdistrict', ]);
| Rule | Passes when |
|---|---|
thai_province |
the value is a province code, e.g. 10 |
thai_district, thai_district:field |
the value is a district code, e.g. 1001, in the province given in field |
thai_subdistrict, thai_subdistrict:field |
the value is a subdistrict code, e.g. 100101, in the district given in field |
thai_postal_code, thai_postal_code:field |
the value is a Thai postal code, and it serves the subdistrict given in field |
- One error per mistake. A parent check passes when the parent field is empty or invalid; that field's own rule reports it, so the user sees one error.
- Arrays work. Wildcards pair by position:
'addresses.*.district' => 'thai_district:addresses.*.province'. - No postal code data. A subdistrict with no known postal code accepts any valid Thai postal code.
- Rule classes. They are also usable directly, without the macros:
ThaiProvince::make(),ThaiDistrict::make()->inProvince('province'), and so on, underFarzai\ThailandAddress\Laravel\Rules.
Messages come in English and Thai, under thailand-address::validation.
Publish them to change the wording:
php artisan vendor:publish --tag=thailand-address-lang
A message you define for a string rule in your own lang/*/validation.php, or
pass to the validator, takes precedence. It can use :other for the parent
field's name.
Eloquent casts
use Farzai\ThailandAddress\Laravel\Casts\AsDistrict; use Farzai\ThailandAddress\Laravel\Casts\AsProvince; use Farzai\ThailandAddress\Laravel\Casts\AsSubdistrict; class Customer extends Model { protected function casts(): array { return [ 'province_code' => AsProvince::class, 'district_code' => AsDistrict::class, 'subdistrict_code' => AsSubdistrict::class, ]; } } $customer->subdistrict_code = '100101'; // or a Subdistrict, or 100101 $customer->subdistrict_code->district->province->nameTh; // 'กรุงเทพมหานคร'
- Storage. The column stores the code as a string. Reading it back gives the model.
- Unknown codes. Setting an unknown code throws
NotFoundException, as Laravel's enum casts do. Reading one back givesnull: a stored code can stop existing when DOPA abolishes an area, and a row read must not fail. - Blank input. Setting
''(a blank form field) ornullclears the column. - Wrong level. Setting a model of the wrong level throws
InvalidArgumentException.
Facade and injection
use Farzai\ThailandAddress\AddressRepository; use Farzai\ThailandAddress\Laravel\Facades\ThailandAddress; ThailandAddress::province('10'); public function __construct(private AddressRepository $addresses) {}
The container holds one AddressRepository. The ThailandAddress facade and
the framework-agnostic Thailand class below both use it.
Plain PHP (and everything else)
use Farzai\ThailandAddress\Thailand; Thailand::provinces(); // Collection<Province>, 77 items Thailand::province('10'); // ?Province, by DOPA code Thailand::provinceOrFail('10'); // Province, or NotFoundException Thailand::provinces()->firstWhere('nameEn', 'Bangkok'); Thailand::province('10')->districts(); // Collection<District> Thailand::province('10')->subdistricts(); // every subdistrict in Bangkok Thailand::district('1001')->subdistricts(); // Collection<Subdistrict> $s = Thailand::subdistrict('100101'); $s->nameTh; // 'พระบรมมหาราชวัง' $s->district->province->nameTh; // 'กรุงเทพมหานคร' (lazy relations) $s->postalCodes; // ['10200'] Thailand::postalCode('10200'); // Collection<Subdistrict> Thailand::search('บางรัก'); // Thai or English names, every level $address = $s->toAddress(); // Address $address->administrativeArea; // 'กรุงเทพมหานคร' $address->locality; // 'เขตพระนคร' $address->dependentLocality; // 'พระบรมมหาราชวัง' $address->postalCode; // '10200' $address->countryCode; // 'TH' $address->inLocale('en')->toArray(); // the same, with English names
- Collections. They are Laravel collections (
Illuminate\Support\Collection), sowhere,firstWhere,pluck,map,sortByand the rest work as in Laravel. - Indexing. Collections are list-indexed, so JSON gives an array.
- Models.
Province,DistrictandSubdistrictare read-only, and implementArrayableandJsonSerializable. - Lookups. Every lookup takes a code as a string or an int. A miss gives
null, or an empty collection. - Loading. Each level's data loads on first use, once per process: a province lookup reads only the 77 provinces, not the 7,436 subdistricts.
- Serializing. A model serializes as its code (a few dozen bytes) and is
rebuilt through
Thailand::repository()when read back, so a queued job or a cache entry stays small. If the code no longer exists when the payload is read back (DOPA abolished the area and the data was updated), unserializing throwsNotFoundException, naming the model class and the stale code. Store the code instead of the model where a payload can outlive a data update.var_dump()shows the model's fields.
Testing with a fake
Thailand is a static facade over an AddressRepository. Swap in a small fake:
use Farzai\ThailandAddress\AddressRepository; use Farzai\ThailandAddress\Thailand; Thailand::swap(AddressRepository::fromArrays( ['99' => ['name_th' => 'ทดสอบ', 'name_en' => 'Test', 'iso_code' => 'TH-99']], ['9901' => ['name_th' => 'เมืองทดสอบ', 'name_en' => 'Mueang Test']], ['990101' => ['name_th' => 'ในเมือง', 'name_en' => 'Nai Mueang', 'postal_codes' => ['99000']]], )); Thailand::swap(null); // back to the real data
In Laravel, any one of Thailand::swap($fake), ThailandAddress::swap($fake)
or binding the fake to AddressRepository::class is enough: Thailand, the
facade, the validation rules and the casts all read the container's
repository, and Thailand::swap() rebinds it there.
Codes
The codes are DOPA's administrative codes, as strings:
| Level | Example | Digits |
|---|---|---|
| Province (จังหวัด) | 10 |
2 |
| District: amphoe (อำเภอ) or khet (เขต) | 1001 |
4: the province's 2 digits, then 2 more |
| Subdistrict: tambon (ตำบล) or khwaeng (แขวง) | 100101 |
6: the district's 4 digits, then 2 more |
DOPA publishes 8-digit codes (PPAATTMM). The package uses the first 6, which
are the province, district and subdistrict.
Names and standards
| This package | DOPA (Thai) | ISO 3166-2:TH | libaddressinput / CLDR | schema.org PostalAddress |
|---|---|---|---|---|
Province |
จังหวัด | province (TH-10 … TH-96) |
administrativeArea |
addressRegion |
District |
อำเภอ, เขต in Bangkok | locality |
addressLocality |
|
Subdistrict |
ตำบล, แขวง in Bangkok | dependentLocality |
part of streetAddress (no own field) |
|
Subdistrict::$postalCodes |
รหัสไปรษณีย์ | postalCode |
postalCode |
|
Address::$countryCode (TH) |
countryCode |
addressCountry |
- Bangkok's khet are
Districts and its khwaeng areSubdistricts, as in DOPA's own English terms. Bangkok itself is aProvince(ISO 3166-2 calls it a metropolitan administration,TH-10). - PHP properties are camelCase (
nameTh,provinceCode,postalCodes,isoCode).toArray()on an area, the generated data files anddata/sources.jsonuse the same names in snake_case (name_th,province_code,postal_codes,iso_code).Address::toArray()uses the libaddressinput names above. - ISO 3166-2 also lists
TH-S(Pattaya, a special administrative city). DOPA counts Pattaya as part of Chon Buri (TH-20), so it has noProvincehere.
Postal codes
- A subdistrict can have more than one postal code, and one postal code can
serve many subdistricts, so
postalCodesis a list of strings. - 7,386 of the 7,436 subdistricts have at least one postal code. The other 50 have an empty list because neither source covers them.
- For 82 subdistricts the two sources disagree, and the list holds the postal codes from both, except that a code only one source gives is dropped when its first two digits are not a postal area both sources agree on in that province. Three real cross-province codes are kept on a checked allowlist. Every postal code belongs to its subdistrict's province, and a test checks that.
toAddress()fillspostalCodeonly when the subdistrict has exactly one, or when you pass one it has; it never guesses among several.- Thailand Post is the authority on postal codes. Check with it before relying on a postal code for something that matters.
The counts for the imported snapshot are in data/sources.json under counts, copied from that snapshot's manifest.json.
Data sources and licences
| Data | Source | Licence |
|---|---|---|
| Codes, Thai names, English names | DOPA, ทำเนียบท้องที่ ccaatt.xlsx (landing page), data as of 1 September 2023 |
None is stated on the page. It is published government record data. |
| Postal codes, by subdistrict code | Thailand Post, the track web app's subdistrict list (one request) | None is stated. See Thailand Post's terms. |
| Postal codes, by name | MDES, สถานที่ทำการไปรษณีย์ (post offices) on data.go.th, postal-2026.06.30.csv |
Creative Commons Attribution. Attribution: Office of the Permanent Secretary, Ministry of Digital Economy and Society. |
| ISO 3166-2:TH province codes | Debian iso-codes v4.20.1, iso_3166-2.json. Each code is joined to its DOPA province by number and must agree by name (cross-checked against Wikipedia's ISO 3166-2:TH table, 2026-10-14) |
The codes are ISO 3166-2; iso-codes is LGPL-2.1-or-later. Only the 77 codes are used. |
Things to know about the data:
- Cancelled areas are dropped. DOPA marks a cancelled area with a non-zero
cancel date and a
*after its name. Those rows are not shipped. - English names are DOPA's romanisation, which is close to RTGS but not strict (for example "Vadhana" for วัฒนา).
- Names are verbatim. Bangkok khet names keep their Thai prefix (
เขตพระนคร); English names have no prefix (Phra Nakhon). - The DOPA file is dated 2023-09-01. Changes made after that date are not in it.
No data from kongvut/thai-province-data or any project derived from it is used.
Refreshing the data
The data comes from published snapshots of the Thai address dataset. It is built from the official sources (DOPA, Thailand Post, MDES, ISO 3166-2) by its own pipeline, which fetches, joins and checks them, and publishes immutable, versioned snapshots. This library fetches nothing itself.
A weekly workflow (.github/workflows/update-data.yml) checks for a newer snapshot,
imports it, runs the tests and opens a pull request; nothing is merged
automatically. Its import job has a read-only token; a second job, which runs no
Composer and no PHP, commits only the four data files and opens the pull request;
a third, which can only start workflows, runs the test matrix on the pull request.
When the test suite fails on the new data (it pins today's counts), the pull
request opens as a draft labelled needs-tests with the failing output. By hand
(the tool is in the repository, not in the Composer package):
php tools/generate.php # the latest published snapshot php tools/generate.php --snapshot=2026-11-01 # a pinned one
Before writing anything, it checks:
- the snapshot is listed as published in
v1/snapshots.json; - its manifest matches the sha256 recorded there;
- every file matches the manifest and carries a schema v1 envelope for that snapshot;
- every row it reads has the expected shape (digit codes, five-digit postal codes, a Thai name, a known status and kind).
These checks show the files are consistent with each other, not that the dataset's pipeline published them; a provenance check is a follow-up.
It then rewrites data/*.php, and data/sources.json, which names the snapshot, its sources and their licences. data/ keeps its shape: active areas only, with Bangkok's khet named as before (เขตพระนคร).
Then run composer test. The tests pin the exact counts, so a real change shows up as a failing test. Update that test on purpose, and describe the change in the CHANGELOG.
Upgrading from 1.x
The package has a new name. parsilver/thailand-provinces-php 1.x is
abandoned in favour of farzai/thailand-address, which starts at 2.0:
composer remove parsilver/thailand-provinces-php composer require farzai/thailand-address
2.0 is a rewrite. Nothing from the 1.x API remains.
| 1.x | 2.0 |
|---|---|
PA\ProvinceTh\Factory::province() |
Thailand::provinces() |
Factory::amphure() |
Thailand::districts() |
Factory::district() (tambon) |
Thailand::subdistricts() |
Factory::geography() |
removed (regions are not in the official sources) |
->find(1) with a row id |
Thailand::province('10') with the DOPA code |
find() returning the whole table on a miss |
null |
->amphures(), ->districts() |
$province->districts(), $district->subdistricts() |
zip_code (one string) |
postalCodes (a list) |
Factory::amphure() / amphures() names |
District, districts(): no Thai transliterations in the API |
name_th, name_en array keys |
nameTh, nameEn properties; toArray() keeps name_th, name_en |
What else changed:
- Ids changed. 1.x keyed provinces and districts by its own row ids
(province 1 was Bangkok). 2.0 uses DOPA codes (Bangkok is
10). If you stored 1.x province or district ids, map them through the 1.xcodefield, which holds the DOPA code. 1.x subdistrict ids were already 6-digit codes, except in Bueng Kan (38), where they did not match DOPA's (#7). - The data was regenerated from the official sources. Abolished areas are gone, Bueng Kan's districts appear once under code 38, and the missing Bangkok khwaeng and Chiang Mai's Galyani Vadhana district are present.
- Requirements: PHP 8.2 or newer. Laravel 12 or 13 for the Laravel integration. Laravel 10 and 11 are not supported: they are end-of-life, and Composer refuses to install them because of open security advisories.
parsilver/thailand-provinces-php is frozen at 1.0.3 and gets no further
releases. The source of both lives in this repository.
Not in 2.0
These may come later. They are not in this release:
- Database migrations and seeders for the address tables
- Livewire or Blade components for cascading province / district / subdistrict dropdowns
Development
composer install composer test # vendor/bin/pest
Testing
- Tests are written in Pest.
tests/Corecovers the plain-PHP API, the data and the snapshot importer.tests/Laravelboots a Testbench application throughuses(...)->in('Laravel')intests/Pest.php. - Pest versions.
require-devallows Pest 3, 4 and 5, so Composer installs the newest one your PHP can run: Pest 5 needs PHP 8.4, Pest 4 needs PHP 8.3, and Pest 3 runs on 8.2. Pest 5 also needs PHPUnit 13, which only Testbench 11 (Laravel 13) allows, so a Laravel 12 setup gets Pest 4 at most. The tests use only what the three share (test(),uses(),beforeEach(),afterEach(),dataset(),->with()), so they run unchanged on each. - CI. CI runs PHP 8.2 to 8.5 against Laravel 12 and 13, prints the Pest
version each job resolved, and checks that the dist excludes
tests/andtools/.
License
The code is MIT. See LICENSE. The data keeps the terms of its sources, listed above.