stanislas-poisson / french-postal-code
The regions, departments, communes and postal codes of France, with one GPS point per postal code: the data, the models and a loader for Laravel and Symfony.
Package info
github.com/Stanislas-Poisson/French-Postal-Code-Package
pkg:composer/stanislas-poisson/french-postal-code
Requires
- php: ^8.2
Requires (Dev)
- laravel/pint: ^1.13
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.5
Suggests
- doctrine/doctrine-bundle: Needed by the Symfony adapter
- doctrine/orm: Needed by the Symfony adapter (3.x, with DBAL 4)
- laravel/framework: Eloquent models and Artisan command (11 to 13)
- symfony/framework-bundle: Doctrine entities and console command (6.4, 7.4 or 8)
- symfony/var-exporter: Needed by Doctrine ORM to load a relation on demand, below PHP 8.4
Provides
None
Conflicts
- doctrine/dbal: <4.0
- doctrine/orm: <3.0
- laravel/framework: <11.0
- symfony/framework-bundle: <6.4
Replaces
None
This package is auto-updated.
Last update: 2026-10-02 22:18:12 UTC
README
French Postal Code
The regions, departments, communes and postal codes of France, with one GPS point per postal code, in your own database.
A Composer package that gives an application the French administrative areas and postal codes as data, models with their relations and a command that loads them into its database. It is built to work with Laravel (Eloquent) and Symfony (Doctrine), and its core needs no framework.
The data comes from the French-Postal-Code project, which builds it from the official open sources (INSEE, La Poste, the Base Adresse Nationale) and publishes it on data.gouv.fr.
Why a package
An application that stores addresses wants to point each one to a stable postal entry, such as "37200 Tours", with a foreign key. This package keeps the original identifiers of the dataset when it loads the rows, and never deletes a row: a closed row has a valid_to date, and a replaced city points to its successor. The foreign keys of your application therefore stay valid from one version to the next.
What the data holds
| Table | Content |
|---|---|
regions |
The regions. |
departments |
The departments and the overseas collectivities. |
communes |
The communes and the municipal arrondissements, closed ones included. |
cities |
One row per commune and per postal code, with its GPS point. The table to reference by a foreign key. |
commune_successions |
For an old INSEE code, the code that follows it, the kind of change and its date. |
Every table has valid_from and valid_to columns. The files are in data/, described by a manifest.json and by a Table Schema per file.
Reading the data without a framework
use StanislasPoisson\FrenchPostalCode\Core\Dataset; $dataset = new Dataset(); $dataset->count('cities'); // 35510 $dataset->columns('cities'); // ['id', 'commune_id', 'postal_code', ...] foreach ($dataset->chunks('cities', 1000) as $rows) { // $rows is a list of arrays indexed by column name, an empty value is null }
The reader streams the files and checks them against the manifest: a truncated or altered file is an error, never a silent partial load.
Laravel
composer require stanislas-poisson/french-postal-code php artisan migrate php artisan french-postal-code:load
migrate creates the tables, french-postal-code:load fills them. The command can be run again at any time: it adds the new rows, updates the others and deletes nothing, so it is also how you take a new version of the data.
The models are in StanislasPoisson\FrenchPostalCode\Laravel\Models: Region, Department, Commune, City and CommuneSuccession, with their relations.
use StanislasPoisson\FrenchPostalCode\Laravel\Models\City; $city = City::query()->where('postal_code', '37200')->firstOrFail(); $city->commune->name; // Tours $city->commune->department->region->name; // Centre-Val de Loire City::query()->current()->count(); // only the rows that are valid today
An application points to a postal entry with a foreign key to french_cities:
// In a migration of your application $table->foreignId('city_id')->constrained('french_cities'); // In your model public function city(): BelongsTo { return $this->belongsTo(City::class); }
Configuration
Many applications already own a cities or a regions table, so the tables of the package are prefixed. Both settings can be set in the environment, or in the configuration file published with php artisan vendor:publish --tag=french-postal-code-config:
| Setting | Environment variable | Default |
|---|---|---|
table_prefix |
FRENCH_POSTAL_CODE_TABLE_PREFIX |
french_ (an empty value gives regions, cities...) |
connection |
FRENCH_POSTAL_CODE_CONNECTION |
the default connection |
Set them before running php artisan migrate.
Compatibility
| Supported | |
|---|---|
| PHP | 8.2, 8.3, 8.4 |
| Laravel | 11 and 12 (PHP 8.2 or more), 13 (PHP 8.3 or more) |
| Databases | Those Laravel supports, with the foreign keys enforced |
Laravel 11 no longer receives security fixes, so Composer refuses to install it unless you accept its advisories; the adapter is tested on it nonetheless.
Symfony
composer require stanislas-poisson/french-postal-code
Register the bundle in config/bundles.php:
StanislasPoisson\FrenchPostalCode\Symfony\FrenchPostalCodeBundle::class => ['all' => true],
Create the tables with your usual Doctrine tooling, then load the data:
php bin/console doctrine:migrations:diff php bin/console doctrine:migrations:migrate php bin/console french-postal-code:load
The entities are mapped for you, so the migration your application generates contains the five tables, with their foreign keys. french-postal-code:load can be run again at any time: it adds the new rows, updates the others and deletes nothing, so it is also how you take a new version of the data.
The entities are in StanislasPoisson\FrenchPostalCode\Symfony\Entity: Region, Department, Commune, City and CommuneSuccession, with their relations and read-only accessors. The repositories add current(), a query builder on the rows that are valid today.
use StanislasPoisson\FrenchPostalCode\Symfony\Entity\City; $city = $entityManager->getRepository(City::class)->findOneBy(['postalCode' => '37200']); $city->getCommune()->getName(); // Tours $city->getCommune()->getDepartment()?->getRegion()?->getName(); // Centre-Val de Loire
An application points to a postal entry with a relation to City:
#[ORM\ManyToOne(targetEntity: City::class)] #[ORM\JoinColumn(nullable: false)] private City $city;
Configuration
# config/packages/french_postal_code.yaml french_postal_code: table_prefix: french_ # an empty value gives "regions", "cities"...
Many applications already own a cities or a regions table, so the tables of the package are prefixed. Set it before generating the migration. The entities live in the default entity manager, on its connection.
Compatibility
| Supported | |
|---|---|
| PHP | 8.2, 8.3, 8.4 |
| Symfony | 6.4 and 7.4 (PHP 8.2 or more), 8.0 (PHP 8.4) |
| Doctrine | ORM 3 with DBAL 4, and DoctrineBundle 2 (Symfony 6.4 and 7) or 3 (Symfony 8) |
| Databases | MySQL, MariaDB, PostgreSQL and SQLite |
Below PHP 8.4, Doctrine ORM needs symfony/var-exporter to load a relation on demand; a Symfony application with Doctrine has it already.
Keeping a reference up to date
A row is never deleted. When a commune merges or a postal code changes, the old City is closed (valid_to is set) and points to the one that replaces it, so your foreign key keeps working and always leads to a row that exists. After loading a new version of the data, you can move the references that lead to a closed city.
Laravel:
// In your model: the closed city knows its replacement $address->city->replacedBy; // City or null // Move every address that leads to a closed city to its replacement Address::query() ->whereIn('city_id', City::query()->whereNotNull('replaced_by_city_id')->select('id')) ->update(['city_id' => City::query()->select('replaced_by_city_id')->whereColumn('french_cities.id', 'addresses.city_id')]);
Symfony:
$address->getCity()->getReplacedBy(); // City or null // The same move, in SQL: adapt the names of your table and of the tables of the package (prefix included) $entityManager->getConnection()->executeStatement( 'UPDATE addresses SET city_id = (SELECT c.replaced_by_city_id FROM french_cities c WHERE c.id = addresses.city_id) WHERE city_id IN (SELECT id FROM french_cities WHERE replaced_by_city_id IS NOT NULL)' );
A replacement can itself be replaced later: run the update again until it changes nothing, or follow replacedBy in a loop. For the old INSEE codes of a commune, commune_successions gives the commune or communes that replace it, with the date and the kind of change.
Updating the data
The files of data/ are never edited by hand. They are the package archive of a release of the builder:
make data # the latest release make data VERSION=4.0.0 # a given release
The archive is checked against the SHA256SUMS file of the release before anything is replaced.
Versions
The version of the package follows SemVer. A new dataset without change of layout is a minor release, a change of the layout of the files is a major release, and a fix is a patch. The first release is 4.0.0, to follow the dataset it holds.
Development
make install make test # PHPUnit: the reader and the loader (see CONTRIBUTING.md for the adapters) make quality # Pint and PHPStan at the maximum level
See CONTRIBUTING.md, SECURITY.md and the code of conduct.
Licence
MIT for the code. The data stays subject to the licences of its sources, see the builder.