Códigos postales de México en una aplicación Laravel
Una clase de servicio, una decisión de caché y una regla de validación. Laravel ya trae todo lo demás que necesitas, y dos de sus comodidades van a jugar en tu contra aquí sin avisarte: las dos están más abajo.
Laravel 13.25 · PHP 8.5 · ejecutado el 2026-08-17
Pon la llave en la configuración, no en una clase
La llave va en el entorno y la URL base va junto a ella, para que una prueba pueda apuntar toda la integración a otro lado sin tocar una línea de código.
.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'),
],
Nunca llames a env() fuera de un archivo de configuración. En cuanto corres config:cache en producción, toda llamada a env() fuera de config/ devuelve null, y la falla parece un problema de autenticación en lugar de uno de caché.
El constructor de la petición
Todo lo que manda la integración se ve igual, así que constrúyelo en un solo lugar: URL base, token, JSON, un timeout para que una red lenta no deje ocupado a un worker, y una política de reintentos más estrecha de lo que parece.
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,
);
}
Los dos argumentos que van después del sleep sostienen la estructura, y dejar fuera cualquiera de los dos es el error que casi todo mundo comete primero.
-
Sin when, retry() reintenta todo.
Un código postal que no existe responde 404, y un retry pelado va a preguntar tres veces más antes de darte la misma respuesta. Lo mismo pasa con una cuota agotada. Reintenta una falla de conexión y un 5xx; nada más va a cambiar de opinión.
-
Sin throw: false, tu propia rama de 404 nunca corre.
En cuanto configuras más de un intento, el cliente lanza una excepción ante cualquier respuesta fallida por omisión, antes de regresarte el control. El if que iba a revisar el 404 se queda ahí, viéndose correcto, y nunca se alcanza.
Guárdalo en caché, y guarda también los fallos
El catálogo se vuelve a comparar contra la fuente una vez al mes y nada se mueve a menos que la fuente se haya movido, así que una semana es un TTL conservador. La mitad interesante es qué haces cuando no hay nada que guardar.
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');
}
El false no es una cuestión de estilo. Cache::remember le pide la llave al store y trata una respuesta null como un fallo de caché, así que guardar «este código no existe» como null significa llamar a la API otra vez en cada petición por él, y los códigos que no existen son justo de los que un bot te va a mandar mil. false es un valor guardado de verdad que simplemente no es null; el ?: de salida lo vuelve a convertir en null para quien llama.
Rechaza un código postal malo en el formulario, no en la entrega
Cinco dígitos que pasan un regex no son un código postal. Una regla invocable convierte la consulta que ya escribiste en validación, y con el caché de arriba un reenvío del formulario no te cuesta nada.
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.');
}
}
}
En el form request
public function rules(): array
{
return [
'postal_code' => ['required', 'string', app(RealPostalCode::class)],
'settlement_id' => ['required', 'integer'],
];
}
La revisión de dígitos va primero a propósito. Un código que no tiene cinco dígitos nunca llega al router del lado de la API, así que salir a confirmarlo gastaría una solicitud para que te digan algo que ya sabías.
Antes de publicarlo
Tres cosas más, una vez que funcione
-
Guarda el código postal como cadena, hasta el fondo.
Una columna entera, un cast a entero o un (int) perdido en cualquier lado convierte 06600 en 6600, que es otro lugar. Haz la migración como cadena de cinco caracteres y déjalo así.
-
Guarda el id del asentamiento, no el nombre de la colonia.
El id sigue significando la misma colonia después de que se actualiza el catálogo. Un nombre es una cadena que alguien puede volver a escribir distinto, y no te vas a enterar por una llave foránea.
-
Dos cosas distintas responden 429.
El límite por minuto manda Retry-After y vale la pena esperarlo. La cuota mensual manda X-RateLimit-Remaining: 0 y ningún Retry-After, y no se libera hasta el primero del mes: registra ese en tu log y deja de preguntar.