juststeveking/laravel-postcodes
Laravel Postcodes offers a simple integration with postcodes.io for UK postcode data. Validate postcodes, retrieve location details, and add postcode lookups to Laravel apps through an expressive API. Ideal for address forms, delivery checks, and location-aware features.
Install and boot
composer require juststeveking/laravel-postcodes
There is no API key, no account, and no dashboard. postcodes.io is a free public service. The service provider is auto-discovered, so a composer require is a working install.
Publish the config so it is versioned in your repo rather than hidden in vendor/:
php artisan vendor:publish --provider="JustSteveKing\LaravelPostcodes\PostcodesServiceProvider"
(Tag names vary by release — run php artisan vendor:publish and pick the package's entry from the interactive list on the version you install.)
Where to look first, in order
config/postcodes.php (post-publish) — base URL, HTTP client bindings, timeouts if exposed.src/ — it is a small package; read the whole directory in ten minutes. You want to know exactly: which endpoints are wrapped, how errors surface, and whether it uses Laravel's Http client.First use case: normalise and validate at the edge
The highest-value first path is not geocoding — it is stopping garbage before it hits the database. Put it in a form request:
// app/Http/Requests/StoreAddressRequest.php
public function rules(): array
{
return [
'postcode' => ['required', 'string', 'max:8', new ValidPostcode()],
];
}
protected function prepareForValidation(): void
{
$this->merge([
'postcode' => strtoupper(preg_replace('/\s+/', '', (string) $this->postcode)),
]);
}
The ValidPostcode rule wraps the package; canonicalise to uppercase-no-spaces on the way in and store that canonical form as your lookup key. Getting normalisation consistent on day one saves you a duplicate-key cleanup later.
Second use case: enrich an address record
Once validation works, the natural next step is attaching admin_district, region, latitude, longitude to the address row at save time (or in a queued job — see Gotchas). That single step unlocks delivery-zone checks and segmentation without any further integration work.
The package is a thin adapter, not a platform. Put your own seam in front of it on day one; it costs a few hours and is the difference between a config change and a rewrite when you outgrow the free API.
interface PostcodeResolver
{
public function lookup(string $postcode): ?PostcodeDetails; // null = unknown postcode
public function nearest(float $lat, float $lng, int $limit = 5): array;
public function reverse(float $lat, float $lng): array;
}
final class PostcodesIoResolver implements PostcodeResolver
{
public function __construct(private readonly Postcodes $client) {}
public function lookup(string $postcode): ?PostcodeDetails
{
try {
$result = $this->client->get(Postcode::normalise($postcode));
} catch (PostcodeNotFound $e) {
return null;
}
return new PostcodeDetails(
postcode: $result->postcode(),
district: $result->adminDistrict(),
region: $result->region(),
lat: $result->latitude(),
lng: $result->longitude(),
);
}
}
Method names on the package DTOs differ between releases — verify against src/ on your installed version and treat your wrapper's signature as the contract you actually maintain. Domain and API code should never import the package namespace; enforce that with a quick grep -r "LaravelPostcodes" app/ in CI.
Postcode geography is effectively immutable. Every uncached call is a network round-trip to a service with no SLA. Cache by canonical postcode:
public function lookup(string $postcode): ?PostcodeDetails
{
$key = 'pc:' . Postcode::normalise($postcode);
return Cache::remember($key, now()->addMonths(3), fn () => $this->resolve($postcode));
}
Negative results (unknown postcodes) are worth a shorter TTL — say 7 days — so a newly issued postcode eventually resolves but typo'd input does not hammer the upstream.
Backfilling an existing orders or CRM table is the most common second task. The bulk endpoint caps at 100 postcodes per call; chunk to 100, dispatch as a queued job, and let the cache absorb repeats.
Order::whereNull('admin_district')
->select('id', 'postcode')
->chunkById(100, fn ($orders) => EnrichOrders::dispatch($orders));
Inside the job, deduplicate postcodes before calling — a thousand orders in three postcodes should be one lookup.
Never let a postcode lookup block a checkout. Set an explicit timeout and decide the failure behaviour per surface:
Http::timeout(2)->retry(2, 200, throw: false);
Use bounded retries with jitter only for idempotent GETs, and only where a retry cannot double the customer's wait.
If the package uses Laravel's Http client, Http::fake() is your whole test story — no network in CI, no flaky suite. Fake the underlying endpoints once in a base test case and everything downstream is deterministic. Your own wrapper interface also lets you bind a fake PostcodeResolver in feature tests, which is faster and keeps fixtures out of unrelated tests.
| Need | Pattern |
|---|---|
| Checkout address form | Validate on submit, enrich at save, cache the result |
| "Do we deliver here?" | Validate + district/outcode gate, before cart commit |
| Store locator | Reverse geocode browser coords, or nearest-postcode from the user's postcode |
| Tax / pricing / territory | Map admin_district / ward to internal bands, resolved via the wrapper |
| CRM / analytics backfill | Queued batch job, 100 per call, dedupe first |
It is postcode-level data, not addresses. postcodes.io does not serve Royal Mail PAF. You get centroid, district, ward, LSOA — no house number, no building. If a stakeholder has asked for "type your postcode, pick your house", this package gets you roughly 40% of the way and the rest is a licensed provider. Establish that expectation before an estimate is written.
There is no SLA. Free, community-run, no contract, no uptime guarantee. Design every call site so that the API being down is survivable. This is not pessimism; it is the actual operational reality of a free dependency.
New-build postcodes return not-found. Postcodes.io is periodically refreshed from ONS data, but recent developments lag. A 404 is not always an invalid postcode — it may be a valid postcode the dataset has not caught up with. Offer a manual address entry escape hatch, and log 404s so you can spot a cluster in a new housing estate rather than misreading it as user error.
Terminated postcodes. If the package exposes terminated lookups, use them for historical data reconciliation; don't let a terminated postcode silently validate as current.
Error modelling is the thing to read the source for. You need to distinguish, in code: (a) unknown postcode → 404, (b) upstream failure → 5xx, (c) timeout/connection error. If the package throws a single exception type for all three, wrap it immediately and normalise before it reaches your domain. Treating a 500 as "invalid postcode" will reject real customers during an outage.
Caching is not built in. Assume you must add it. It is the single highest-leverage piece of work you will do with this package: it removes latency, removes rate-limit pressure, and gives you a degraded-mode answer when upstream is down.
config:cache discipline. Publish the config, read everything through config(), and confirm php artisan config:cache works in your deploy. Small packages are the usual suspects for stray env() calls that break in production and nowhere else.
Base URL must be configurable. Confirm it is. You want CI pointing at a local stub and, later, production optionally pointing at an internal proxy that centralises caching and rate limiting across services.
Logging leaks. If you have global HTTP logging middleware (Laravel Telescope, an outbound request logger, an APM integration), postcode lookups will flow through it. Postcode alone is largely non-personal; postcode plus name plus address sent to a third party is personal data. Do a short privacy sign-off and consider excluding these requests from verbose log capture.
Facade in queued jobs. The facade resolves from the container fine in jobs, but injecting your PostcodeResolver is more testable and survives a container rebuild in a long-running worker. Prefer injection.
Bulk endpoint limits. 100 per request. Chunk, deduplicate, and queue.
Bus factor. Zero stars, zero dependents, one maintainer. Pin the version in composer.json, review upstream quarterly, name an internal owner, and keep the fork rebase-friendly by keeping the package out of your domain code. The exit cost stays low only if you enforce the seam.
Version compatibility first. Check the installed Laravel version against the package constraint before estimating anything. The 4.1.0 release added Laravel 12 support, which is a good sign, but version drift in small packages is the most common cause of a slipped spike.
How can I help you explore Laravel packages today?