Search by

lekoala / belgian-geography

lekoala

Compact, up-to-date Belgian postal codes, municipalities and multilingual place names.

Package info

github.com/lekoala/belgian-geography

pkg:composer/lekoala/belgian-geography

Statistics

Installs: 9

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.2.1 2026-09-22 21:19 UTC

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 Namen mean the same municipality as Namur?
  • 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 names
  • PostalCode: a four-digit code and its current postal places
  • PostalPlace: postal code + municipality + multilingual locality/postal name
  • Province: 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 by nl, fr, de
  • Coordinates: 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.zip
  • openaddress-bebru.zip
  • openaddress-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.