joshdovey / postcodes
Laravel client for the GB Postcodes API โ lookup, autocomplete, boundaries, distance, radius, reverse geocoding and ONSPD geography.
Requires
- php: ^8.2
- illuminate/contracts: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.13
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^2.0|^3.0|^4.0
README
The Laravel client for the GB Postcodes API. Lookup, autocomplete, boundaries, distance, radius search, reverse geocoding, and full ONSPD geography โ as typed objects, not arrays.
use JoshDovey\Postcodes\Facades\Postcodes; $postcode = Postcodes::get('SW1A 1AA'); $postcode->latitude(); // 51.501009 $postcode->longitude(); // -0.141588 $postcode->geography->wardName; // 'St James's' $postcode->geography->localAuthorityName; // 'Westminster' $postcode->population->residents(); // 14 $postcode->boundary; // GeoJSON Polygon, ready for a map
- ๐งพ Typed objects everywhere โ
$postcode->geography->localAuthorityName, not$postcode['geography']['local_authority_name'] - โ A real validation rule โ checks postcodes exist, not just that they look plausible, with a safe fallback if the API is down
- โก Caches by default โ postcode geography barely changes, so most repeat lookups cost no request at all
- ๐ Retries with backoff, honouring
Retry-After, so transient failures don't become your problem - ๐งญ Everything geospatial โ boundary polygons, radius and polygon search, reverse geocoding, distance matrices
- ๐งช A first-class fake โ stub every endpoint and assert on what was sent, no HTTP in your test suite
Contents
- Installation
- Quickstart
- Looking up postcodes
- Validating postcodes
- Geography and demographics
- Distance and proximity
- Maps and boundaries
- Trimming responses with
fields - Caching
- Error handling
- Rate limits and quotas
- Testing
- Artisan commands
- Configuration reference
Installation
composer require joshdovey/postcodes
Add your key and the API origin to .env:
POSTCODES_API_KEY=your-key POSTCODES_API_URL=https://postcodes-api.co.uk
POSTCODES_API_URL is the origin the API is served from; the /api path is appended for you. Publish the config file if you want to change anything else:
php artisan vendor:publish --tag=postcodes-config
Check it works:
php artisan postcodes:lookup "SW1A 1AA"
Requirements: PHP 8.2+ and Laravel 12 or 13.
Quickstart
Use the facade, or inject PostcodesClient โ they are the same singleton:
use JoshDovey\Postcodes\Facades\Postcodes; use JoshDovey\Postcodes\PostcodesClient; class CheckoutController { public function __construct(private PostcodesClient $postcodes) {} public function store(Request $request) { $validated = $request->validate([ 'postcode' => ['required', ValidPostcode::unit()], ]); $postcode = $this->postcodes->getOrFail($validated['postcode']); return Order::create([ 'postcode' => $postcode->postcode, 'latitude' => $postcode->latitude(), 'longitude' => $postcode->longitude(), 'local_authority' => $postcode->geography->localAuthorityName, ]); } }
Looking up postcodes
One postcode
$postcode = Postcodes::get('sw1a1aa'); // spacing and case do not matter $postcode?->postcode; // 'SW1A 1AA' โ canonically spaced $postcode?->type; // PostcodeType::Unit $postcode?->center; // Coordinates {latitude, longitude} $postcode?->areaSqKm; // 0.0134 $postcode?->raw; // the untouched API payload
get() returns null when nothing matches. Use getOrFail() to throw a NotFoundException instead:
$postcode = Postcodes::getOrFail('SW1A 1AA');
Autocomplete
Partial postcodes return everything inside them, which is what a typeahead wants:
Route::get('/postcodes/suggest', function (Request $request) { return Postcodes::autocomplete($request->query('q'), fields: ['name', 'center']) ->map(fn ($postcode) => [ 'label' => $postcode->postcode, 'lat' => $postcode->latitude(), 'lng' => $postcode->longitude(), ]); });
autocomplete() always returns a Collection, whether the query resolved to one postcode or a whole sector.
Many at once
$results = Postcodes::search(['SW1A 1AA', 'M1 1AE', 'nonsense']); $results['SW1A1AA']->latitude(); // Postcode $results['NONSENSE']; // null โ did not resolve $found = $results->filter(); // drop the misses
Results are keyed by the normalized code (uppercase, no spaces). The API takes 100 codes per request; longer lists are split and merged for you.
Validating postcodes
A postcode that looks right is not necessarily real. The ValidPostcode rule checks the shape locally first, then confirms the postcode exists:
use JoshDovey\Postcodes\Rules\ValidPostcode; $request->validate([ 'billing_postcode' => ['required', new ValidPostcode], // any level: SW, SW1A, SW1A 1AA 'delivery_postcode' => ['required', ValidPostcode::unit()], // a full postcode only ]);
There is a string rule too:
$request->validate([ 'postcode' => 'required|postcode', 'delivery_postcode' => 'required|postcode:unit', ]);
Anything that cannot be a postcode is rejected without a request, so typos do not cost you API calls. If the API is unreachable, the rule falls back to the format check and lets the value through โ an outage should not break your checkout. Fail closed instead, per rule or globally:
(new ValidPostcode)->strict(); // this rule // config/postcodes.php โ 'validation' => ['strict' => true]
Outside the validator:
Postcodes::isValid('SW1A 1AA'); // true Postcodes::normalize('sw1a1aa'); // 'SW1A 1AA', or null if it does not exist $result = Postcodes::validate('SW1A'); $result->valid; // true $result->type; // PostcodeType::District $result->isUnit(); // false
For local formatting with no API call at all:
use JoshDovey\Postcodes\Support\PostcodeFormat; PostcodeFormat::normalize(' sw1a 1aa '); // 'SW1A1AA' PostcodeFormat::pretty('sw1a1aa'); // 'SW1A 1AA' PostcodeFormat::looksLikeUnit('SW1A 1AA'); // true
Geography and demographics
Every lookup carries ONSPD administrative geography, or ask for just that:
$postcode = Postcodes::geography('SW1A 1AA'); $postcode->geography->localAuthorityName; // 'Westminster' $postcode->geography->wardName; // 'St James's' $postcode->geography->parliamentaryConstituencyName; // 'Cities of London and Westminster' $postcode->geography->lsoaCode; // 'E01004736' $postcode->geography->nhsIcbName; // 'NHS North West London' $postcode->geography->policeForceAreaName; // 'Metropolitan Police' $postcode->geography->ruralUrbanClassification; // 'A1'
Population comes from the census and mid-year estimates:
$postcode->population->censusResidents; // 12 $postcode->population->censusHouseholds; // 5 $postcode->population->estimatedResidents; // 14 $postcode->population->residents(); // 14 โ the estimate, falling back to the census
And across a whole catchment:
$catchment = Postcodes::radiusPopulation('SW1A 1AA', radius: 5); $catchment->residents(); // 191004 $catchment->unitCount; // 9412 postcodes summed $catchment->householdSize(); // 2.19
Distance and proximity
$distance = Postcodes::distance('SW1A 1AA', 'M1 1AE'); $distance->distance; // 262.4155 $distance->unit; // DistanceUnit::Kilometres $distance->miles(); // 163.06 $distance->metres(); // 262415.5
Everything that measures distance takes a unit, or set one for the whole client:
Postcodes::distance('SW1A 1AA', 'M1 1AE', 'miles'); Postcodes::inMiles()->distance('SW1A 1AA', 'M1 1AE');
Within a radius
$nearby = Postcodes::radius('SW1A 1AA', radius: 2, limit: 100); foreach ($nearby as $postcode) { echo $postcode->postcode.' โ '.$postcode->distance.' '.$nearby->unit->value; } $nearby->closest(); // the nearest Postcode $nearby->codes(); // Collection of postcode strings $nearby->count();
Nearest neighbours
$nearest = Postcodes::nearest('SW1A 1AA', count: 10);
A distance matrix
$matrix = Postcodes::matrix(['SW1A 1AA', 'M1 1AE', 'EH1 1YZ']); $matrix->between('SW1A 1AA', 'M1 1AE'); // 262.4155 โ spacing and case do not matter $matrix->closestTo('SW1A 1AA')->to; // 'M11AE' $matrix->toGrid(); // ['SW1A1AA' => ['M11AE' => 262.4155, ...], ...]
Between 2 and 20 postcodes, checked before the request goes out.
Maps and boundaries
Reverse geocoding
$postcode = Postcodes::reverse(latitude: 51.501009, longitude: -0.141588);
Latitude comes first, and a coordinate outside Great Britain is rejected before a request is spent on it. Returns null when the coordinate falls outside every boundary (reverseOrFail() throws instead). Resolve a coarser level with type:
Postcodes::reverse(51.501009, -0.141588, PostcodeType::District);
Inside a drawn shape
Hand it a GeoJSON Polygon, MultiPolygon, or Feature โ a delivery zone, a sales territory, a shape drawn on a map:
$postcodes = Postcodes::within($geoJsonFromYourMap, limit: 500);
Boundaries for a map layer
$features = Postcodes::radius('SW1A 1AA', 1) ->postcodes ->map->toFeature(); return ['type' => 'FeatureCollection', 'features' => $features];
toFeature() gives you a GeoJSON Feature with the boundary as geometry โ or the centroid when no boundary was requested โ and the rest of the record as properties.
Postcode areas (the letters at the front โ SW, M, EH) come from a public endpoint that needs no key:
Postcodes::areas(); // all of them Postcodes::areas(bbox: [-0.5, 51.2, 0.3, 51.7]); // just the ones in view
Trimming responses with fields
Boundary geometry is most of a default response. Drop what you do not need:
use JoshDovey\Postcodes\Enums\Field; Postcodes::get('SW1A 1AA', [Field::Center, Field::WardName]); Postcodes::get('SW1A 1AA', 'center,ward_name'); // strings work too Postcodes::withFields(Field::Center)->autocomplete('SW1A'); // for every call Postcodes::withoutBoundaries()->get('SW1A 1AA'); // everything but the polygon
name always comes back. Unknown field names are rejected locally, with the allowed list in the message, rather than costing you a 400.
Caching
Postcode geography barely changes, and the API caches every read for a day server-side, so responses are cached locally too โ most lookups become no request at all. On by default for a day:
Postcodes::cacheFor(3600)->get('SW1A 1AA'); // this client, one hour Postcodes::withoutCache()->get('SW1A 1AA'); // straight to the API
Only successful responses are stored; errors and the health check never are. Cache keys ignore spacing, case, and query ordering, so sw1a1aa and SW1A 1AA share an entry. Turn it off entirely, or point it at a specific store, in the config file.
Error handling
Every failure throws a subclass of PostcodesException, so you can catch broadly or precisely:
use JoshDovey\Postcodes\Exceptions\{ PostcodesException, AuthenticationException, ForbiddenException, BadRequestException, NotFoundException, RateLimitException, ServerException, TimeoutException, ConnectionException, }; try { $postcode = Postcodes::getOrFail($input); } catch (RateLimitException $e) { if ($e->isQuotaExceeded()) { // Plan quota gone: $e->period(), $e->limit(), $e->used() return response('Come back next month', 503); } return response('Slow down', 429, ['Retry-After' => $e->retryAfter()]); } catch (AuthenticationException $e) { Log::critical('Postcodes API key rejected', ['message' => $e->getMessage()]); } catch (PostcodesException $e) { $e->status(); // HTTP status, or null for a connection failure $e->body(); // the parsed error body $e->url(); // the URL that failed }
| Exception | When |
|---|---|
BadRequestException |
400/422, plus the checks this client makes before sending |
AuthenticationException |
401 โ missing, unrecognised, or expired key |
ForbiddenException |
403 โ key restricted by IP, scope, or demo limits |
NotFoundException |
404 โ the postcode or coordinate did not resolve |
RateLimitException |
429 โ burst limit or plan quota |
ServerException |
5xx |
TimeoutException |
the request took too long |
ConnectionException |
the request never arrived (DNS, TLS, offline) |
UnexpectedResponseException |
a 2xx whose body was not JSON โ usually a proxy page |
ConfigurationException |
no base URL or key configured |
Retries cover the failures that clear on their own โ timeouts, dropped connections, burst 429s, and 5xxs โ twice by default, backing off exponentially with jitter and deferring to a Retry-After header when the API sends one. A blown plan quota is never retried, because it will not clear before the window rolls over.
Postcodes::withRetries(5)->get('SW1A 1AA'); Postcodes::withoutRetries()->get('SW1A 1AA'); // fail fast Postcodes::withTimeout(30)->within($hugePolygon);
Rate limits and quotas
Read where your key stands after any call, and back off before you get throttled:
$state = Postcodes::rateLimit(); $state?->remaining; // requests left this minute $state?->isNearlyExhausted(); // within 10% of the limit $state?->hasQuotaWarning(); // past 80% of the plan quota $state?->isExpiringSoon(); // key expires within a week
Or be told as it happens:
Postcodes::onRateLimit(function ($state) { if ($state->hasQuotaWarning()) { Log::warning('Postcodes quota running low', $state->toArray()); } })->get('SW1A 1AA');
Serving several tenants, each with their own key:
Postcodes::withApiKey($tenant->postcodes_key)->get($postcode);
Testing
Postcodes::fake() swaps the client for one that answers from stubs and records every call. Nothing leaves the process, and anything resolving the client from the container gets the fake too.
use JoshDovey\Postcodes\Facades\Postcodes; use JoshDovey\Postcodes\Testing\Fixtures; it('stores the coordinates of a delivery address', function () { $fake = Postcodes::fake([ 'v1/postcodes/*' => Fixtures::postcode('SW1A 1AA'), ]); $this->post('/orders', ['postcode' => 'SW1A 1AA'])->assertCreated(); $fake->assertSent('v1/postcodes/SW1A1AA'); expect(Order::sole()->latitude)->toBe(51.501009); });
Stub patterns take * wildcards, and a closure gets the request:
Postcodes::fake([ 'v1/postcodes/validate*' => Fixtures::validation('SW1A 1AA'), 'v1/postcodes/radius*' => Fixtures::radius(['SW1A 2AA' => 0.35]), 'v1/postcodes/*' => fn (ApiRequest $request) => Fixtures::postcode( str_replace('v1/postcodes/', '', $request->path), ), ]);
Test your error handling by stubbing a failure:
Postcodes::fake([ '*' => new RateLimitException('Quota exceeded', status: 429, body: ['period' => 'month']), ]);
Assertions:
$fake->assertSent('v1/postcodes/radius*'); $fake->assertSent(fn (ApiRequest $r) => $r->hasQuery('unit', 'miles')); $fake->assertNotSent('v1/postcodes/reverse'); $fake->assertSentCount(2); $fake->assertNothingSent(); $fake->recorded(); // Collection<ApiRequest>
Fixtures builds realistic payloads for every endpoint โ postcode(), postcodes(), search(), validation(), radius(), population(), matrix(), within(), area(), polygon(), health() โ each overridable.
Because the client is built on Laravel's HTTP client, plain Http::fake() works too if you would rather assert at that level.
Artisan commands
php artisan postcodes:lookup "SW1A 1AA" # a table of what the API knows php artisan postcodes:lookup "SW1A 1" # every postcode in the sector php artisan postcodes:lookup "SW1A 1AA" --fields=center # trim the payload php artisan postcodes:lookup "SW1A 1AA" --json # the raw response php artisan postcodes:health # exits non-zero when degraded
Configuration reference
| Key | Env | Default | What it does |
|---|---|---|---|
api_key |
POSTCODES_API_KEY |
โ | Sent as X-API-Key |
base_url |
POSTCODES_API_URL |
https://postcodes-api.co.uk |
API origin; /api is appended |
timeout |
POSTCODES_TIMEOUT |
10 |
Seconds; 0 disables |
connect_timeout |
POSTCODES_CONNECT_TIMEOUT |
5 |
Seconds |
retries |
POSTCODES_RETRIES |
2 |
Retries after the first attempt |
retry_delay |
POSTCODES_RETRY_DELAY |
500 |
Base backoff, ms, doubled per attempt |
max_retry_delay |
POSTCODES_MAX_RETRY_DELAY |
30000 |
Ceiling for one wait, ms |
cache.enabled |
POSTCODES_CACHE |
true |
Cache successful responses |
cache.store |
POSTCODES_CACHE_STORE |
default store | Which cache store to use |
cache.ttl |
POSTCODES_CACHE_TTL |
86400 |
Seconds |
defaults.unit |
POSTCODES_UNIT |
km |
km or miles |
defaults.detail |
POSTCODES_DETAIL |
high |
Boundary simplification |
defaults.fields |
โ | null |
Default field list for every call |
validation.register_rule |
โ | true |
Register the postcode string rule |
validation.strict |
โ | false |
Fail validation when the API is unreachable |
Method reference
| Method | Endpoint |
|---|---|
get($postcode, $fields, $detail) |
GET /v1/postcodes/{postcode} |
getOrFail(...) |
as above, throwing when missing |
autocomplete($partial, $fields, $detail) |
GET /v1/postcodes/{postcode} |
search($postcodes, $fields) |
GET /v1/postcodes/search |
validate($postcode) ยท isValid() ยท normalize() |
GET /v1/postcodes/validate |
geography($postcode, $fields) ยท geographyOrFail() |
GET /v1/postcodes/{postcode}/geography |
reverse($lat, $lng, $type, $fields) ยท reverseOrFail() |
GET /v1/postcodes/reverse |
distance($from, $to, $unit) |
GET /v1/postcodes/distance |
radius($postcode, $radius, $unit, $limit, $fields) |
GET /v1/postcodes/radius |
radiusPopulation($postcode, $radius, $unit) |
GET /v1/postcodes/radius/population |
nearest($postcode, $count, $unit, $fields) |
GET /v1/postcodes/nearest |
matrix($postcodes, $unit) |
GET /v1/postcodes/matrix |
within($polygon, $limit, $fields) |
POST /v1/postcodes/within |
areas($bbox, $limit, $fields) |
GET /v1/areas |
health() |
GET /health |
Also available
- JavaScript / TypeScript:
@josh-dovey/postcodesโ the same API, same method names, for browser and Node.
Testing this package
composer install
composer test
composer lint
Credits
License
MIT. See LICENSE.md.