farzai / geonames
GeoNames countries, administrative divisions, cities and postal codes from verified Farzai snapshots. Plain PHP, with first-class Laravel support.
Fund package maintenance!
Requires
- php: ^8.3
- ext-json: *
- ext-zlib: *
- illuminate/collections: ^12.0 || ^13.0
- illuminate/contracts: ^12.0 || ^13.0
Requires (Dev)
- laravel/pint: ^1.2
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^3.0 || ^4.0
- phpstan/phpstan: ^2.0
Suggests
- ext-pdo_sqlite: Reads the per-country SQLite files (nearest-city lookups).
- laravel/framework: Service provider, facade, validation rules, Eloquent casts, migrations and seeder are discovered automatically
Provides
None
Conflicts
None
Replaces
None
- dev-main
- 3.0.0
- v2.x-dev
- 2.1.0
- 2.0.0
- 1.0.3
- 1.0.2
- 1.0.1
- 1.0.0
- dev-docs/60-dedupe-docblock
- dev-ci/58-pin-actions
- dev-docs/llms-postal-seeding
- dev-feat/3.0-5-docs
- dev-fix/56-dedupe-paged-docs
- dev-feat/3.0-4-laravel
- dev-fix/55-dedupe-paged
- dev-feat/3.0-3-repository
- dev-feat/3.0-2-snapshot-fetcher
- dev-feat/3.0-1-skeleton
- dev-fix/49-extract-dir
- dev-fix/37-download-integrity
- dev-fix/34-clean-install
- dev-fix/39-zero-elevation
- dev-ci/41-test-matrix
- dev-ci/add-php-8.4-8.3-support
- dev-feature/memory-optimization
- dev-refactor/improve-architecture-and-tests
- dev-feature/mongodb-output
This package is auto-updated.
Last update: 2026-10-17 01:09:25 UTC
README
Countries, first- and second-level administrative divisions, cities and postal codes from GeoNames, read from the verified snapshots Farzai publishes at https://data.farzai.com/geonames/. Plain PHP, with first-class Laravel support.
- An API that reads like Laravel: Laravel collections,
*OrFaillookups, models with names in many languages (Thai and English first). - No network at runtime. One command downloads exactly the countries you ask for, and checks every file against the manifest's sha256 before it keeps it.
- Validation rules, Eloquent casts, migrations and a seeder for Laravel.
- The data is pinned: this release reads snapshot
2026-10-16.1and never followslatestby itself.
Upgrading from 2.x: 3.0 is a rewrite. The downloader, the converters (including MongoDB)
and the --feature-class option are gone. See UPGRADING.md.
Requirements
- PHP 8.3 or newer, with
ext-jsonandext-zlib illuminate/collections12 or 13, installed with the packageext-pdo_sqlitefornearest()(optional)- Laravel 12 or 13 for the Laravel integration (optional)
Install
composer require farzai/geonames
Countries, the feature-kind table and time zones ship with the package (about 310 KB), so
Geonames::countries() works at once. Everything else is fetched on demand.
Fetch data
vendor/bin/geonames fetch TH US # admin1, admin2 and cities (the default) vendor/bin/geonames fetch GB --datasets=cities,postal_codes vendor/bin/geonames fetch TH --sqlite # the SQLite file, for nearest() vendor/bin/geonames fetch TH --datasets=all --snapshot=latest
| Option | Meaning |
|---|---|
--datasets=a,b |
admin1, admin2, cities, alternate_names, postal_codes, or all (default admin1,admin2,cities) |
--sqlite |
also fetch each country's SQLite file |
--snapshot=ID |
a snapshot id, or latest (default: the snapshot this release is pinned to) |
--path=DIR |
the store directory (default $GEONAMES_PATH, else ~/.cache/farzai-geonames) |
- Verified. The manifest is checked against the pinned (or
latest.json) sha256, and every file against the manifest's sha256 and size. A file is written to<name>.partand renamed only when both match, so a failed download leaves nothing behind. - Pinned.
latest.jsonis read only with--snapshot=latest. A snapshot thatsnapshots.jsonlists as withdrawn is refused, and so is a different schema major version (a newer minor reads fine).--snapshot=latesttrustsdata.farzai.comend to end; the bundled pin is the anchor for everything else. Only https is fetched (plain http only for localhost). - Country codes must be two letters; anything else (
../../x,all) is refused. - A file the snapshot does not have is reported, not an error. Thailand has no postal
file: use
farzai/thailand-address, the authority for Thai postal codes.Geonames::postalCodes('TH')throwsPostalCodesElsewhereExceptionand says so.
Reading never touches the network. If the data you ask for is not in the store, you get
a NotFetchedException that names the command to run.
Plain PHP (and everything else)
use Farzai\Geonames\Geonames; Geonames::countries(); // Collection<Country>, 252, bundled $th = Geonames::country('TH'); // by ISO alpha-2, alpha-3 or numeric; ?Country Geonames::countryOrFail('tha'); // or NotFoundException $th->name('th'); // 'ไทย'; ->name('en'); ->name('ja') falls back to the default name $th->nameTh; // property or method, as in thailand-address $th->currency(); // ['code' => 'THB', 'name' => 'Baht'] $th->callingCode; $th->timeZones(); $th->postalCodeRegex(); Geonames::admin1('TH'); // Collection<Admin1>, keyed by geoname_id and "TH.40" Geonames::admin1Code('TH', '40')->iso31662(); // 'TH-10' (nullable, never a join key) Geonames::admin2('TH', admin1: '50'); Geonames::cities('TH', minPopulation: 100_000); // Collection<City> Geonames::city(1609350, 'TH'); // by geoname_id, fetched countries only Geonames::search('chiang', 'TH'); // names in any selected language, prefix first Geonames::postalCodes('US', '90210'); // every row of a code, each with ->precision() Geonames::nearest(13.75, 100.5, 'TH', limit: 5);// needs the SQLite file; each result has distance_km Geonames::timeZones('TH'); // ['Asia/Bangkok']
- Collections are Laravel collections (
Illuminate\Support\Collection). - Models (
Country,Admin1,Admin2,City,PostalCode) are read-only and implementArrayableandJsonSerializable.toArray()keeps the snapshot's snake_case field names, including fields a newer schema minor adds. - Null means unknown. An unknown
kindreads asother. Rows withstatus: abolishedare hidden unless you ask:Geonames::withAbolished()->cities('TH'). - Names are
{bcp47: name}and hold only the languages the pipeline selected (th and en first). Each is the preferred name of the language, or the first current name that is not historic, not colloquial and has no end date, so a name is not always a "preferred" one.name('xx')falls back to GeoNames' default name. - Postal codes have no
geoname_id; one code serves many places, sopostalCodes('US', '90210')returns a collection.->precision()isfullorpartial: for CL, CN, IE, MT, AR and BR only the first characters of a code are known, so never treat apartialrow as a complete code. GB and NL include full unit codes; Canada has the forward sortation area only. - Large countries.
postalCodes('GB')with no code builds every row as a model (about 1.8 million for GB) and holds them all in memory, andcity($id)without a country scans every fetched country file until it finds the id. Pass the country tocity(), askpostalCodes()for one code, and for bulk reads seed the database (geonames:seed, see Migrations and seeder) and query the tables instead. - Nearest reads the country's SQLite file through its R*Tree and is exact: the
search box doubles until the answer cannot change. It needs
pdo_sqliteand the file (fetch TH --sqlite), and is limited to one country.
Testing with a fake
use Farzai\Geonames\Geonames; use Farzai\Geonames\GeonamesRepository; Geonames::swap(GeonamesRepository::fromArrays( countries: [['iso2' => 'XX', 'iso3' => 'XXX', 'iso_numeric' => '999', 'name' => 'Testland', 'names' => ['th' => 'เทสต์']]], cities: ['XX' => [['geoname_id' => 1, 'name' => 'Test City', 'names' => [], 'lat' => 1.0, 'lon' => 2.0, 'population' => 10]]], )); Geonames::swap(null); // back to the real data
In Laravel, Geonames::swap() also rebinds GeonamesRepository in the container, which the
facade, the rules and the casts read.
Laravel
Package discovery registers the service provider and the Geonames facade alias.
php artisan geonames:fetch TH US --datasets=admin1,admin2,cities
Validation
use Illuminate\Validation\Rule; $request->validate([ 'country' => ['required', Rule::geonamesCountry()], 'admin1' => ['required', Rule::geonamesAdmin1()->forCountryField('country')], 'city_id' => ['required', Rule::geonamesCity()->forCountryField('country')], 'postal_code' => ['required', Rule::geonamesPostalCode()->forCountryField('country')], ]);
String rules do the same: geonames_country, geonames_admin1:country, geonames_city:country,
geonames_postal_code:country.
| Rule | Passes when |
|---|---|
geonames_country |
the value is an ISO 3166-1 alpha-2, alpha-3 or numeric code |
geonames_admin1:field |
the value is an admin1 code (50) of the country in field; without a field it must be TH.50 |
geonames_city:field |
the value is a city geoname_id, in the country in field when given |
geonames_postal_code:field |
the value matches the country's postal-code pattern; ->mustExist() also requires a row in the fetched postal file |
- One error per mistake. A parent field that is empty or invalid passes the child's
check; the parent's own rule reports it. Wildcards pair by position:
'addresses.*.admin1' => 'geonames_admin1:addresses.*.country'. - Data not fetched is a failed check whose message names
geonames:fetch, never an exception out of validation. mustExist()and partial countries. A code in CL, CN, IE, MT, AR or BR matches when it starts with a listed prefix. CN, CL and BR list codes zero-padded to full length (CN230000, BR69945-000); the padding is not part of the prefix, so231199passes when231100is listed. Spaces and case are ignored (GBSW1A 1AA). The file is streamed and the check stops at the first match. For Thailand the message points tofarzai/thailand-address.- Messages come in English and Thai (
geonames::validation). Publish them withphp artisan vendor:publish --tag=geonames-lang.
Eloquent casts
use Farzai\Geonames\Laravel\Casts\AsAdmin1; use Farzai\Geonames\Laravel\Casts\AsCity; use Farzai\Geonames\Laravel\Casts\AsCountry; protected function casts(): array { return ['country' => AsCountry::class, 'region' => AsAdmin1::class, 'city_id' => AsCity::class]; } $customer->country = 'tha'; // stores 'TH' $customer->country->name('th'); // 'ไทย' $customer->region = 'TH.50'; // stores the GeoNames code $customer->city_id = 1153671; // stores the geoname_id
- Unknown codes. Setting one throws
NotFoundException; reading one back givesnull, so a stored code that stops existing never breaks a row read. - Blank input. Setting
''ornullclears the column. AsAdmin1andAsCityneed the admin1 or cities data to be fetched.
Migrations and seeder
php artisan vendor:publish --tag=geonames-migrations php artisan migrate php artisan geonames:seed TH US --datasets=countries,admin1,admin2,cities,postal_codes
Tables: geonames_countries, geonames_admin1, geonames_admin2, geonames_cities,
geonames_postal_codes, geonames_alternate_names (names are configurable in
config/geonames.php; publish it with --tag=geonames-config).
- Keys. Rows with a
geoname_iduse it as the primary key and are upserted on it, so a re-run changes nothing. Postal codes and alternate names have no id of their own, so they are replaced per country in one transaction: a code removed from a later snapshot goes too. The postal file repeats a few rows byte for byte (JP, CH, IN, FR); after the insert the seeder finds the postal codes that occur more than once, compares those rows and deletes each repeat, keeping the first, and says how many it removed. It uses plain GROUP BY and index lookups, so there is no database version floor beyond Laravel's own. Admingeonames_codeis an index, not a unique key, because an abolished row and its replacement share a code. - Abolished rows are kept, with
status = abolished. GeonamesSeeder(publish with--tag=geonames-seeder) does the same fromdb:seed.- Add columns only through new migrations of your own.
Configuration
| Key | Env | Default |
|---|---|---|
geonames.path |
GEONAMES_PATH |
storage/app/geonames |
geonames.snapshot |
GEONAMES_SNAPSHOT |
null: the snapshot this release is pinned to. fetch --snapshot=latest downloads that snapshot, but reads stay on the pin until you set this |
geonames.base_url |
GEONAMES_BASE_URL |
https://data.farzai.com/geonames/v1 |
What is in the data
| Dataset | Rows (snapshot 2026-10-16.1) | Notes |
|---|---|---|
| countries | 252 | bundled; ISO codes, currency, calling code, languages, neighbours |
| admin1 | 3,865 | one global file; keyed by geoname_id and geonames_code |
| admin2 | 47,641 | per country |
| cities | 236,262 | places with population 500 or more, or an administrative seat; population_tier 15000, 5000, 1000 or 500 |
| postal_codes | 4,102,707 in 120 countries | per country; precision full or partial |
| alternate_names | 1,478,335 | per country; lang, preferred, short, colloquial, historic |
Ids are geoname_id (plus wikidata_id where GeoNames links one). Admin divisions are keyed
by their own geoname_id; ISO 3166-2 is a nullable attribute. Time zones are IANA names;
coordinates are WGS84 with five decimals. The schema is
data/schema.json, and the data rules (null is unknown, ignore unknown
fields and enum values) are those of schema 1. Refreshes are monthly.
Data licence and attribution
The data is from GeoNames and is licensed under CC BY 4.0. If you show it, say:
Data from GeoNames (geonames.org), licensed under CC BY 4.0
and note that it was converted (to JSON, NDJSON and SQLite), limited to places with a
population of 500 or more or an administrative seat, with names selected by language,
coordinates rounded to five decimals, and kinds and population tiers added. Postal codes
come from GeoNames' postal dump, which states CC BY 4.0 (its readme links the 3.0 URL; the
snapshot manifest records that). Great Britain's full postal codes carry: "Contains Royal
Mail data © Royal Mail copyright and database right 2022. Source: Office for National
Statistics licensed under the Open Government Licence v.3.0." Every snapshot's
manifest.json lists the licence of each source; data/snapshot.json repeats the credits
for the pinned one. The data is provided as is, without warranty of accuracy, timeliness
or completeness.
The package code is MIT licensed.
Testing
composer test # no network GEONAMES_LIVE=1 vendor/bin/pest --group=live # fetches Thailand from data.farzai.com
Maintainers
php tools/update-bundled-data.php latest moves the pin and the bundled files to the
newest published snapshot; a test checks that the bundled files match the pinned manifest.