Mexican postal codes in a Laravel application
One service class, one cache decision and one validation rule. Laravel already ships everything else you need, and two of its conveniences will quietly work against you here — both are below.
Laravel 13.25 · PHP 8.5 · run on 2026-08-17
Put the key in configuration, not in a class
The key belongs in the environment and the base URL belongs beside it, so a test can point the whole integration somewhere else without touching a line of code.
.env
POSTALKIT_KEY=your-api-key POSTALKIT_BASE=https://api.postalkit.mx
config/services.php
'postalkit' => [
'key' => env('POSTALKIT_KEY'),
'base' => env('POSTALKIT_BASE', 'https://api.postalkit.mx'),
],
Never call env() outside a config file. Once you run config:cache in production every env() call outside config/ returns null, and the failure looks like an authentication problem rather than a caching one.
The request builder
Everything the integration sends looks the same, so build it in one place: base URL, token, JSON, a timeout so a slow network cannot hold a web worker open, and a retry policy that is narrower than it looks.
app/Services/PostalKit.php
private function request(): PendingRequest
{
return Http::baseUrl(config('services.postalkit.base'))
->withToken(config('services.postalkit.key'))
->acceptJson()
->timeout(5)
->retry(
times: 3,
sleepMilliseconds: 200,
when: fn (Throwable $e): bool => $e instanceof ConnectionException
|| ($e instanceof RequestException && $e->response->serverError()),
throw: false,
);
}
Both of the arguments after the sleep are load-bearing, and leaving either one out is the mistake almost everybody makes first.
-
Without when, retry() retries everything.
A postal code that does not exist answers 404, and a bare retry will ask three more times before giving you the same answer. The same is true of a quota that has run out. Retry a connection failure and a 5xx; nothing else is going to change its mind.
-
Without throw: false, your own 404 branch never runs.
As soon as more than one attempt is configured, the client throws on any failed response by default — before returning to you. The if that was going to check for 404 sits there looking correct and is never reached.
Cache it, and cache the misses too
The catalog is re-checked against the source once a month and nothing moves unless the source moved, so a week is a conservative TTL. The interesting half is what you do when there is nothing to cache.
public function postalCode(string $code): ?array
{
$record = Cache::remember(
"postalkit:code:{$code}",
now()->addWeek(),
fn (): array|false => $this->fetch($code) ?? false,
);
return $record ?: null;
}
private function fetch(string $code): ?array
{
$response = $this->request()->get("/v1/postal-codes/{$code}");
if ($response->status() === 404) {
return null;
}
return $response->throw()->json('data');
}
The false is not a stylistic choice. Cache::remember asks the store for the key and treats a null answer as a miss, so caching “this code does not exist” as null means calling the API again on every single request for it — and the codes that do not exist are exactly the ones a bot will send you a thousand of. false is a real cached value that simply is not null; the ?: on the way out turns it back into null for the caller.
Reject a bad postal code at the form, not at delivery
Five digits passing a regex is not a postal code. An invokable rule turns the lookup you already wrote into validation, and the cache above means a repeated submission costs you nothing.
app/Rules/RealPostalCode.php
class RealPostalCode implements ValidationRule
{
public function __construct(private PostalKit $postalKit) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) || preg_match('/^\d{5}$/', $value) !== 1) {
$fail('The :attribute must be five digits.');
return;
}
if ($this->postalKit->postalCode($value) === null) {
$fail('The :attribute is not a Mexican postal code.');
}
}
}
In the form request
public function rules(): array
{
return [
'postal_code' => ['required', 'string', app(RealPostalCode::class)],
'settlement_id' => ['required', 'integer'],
];
}
The digit check runs first on purpose. A code that is not five digits never reaches the router on the API side, so calling out to confirm it would spend a request to be told something you already knew.
Before you ship it
Three more things, once it works
-
Store the postal code as a string, all the way down.
An integer column, an integer cast or a stray (int) anywhere turns 06600 into 6600, which is a different place. Migrate it as a five-character string and leave it alone.
-
Store the settlement id, not the colonia name.
The id still means the same colonia after the catalog refreshes. A name is a string somebody may re-spell, and you will not find out from a foreign key.
-
Two different things answer 429.
The per-minute burst limit sends Retry-After and is worth waiting out. The monthly quota sends X-RateLimit-Remaining: 0 and no Retry-After, and will not clear until the first of the month — log that one and stop asking.