awcodes/postal-codes
A Laravel package for working with postal codes. It provides simple helpers and validation rules to format, normalize, and verify postal codes, helping you keep address data clean and consistent across your applications.
Install, publish, migrate. This is a reference-data package: a service provider, publishable config, migration, Eloquent model, and an import path for GeoNames TSV dumps. There is no third-party API call at runtime — you own the data.
composer require awcodes/postal-codes
php artisan vendor:publish --tag="postal-codes-config"
php artisan vendor:publish --tag="postal-codes-migrations"
php artisan migrate
If a tagged publish returns nothing, run php artisan vendor:publish bare and pick from the list — tags drift between versions.
Read three files before writing a line of application code. They answer most of the questions you'll have all week:
config/postal-codes.php — table name, connection, country selection for imports.lat/lng compile to a true double on your driver (see Gotchas).Find the import command before anything else. It ingests GeoNames TSV exports. php artisan list | grep -i postal will show it in the postal-codes namespace. Import one small country first, never the world:
DB::table(config('postal-codes.table', 'postal_codes'))
->where('country_code', 'NL')
->count();
Check your framework floor. maatwebsite/excel ^4.0 requires illuminate/support ^12.0 || ^13.0, so this package is Laravel 12/13 only. Laravel 10 and 11 can't install it. Verifying that takes thirty seconds and saves an afternoon.
First use case: validate a checkout postal code and resolve a region. Keep format validation and dataset lookup as two separate jobs — this is the single most important early architectural decision:
// 1. Format-only validation. Never blocks on dataset coverage.
$request->validate([
'postal_code' => ['required', 'string', 'max:16'],
]);
// 2. Known-code lookup. Advisory, never a hard gate.
$place = DB::table(config('postal-codes.table', 'postal_codes'))
->where('country_code', $request->country)
->where('postal_code', normalizePostalCode($request->country, $request->postal_code))
->first();
$taxRegion = $place?->admin1_code;
GeoNames coverage and freshness vary per country. A code missing from your imported slice is not proof of a bad address, and a paying customer should never be blocked by a data dump.
Put the package behind your own interface. Five stars, zero dependents, a single maintainer. Controllers, form requests, and Blade templates should never reference the package model directly.
interface PostalCodeRepository
{
public function find(string $country, string $code): ?PostalCodeData;
public function hasCountry(string $country): bool;
public function near(float $lat, float $lng, float $radiusKm, ?string $country = null): Collection;
}
Bind it in a service provider and confine the package import to one adapter class. When you later swap in a geocoder or a paid address service, only the binding changes — and you return your own DTO, so nothing ripples.
Store a normalized column next to the raw input. Normalization is country-dependent: Canada and the UK use an internal space (K1A 0B1, SW1A 1AA), the Netherlands uses 1234 AB, Germany has none. Keep postal_code as typed (or as GeoNames publishes it) and postal_code_normalized uppercased with separators stripped, then index the normalized column. Never put a postal code in an integer column — 01234 loses its leading zero silently.
Always query by (country_code, postal_code) together. One code can map to many rows (suburbs, PO boxes, multiple localities), and identical formats exist across unrelated countries. A bare first() on postal_code alone is a recurring source of "wrong region" bugs. Decide explicitly whether the feature takes the first row or returns a collection.
Import by market, not worldwide. Per-country GeoNames files range from a few thousand to tens of thousands of rows; a global load wrecks import time, table size, and index tuning for a shop that serves three countries. Make country selection a config value or command argument, and treat "add a new market" as a repeatable runbook step rather than a schema event.
Chunk and bypass Eloquent. Large files will OOM through models. Use raw chunked inserts inside a transaction:
DB::table('postal_codes')->insertOrIgnore($chunk); // 1k–5k rows per chunk
Wrap the run in a transaction or a staging-table swap so a mid-import failure doesn't leave a half-populated country in production.
Cache lookups — including misses. Postal-code to locality/admin-area mappings are immutable between imports, which makes them ideal cache entries. Cache negative results too, or a bot probing random codes will pin your database.
return Cache::remember("pc:{$country}:{$normalized}", now()->addDay(), fn () =>
$this->find($country, $normalized)
);
Key by an import version or timestamp so a refresh invalidates the cache automatically rather than via cache:clear archaeology.
For proximity, prefilter with a bounding box before haversine. Since v1.0.2 stores full double precision, centroid distance is defensible — but naive haversine over the table is a full scan.
$delta = $radiusKm / 111.0;
$dLng = $delta / cos(deg2rad($lat));
DB::table('postal_codes')
->whereBetween('lat', [$lat - $delta, $lat + $delta])
->whereBetween('lng', [$lng - $dLng, $lng + $dLng])
->get();
Then filter by great-circle distance in PHP or in the query.
Schedule a refresh with a version record. The package does not maintain freshness for you. A monthly or quarterly job that downloads the TSV, imports into staging, verifies row counts against the previous run, then swaps — keeps this honest. Log the source file date so you can answer "how current is our data?" during an incident.
Test against fixtures, not the real dataset. Seed a tiny fixed table: three Dutch codes, one with a space, one that doesn't exist. Tests depending on a live import are slow, non-deterministic, and break the moment someone refreshes the source.
Instrument the miss rate per country. A climbing miss rate in a supported market is your earliest signal that coverage is thin or the import slice is wrong — and it's the metric that decides keep-vs-replace at the next review.
v1.0.2 fixed silently rounded coordinates — but the migration alone won't repair existing rows. Laravel 11 narrowed Blueprint::float() to float($column, $precision = 53). The package's float('lat', 10, 8) therefore discarded the 8 decimal places and set precision to 10, which lands in the single-precision band: 4-byte float on MySQL, real on PostgreSQL, 4-byte real on SQL Server — roughly 7 significant digits. 40.71277800 cannot survive that. Pin ^1.0.2. If you're already on v1.0.1 or earlier, treat the upgrade as a data operation: widen the columns and re-import. An ALTER restores no digits, because the loss happened at insert time.
Verify the DDL on your production driver. The test suite runs on SQLite, which emits a bare float and stores a full double regardless — so it structurally cannot catch this class of bug. Check the generated schema yourself:
-- MySQL
SHOW CREATE TABLE postal_codes; -- expect `double`, not `float`/`real`
-- PostgreSQL
SELECT data_type FROM information_schema.columns
WHERE table_name = 'postal_codes' AND column_name = 'lat'; -- expect double precision
Add a schema assertion against a real driver in CI, or at minimum a deploy checklist line. "Tests pass" proves nothing here.
The framework floor is transitive and non-negotiable. maatwebsite/excel ^4.0 pulls illuminate/support ^12.0 || ^13.0. A lookup table dragging in a spreadsheet library is heavier than "thin provider" implies — and that library, not the package code, pins your Laravel version.
GeoNames data is CC BY 4.0 and requires attribution. That obligation flows to your product surface (about page, docs, API metadata). Get sign-off before the first production import, not after.
Some markets have no postal codes at all. Ireland, Hong Kong, the UAE, and others don't use them. A required rule on the field blocks legitimate checkouts in those markets. Gate on your own market config rather than on the validation rule alone.
A hit is not a valid address; a miss is not an invalid one. GeoNames tells you which codes exist and roughly where. It says nothing about deliverability or street-level accuracy. Keep the known-code check advisory and never hard-fail a customer on a dataset miss — log and continue.
Centroids are city-level, not rooftop. Even with precision restored, these are place centroids. Fine for depot/zone assignment, distance sorting, regional map pins. Not fine for anything a customer reads as "we know where you are," and not a substitute for a geocoder.
Import memory and timeouts. A large country file through Eloquent will OOM. Chunk it, insert raw, run it in a queued job or with an explicit memory limit — never inline in a deploy script that can time out halfway through.
Debugging "code not found," in order: (1) confirm the country was actually imported — count() grouped by country_code; (2) confirm your normalization matches the stored value — spaces, case, leading zeros; (3) check whether the source file contains the code at all. Nine times in ten it's normalization or a missing country slice.
Pin the version and keep the exit path warm. Five stars, zero dependents, one maintainer. Pin ^1.0.2, isolate behind the repository interface, and return your own DTOs. The v1.0.2 release says good things about the maintainer's responsiveness, but it doesn't change the adoption math — assume you may eventually fork or replace, and make that a one-file change.
How can I help you explore Laravel packages today?