nickurt/laravel-postcodeapi
Laravel package for looking up Dutch addresses and postcodes through services like PostcodeAPI.nu and Pro6PP. Validate postcodes and fetch street, city, and coordinates with a simple, configurable API.
Install
composer require nickurt/laravel-postcodeapi
The service provider is auto-discovered. Publish the config:
php artisan vendor:publish --tag=config
# or, if tags aren't registered:
php artisan vendor:publish --provider="Nickurt\PostcodeApi\ServiceProvider"
Configure the driver
config/postcodeapi.php is driver-based — set POSTCODE_API_DRIVER and the matching credentials env vars (typically a token/key for postcodeapi, pro6pp, postcodenl, etc. — check your plan's API docs for the exact env names).
POSTCODE_API_DRIVER=postcodeapi
POSTCODE_API_TOKEN=your-token-here
First use case — autofill an address from a postcode + house number
This is the bread-and-butter flow: a user enters 1234AB and 42, you resolve street/city server-side.
use Nickurt\PostcodeApi\Facades\Postcode;
$address = Postcode::setPostcode('1234AB')
->setHouseNumber(42)
->find();
$address['street'] ?? null; // shape depends on the driver
$address['city'] ?? null;
First use case — validate a Dutch postcode
use Nickurt\PostcodeApi\Rules\PostcodeNL;
$request->validate([
'postcode' => ['required', 'string', new PostcodeNL()],
'house_number' => ['required', 'integer'],
]);
Look in vendor/nickurt/laravel-postcodeapi/src/ first — the driver classes and the rule are small and readable, and that's the fastest way to learn the response shape for the driver you actually picked.
Wrap lookups in a small domain service
Don't sprinkle the facade through controllers. A single AddressLookup service gives you one place to normalize input, cache, and swallow provider failures:
final class AddressLookup
{
public function find(string $postcode, int|string $houseNumber): ?Address
{
$postcode = strtoupper(preg_replace('/\s+/', '', $postcode));
return Cache::remember(
"postcode:{$postcode}:{$houseNumber}",
now()->addWeek(),
function () use ($postcode, $houseNumber) {
try {
$result = Postcode::setPostcode($postcode)
->setHouseNumber($houseNumber)
->find();
} catch (\Throwable $e) {
report($e);
return null;
}
return $result ? Address::fromDriverResponse($result) : null;
}
);
}
}
Drive the form with an async endpoint
The clean UX is: postcode + house number → fetch('/api/address-lookup') → populate street/city. Keep the lookup endpoint separate from the submit handler so a slow/down provider never blocks the actual form submission. Always let the user override the autofilled fields — providers occasionally disagree with reality, and users know their own address.
Use it as a rule and as enrichment, not one or the other
class StoreAddressRequest extends FormRequest
{
public function rules(): array
{
return [
'postcode' => ['required', 'string', 'max:7', new PostcodeNL()],
'house_number' => ['required', 'string', 'max:10', 'regex:/^\d+/'],
'street' => ['required', 'string', 'max:255'],
'city' => ['required', 'string', 'max:255'],
];
}
}
The rule catches obviously bogus postcodes on write; the enrichment pattern above keeps the form friendly while a user is typing.
Driver-agnostic integration
Because the package abstracts the provider, keep your own code driver-agnostic too. Don't leak provider-specific field names (street, street_name, publaw) out of your boundary — map to your own DTO/array shape once, inside your service. That way swapping POSTCODE_API_DRIVER (or moving providers due to pricing) is a config change plus one mapping function, not a codebase-wide grep.
Cache aggressively outside tests
Providers bill per lookup or rate-limit hard. Postcode → address data is effectively immutable, so a week+ TTL keyed on postcode:houseNumber costs almost nothing and removes most of your API traffic. Add a short single-flight lock (Cache::lock) if your traffic can stampede on a popular postcode.
Testing — fake at the outbound HTTP layer
The package doesn't ship a fake, so lean on Laravel's HTTP client fakes if the driver uses it (and mock the driver class if not):
Http::fake([
'api.postcodeapi.nu/*' => Http::response([
'street' => 'Damrak',
'town' => 'Amsterdam',
], 200),
]);
$this->getJson('/api/address-lookup?postcode=1012AB&number=1')
->assertOk()
->assertJsonPath('city', 'Amsterdam');
For the validation rule, test both the valid and invalid branches, plus the provider-5xx branch (see Gotchas).
Provider failures fail closed by default
If the upstream API is down or returns 500, a validation rule that calls out to it will typically fail — meaning a valid address is rejected because someone else's server is having a bad day. Decide deliberately: catch provider errors inside a custom rule and return true (accept, verify later) rather than letting the exception bubble into a 500. Don't let an external dependency become your availability.
Normalize before you send
1012 AB → 1012AB.[1-9][0-9]{3}[A-Z]{2} — cheap regex check locally before you spend a paid API call.$isWellFormed = preg_match('/^[1-9][0-9]{3}[A-Z]{2}$/', $normalized) === 1;
House number "toevoeging"
42a, 42-1, 42 bis are real and common. If your form has a separate house_number_addition field, concatenate into the single string the provider expects (format depends on driver), and expect lookups to return a different street for the same number with different suffixes. Don't cast house_number to int — you'll silently drop the suffix the user typed.
Config cache will bite you
php artisan config:cache in a deploy means env changes to POSTCODE_API_DRIVER / token do nothing until you re-cache. If production behaves differently from staging, this is the first thing to check.
Facade alias collisions
Registering Postcode as a root alias is fine in isolation but collides with other address/postcode packages. Where possible use the FQCN or inject the underlying client instead of relying on the alias.
Response shape is driver-specific
The find() result differs meaningfully across postcodeapi, pro6pp, and the others — field names, nesting, and null handling all vary. Write your mapper against the driver you've actually configured, and cover it with a test that asserts the mapped shape (not the raw provider payload). If you support multiple drivers simultaneously, that mapping layer is not optional — it's the whole point.
Version compatibility
The package targets Laravel 11/12/13. Laravel 13 is not released yet, so on any current project you're relying on Laravel 11 or 12 — pin the package with a caret constraint and test the upgrade path when you bump Laravel, since HTTP client and container changes are the usual breakage points here.
Extension points
driver key is resolved.PostcodeNL if you need extra semantics (e.g., accept-and-flag rather than reject).composer update.Don't log raw lookups with full addresses
Postcode + house number is personally identifiable address data under GDPR/AVG. Your report($e) in the catch block is fine, but be careful attaching the request payload to exception context or debug logging.
How can I help you explore Laravel packages today?