stanislas-poisson/french-postal-code
A Laravel/PHP package for working with French postal codes, providing validation and lookup of codes and associated cities. Useful for address forms and data normalization.
Install and publish
composer require stanislas-poisson/french-postal-code
php artisan vendor:publish # then select the package's provider/tag from the prompt
php artisan migrate
Because the package ships migrations plus a data loader, the publish step matters: check vendor/stanislas-poisson/french-postal-code/ for the service provider's publish tags (typically config, migrations, or a single french-postal-code tag) and publish only what you need. Confirm auto-discovery worked by running php artisan list | grep -i postal — the loader should appear as a command (or as a seeder class if the package only exposes one).
First use case: a validated postal-code field
The fastest win is replacing a free-text "Code postal / Ville" field on a checkout or signup form. Load the dataset once, then:
preg_match('/^\d{5}$/', $value) — shape only, not existence.postal_codes table (or via the package model) to confirm it exists and to retrieve the associated commune, department and region.Where to look first
Read the migrations before the models: the schema tells you whether the package models a postal code as a first-class table with a latitude/longitude pair (as the description implies) and whether communes link to postal codes directly or through a pivot. That single detail determines every query you write afterwards.
Model graph and relationships. Expect the canonical French hierarchy: Region → Department → Commune, with postal codes attached to communes. Model your own Address/Company/Store with a postal_code_id (or a plain 5-char string plus an index) rather than duplicating city names — the package becomes your reference table, and your app stays normalised.
A lookup service with caching. This data is effectively read-only between deployments, so wrap it:
class PostalCodeLookup
{
public function find(string $code): ?PostalCodeData
{
return Cache::remember("cp:{$code}", now()->addDay(), function () use ($code) {
return PostalCode::with('communes.department.region')
->where('code', $code)
->first()
?->toData();
});
}
}
Cache by postal code, not by department — lookups are overwhelmingly single-code.
Autocomplete endpoint. For a typeahead, query WHERE code LIKE '75%' with a LIMIT, and dedupe by commune for display. Return {code, city, department, region} and let the front end fill three fields from one selection. Index the code column and, if you search by name, a normalised (accent-stripped, lowercased) city column — accents will bite you otherwise.
Radius / proximity search. With one GPS point per postal code, the honest pattern is: resolve the user's postal code to its point, then run a bounding-box prefilter on latitude/longitude and refine with a Haversine expression. Treat results as "postal-code centroid within X km", not "address within X km" — good enough for store locators and delivery-zone checks, not for routing.
Seeding strategy. Load the dataset in a deploy step or a dedicated seeder, not in DatabaseSeeder on every test run. For tests, either seed a small subset (if the loader accepts a department filter) or use a handful of factories, and assert against known codes rather than the whole table.
Deployment. Add the loader to your release script after migrate. If it is slow, run it inside a transaction with model events disabled, or chunk by department. Re-run it on every package upgrade — French communes merge and reassign codes regularly (communes nouvelles), so a stale table will silently reject valid input.
Postal codes are not 1:1 with communes. Large cities have many codes; rural codes often cover several communes. Never assume where('code', $cp)->first() gives you the city — design for a collection or pick a documented default. This is the single most common source of bad address data.
Don't derive the department from the first two digits. Corsica (20xxx spanning 2A/2B) breaks that shortcut, and overseas codes (971–976, plus 98xxx) don't follow the metropolitan pattern. Always join to the department record.
Validate shape, then existence. A regex accepts 99999; only a DB lookup rejects it. Do both, in that order, and give a friendly "code postal inconnu" message.
Strip CEDEX before lookup. Users paste 75008 CEDEX 08. Normalise with something like preg_replace('/\s*CEDEX.*$/i', '', $input) and keep the suffix in a separate field if you need it for shipping.
One point per code is coarse. Do not feed these coordinates into distance-based pricing or turn-by-turn navigation. For precise geocoding, keep an external geocoder and use this package for the canonical administrative hierarchy.
Monaco and foreign codes. 98000 is Monaco, not France. If your form is France-only, decide explicitly whether to accept it; if it's international, gate this package's lookup behind a country selector.
Loader performance and idempotency. Tens of thousands of communes plus postal codes is a multi-minute import on shared hosting. Confirm the loader truncates or upserts cleanly before running it twice; run it with --no-interaction in CI so a stray prompt doesn't hang the pipeline.
Package maturity. With no dependents and a single maintainer, pin the version in composer.json, keep a smoke test that asserts a few well-known lookups (75001 → Paris, 13001 → Marseille), and consider snapshotting the data into your own seeder so a broken release can't block deploys. If you use Symfony-style loader classes elsewhere, don't wire them into Laravel — use the Laravel service provider path.
Extension points. If the package exposes configurable table names or model bindings, override them in config/french-postal-code.php rather than editing vendor files; if not, add a thin repository layer so you can swap the source later without touching controllers.
How can I help you explore Laravel packages today?