joshdovey/postcodes

Laravel client for the GB Postcodes API โ€” lookup, autocomplete, boundaries, distance, radius, reverse geocoding and ONSPD geography.

Maintainers

Package info

github.com/Josh-Dovey/postcodes-laravel

pkg:composer/joshdovey/postcodes

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-08-22 09:20 UTC

This package is auto-updated.

Last update: 2026-08-22 09:24:45 UTC


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.

Latest Version on Packagist Tests Total Downloads license: MIT

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

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.