lekoala / belgian-geography
Compact, up-to-date Belgian postal codes, municipalities and multilingual place names.
Requires
- php: ^8.3
Requires (Dev)
- ext-zip: *
- carthage-software/mago: ^1
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-22 21:21:26 UTC
README
Small, framework-agnostic PHP reference library for current Belgian municipalities, postal codes and multilingual place names.
It is intentionally not an address/geocoder library. Use Google Maps, BeST or another geocoder when you need street-level addresses. This package answers the smaller questions applications repeatedly need to solve locally:
- Which municipality has this NIS code?
- What is its current French, Dutch or German name?
- Does
Namenmean the same municipality asNamur? - Which municipalities can occur under postal code
1040? - Which postal place corresponds to
Jambes?
Design
The runtime package has no framework, database or HTTP dependency. It ships a compact snapshot generated from the weekly FPS BOSA BeST Address exports.
Municipality identity is the NIS code, never a localized name or a postal code. French/Dutch/German BeST names are indexed as equivalent lookup names.
On top of the generated snapshot, resources/aliases.php holds a minimal,
versioned layer of search aliases the BeST snapshot does not contain: English
usage that differs from the native name (Brussels, Antwerp, Ghent), plus
French/Dutch/German search spellings (Aerschot, Arel, Tongres, …). These are
lookup helpers, never names of record: municipality identity and current names
come from BeST alone. Statbel 2025, for instance, publishes 24001 as Aarschot
in NL and FR, and 73111 as Tongeren-Borgloon / Tongres-Looz, so aerschot
or tongres remain search aliases rather than current names. An entry is kept
only when it is a plausible search spelling and missing from BeST (verified with
tools/check-alias-coverage.php); at load time a search alias never shadows a
current BeST name, and one already covered by BeST is simply inert. Archaic
spellings stay out: historical SEO slugs such as an obsolete spelling belong to
the application that served them, not to a current Belgian reference library.
resources/places.php is a second, even smaller hand-maintained layer of
supplementary postal places: current localities BeST does not restitute
through its postname_* fields. For 1020, 1120 and 1130 BeST only publishes
BRUXELLES/BRUSSEL, while the City of Brussels and bpost identify these postal
areas as Laeken/Laken, Neder-Over-Heembeek and Haren. These are real
PostalPlace objects, not aliases, and never municipalities. A supplement may only
name a locality of a postal code / municipality relation BeST already has, and it
is inert once BeST publishes one of its names for that relation.
Install
composer require lekoala/belgian-geography
Usage
use LeKoala\BelgianGeography\Belgium; $be = Belgium::load(); $liege = $be->municipalityByName('Luik'); echo $liege?->nisCode; // 62063 echo $liege?->name('fr'); // Liège echo $be->municipalityByName('Liege')?->name('de'); // accent-insensitive lookup $namur = $be->municipalityByName('Namen'); echo $namur?->name('fr'); // Namur $postcode = $be->postalCode('1040'); foreach ($postcode?->places() ?? [] as $place) { echo $place->municipalityNisCode; } foreach ($be->postalPlacesByName('Jambes') as $place) { echo $place->postalCode; // 5100 } foreach ($be->postalPlacesByName('Koolkerke') as $place) { echo $place->postalCode; // 8000, sub-municipality of Brugge } $laeken = $be->postalPlacesByName('Laeken')[0]; // supplementary place, see Design echo $laeken->postalCode; // 1020 echo $laeken->municipalityNisCode; // 21004, City of Brussels $be->municipalityByName('Laeken'); // null: a locality, not a municipality foreach ($be->postalCodesForRegion('BE-BRU') as $postalCode) { echo $postalCode->code; // 1000, 1020, 1030, … } echo $be->provinceForMunicipality('62063')?->isoCode; // BE-WLG echo $be->province('BE-VWV')?->name('en'); // West Flanders echo $be->regionForMunicipality('21004')?->name('fr'); // Bruxelles echo $be->provinceForMunicipality('21004'); // null: Brussels is a region, not a province // Fallback-aware label for UI: only the requested locale and the explicit // fallbacks are used (name() stays strict, and no implicit locale is added): echo $be->municipality('92094')?->displayName('de', 'fr'); // Namur echo json_encode($be->municipality('92094')); // {"nisCode":"92094","region":"BE-WAL","names":{"nl":"Namen","fr":"Namur","de":"Namur"},"coordinates":{"latitude":50.42305,"longitude":4.77892}}
Centering a map
Every municipality exposes an approximate center so a map can be centered without geocoding anything. Province and region centers are recomposed from the same points:
$city = $be->municipalityByName('Luik'); echo $city?->coordinates?->latitude; // 50.63… echo $city?->coordinates?->longitude; // 5.58… $be->province('BE-VAN')?->coordinates; // approximate province center $be->region('BE-WAL')?->coordinates; // approximate region center
Each point is an approximate municipality center derived from current BeST address points: the arithmetic mean of the addresses' WGS84 coordinates, rounded to 5 decimal places. Province and region centers are the same barycenter of every underlying address, weighted by each municipality's contributing address count. They are meant to place a marker or fit a view — not a cadastral, administrative or geometric centroid, and still not an address/geocoder.
Searching names: one result or several
For user-facing search, resolve against municipalitiesByName() and handle the
three cases — none, one, several. municipalityByName() is a convenience that
returns null on ambiguity, which is not the same as "not found", so don't
present it as an empty result:
$matches = $be->municipalitiesByName('Saint-Nicolas'); // Ambiguous: 46021 (Sint-Niklaas, FR search alias) and 62093 (Liège). foreach ($matches as $municipality) { $province = $be->provinceForMunicipality($municipality->nisCode); echo $municipality->displayName('fr') . ' (' . $municipality->name('nl') . ') — ' . $province?->name('fr') . PHP_EOL; } // Saint-Nicolas (Sint-Niklaas) — Flandre orientale // Saint-Nicolas (Saint-Nicolas) — Liège echo $be->municipalityByName('Namen')?->nisCode; // 92094 (single match)
Display labels
Search is case- and accent-insensitive. For presentation, placeLabel() reuses
the municipality spelling when the locality name is the same word as its
municipality in the same locale; otherwise the source spelling is kept. The raw
source label stays available through name() / displayName():
$halle = $be->postalPlacesByName('Halle')[0]; // postal code 1500 echo $halle->name('nl'); // HALLE — source spelling echo $be->placeLabel($halle, 'nl'); // Halle — municipality spelling // A locality whose name differs from its municipality keeps the source graphy: // 2980 "Halle" is a locality of the municipality of Zoersel.
Resolving URL slugs
Normalizer::key() is the canonical slug function: it lowercases, folds accents
and drops spaces/punctuation. Generate slugs with it and resolve them with the
name lookups — so /city/liege finds Liège, and /city/luik finds the same
municipality:
use LeKoala\BelgianGeography\Normalizer; Normalizer::key('Liège'); // "liege" Normalizer::key('La Roche-en-Ardenne'); // "larocheenardenne" $be->municipalityByName('liege')?->nisCode; // 62063 $be->municipalityByName('luik')?->nisCode; // 62063
municipalityByName() is an exact normalized match (not a prefix) and returns
null on ambiguity; a slug route should distinguish the three cases:
$matches = $be->municipalitiesByName($slug); if ($matches === []) { // 404 } elseif (count($matches) === 1) { // single city page } else { // disambiguate, e.g. "saint-nicolas" -> 46021 / 62093 }
Locality slugs resolve through postalPlacesByName(). If the route must also
accept sub-municipalities, fall back to it and use the locality's municipality:
$city = $be->municipalityByName($slug); // null when unknown or ambiguous if ($city === null) { $place = $be->postalPlacesByName($slug)[0] ?? null; $city = $place === null ? null : $be->municipality($place->municipalityNisCode); }
Important: postal codes are not municipality IDs
The model intentionally keeps postal codes and municipalities separate. Their relation
is not globally one-to-one; Brussels contains real exceptions where the exact address
matters. municipalitiesForPostalCode() therefore returns a list.
Data model
The public model is deliberately small:
Municipality: NIS code, region code, multilingual namesPostalCode: a four-digit code and its current postal placesPostalPlace: postal code + municipality + multilingual locality/postal nameProvince: ISO 3166-2 code, region code, trilingual names (nl,fr,en)Region: ISO 3166-2 code and trilingual names (BE-VLG,BE-BRU,BE-WAL)LocalizedName: current names keyed bynl,fr,deCoordinates: optional WGS84 latitude/longitude of an approximate center
The snapshot stores a region once per municipality. A locality carries no region
of its own: PostalPlace::$region is derived from the referenced municipality at
load time, and a locality that references an unknown municipality is rejected.
resources/data.php is an internal, versioned storage format (schema_version).
Its records are compact tuples — a municipality is [region, names], a locality is
[municipality NIS code, names] — and the layout is not a public contract.
Always read the snapshot through the Belgium API, whose objects and JSON are stable.
Regions are represented by BE-VLG, BE-BRU and BE-WAL. There are exactly
ten provinces (BE-VAN, BE-VWV, …) derived from NIS prefix ranges; the
Brussels-Capital Region is a region, not a province, so
provinceForMunicipality('21004') returns null while
regionForMunicipality('21004') returns BE-BRU. Every model exposes
toArray() / JsonSerializable, plus an explicit-fallback displayName()
next to the strict name(): only the requested locale and the given fallbacks
are considered, and an unmatched request returns null (no implicit locale).
Combined BeST postal labels (BRUGGE/Koolkerke) are split so every locality —
including sub-municipalities — resolves to its municipality and postal code.
Locality label casing is preserved from the source: BeST publishes some Flemish
postname_* values in all caps (HALLE for postal code 1500, whose
municipality is Halle). The library does not rewrite proper names; all lookups
are case- and accent-insensitive, and placeLabel() offers a cleaned display
label when the locality name matches its municipality (see Display labels).
Street-level addresses and geocoding remain out of scope for v0.x: the library
only ships an approximate municipality center (see Centering a map).
Coordinates companion
resources/data.php stays the raw derived BeST snapshot and carries no computed
coordinate. Approximate municipality centers live in a separate generated
companion, resources/centers.php, with its own schema_version:
'municipalities' => [ '11002' => [51.214_72, 4.419_97, 344_247], // latitude, longitude, address count ],
It is optional and local to the snapshot: centers are read from the
centers.php sitting next to the loaded data.php, never from the packaged
fallback. A snapshot copied elsewhere without its sibling centers.php therefore
yields null coordinates, even with packaged references enabled — while the
package ships centers for every municipality. The address count is internal — it
lets the province and region centers be recomposed as the barycenter of the
underlying addresses at load time. data.php must never contain computed data.
Partial / custom snapshots
Belgium::load($path, usePackagedReferences: false) loads a snapshot without
falling back to the packaged aliases.php / places.php / provinces.php / regions.php.
Companion files placed next to the snapshot are still loaded. This keeps
fixtures and partial datasets (which may not contain every municipality the
packaged alias layer references) loadable; with the default true, those
packaged references apply. The generated centers.php is the exception: it is
only ever read next to the snapshot, so it never leaks across snapshots.
Updating the bundled data
Source checkouts can regenerate resources/data.php and the
resources/centers.php companion directly from the three weekly BOSA CSV
archives:
composer data:update
tools/ is excluded from the Composer dist, so this is a maintainer command for
source checkouts only — consumers use the snapshot shipped in the package. The
updater downloads:
openaddress-bevlg.zipopenaddress-bebru.zipopenaddress-bewal.zip
It scans only current address rows from openaddress-be*.csv to derive the compact municipality/postal-place relation, and separately aggregates the EPSG:4326 address points into the approximate municipality centers written to resources/centers.php (out-of-range points are ignored). It discards street and house-number data. Large BeST source
files never become part of the Composer package.
ext-zip is needed only for this maintainer command, not at runtime.
To regenerate from archives already downloaded locally:
php tools/update-data.php --source-dir=/path/to/archives
Why derive from addresses?
BeST correctly models municipality and postal information as separate objects. In particular, the relation is many-to-many in some Brussels cases. Reading the current address relations at build time preserves that reality while allowing the published runtime dataset to remain tiny.
For locality labels, the generator consumes the current flat BOSA/OpenAddresses postname_* fields. Those fields already incorporate the regional fallback used by BeST (including municipality parts where applicable); when no postal label is present, the municipality names are used. Combined labels such as BRUGGE/Koolkerke are split into individual localities.
Data provenance
The source exports are published by FPS BOSA from the authentic regional address registers and are refreshed weekly. Generated data records its source URLs and SHA-256 hashes; the files are deterministic — identical archives produce byte-identical output, so no generation timestamp is stored.
See DATA-LICENSE.md for attribution and data licensing.
Scope
This package intentionally does not provide:
- street/address autocomplete,
- geocoding,
- exact geometry: only an approximate municipality center (and province/region centers recomposed from it) is provided, never polygons, bounding boxes, distances or cadastral coordinates,
- a curated list of reference names: only search aliases are added, never names of record, and old/archaic spellings stay out (see Design).
Those concerns can be added by separate packages or application code if a real use case appears. The goal here is a boring, reliable Belgian reference layer.