saade/cep-php
PHP library for Brazilian CEP postal code lookups. Query a CEP and retrieve address details such as street, neighborhood, city, and state through a simple, lightweight API, making it easy to add postal code search to PHP applications.
Install the package and confirm your Guzzle version is new enough — v2 requires guzzlehttp/guzzle: ^7.15.2|^8.0.1 because older releases carry published security advisories.
composer require saade/cep-php
Review the package config (provider order and timeouts) and adjust it for your region before wiring it into anything user-facing. Then try the simplest day-to-day use case — resolving a Brazilian CEP into address fields:
use Saade\Cep\Cep;
$address = Cep::get('01310-100');
$address->provider; // which provider answered
$address->cep;
$address->street;
$address->neighborhood;
$address->city;
$address->state; // may be null depending on the provider
When you need resilience against a single provider being slow or down, use Cep::race() — it queries providers concurrently and resolves with the first successful response:
$address = Cep::race('01310-100');
Where to look first:
Saade\Cep\Cep — the get() / race() entry points.Saade\Cep\Providers\Provider — the base class, useClient(), and the per-provider request() / handleErrors() / toDTO() lifecycle.Saade\Cep\DataObjects\CepResponse — the DTO you will actually pass around your app.Wrap it behind your own service, not the facade. Keep Cep:: calls out of controllers and models so you can cache, validate, and swap providers in one place.
final class CepLookup
{
public function find(string $cep): ?CepResponse
{
$cep = preg_replace('/\D/', '', $cep);
if (strlen($cep) !== 8) {
return null;
}
return Cache::remember("cep:{$cep}", now()->addDay(), fn () => Cep::race($cep));
}
}
Cache aggressively. CEP data is effectively static, and race() fans out to several providers. A day-long cache key on the normalized 8-digit CEP removes almost all outbound traffic and protects you from provider rate limits.
Normalize input before calling. Strip masks (01310-100 → 01310100) at the boundary of your service. Providers vary in what they accept, and consistent input makes race behaviour predictable.
Use async when resolving many CEPs. getAsync() / sendAsync() return Guzzle promises (and, as of v2, no longer accept a connector). Fan out and settle:
use GuzzleHttp\Promise\Utils;
$promises = collect($ceps)->mapWithKeys(
fn ($cep) => [$cep => Cep::getAsync($cep)]
);
$results = Utils::settle($promises->all())->wait();
Guard each result with $result['state'] === 'fulfilled' — a batch import should degrade to partial success, not blow up.
Custom providers are now a plain class. Extend Provider, declare a $timeout, and implement three methods against PSR-7:
class MyProvider extends Provider
{
protected float $timeout = 5;
protected function request(string $cep): RequestInterface
{
return new Request('GET', "https://example.com/cep/{$cep}", ['Accept' => 'application/json']);
}
protected function handleErrors(ResponseInterface $response): void
{
if ($response->getStatusCode() !== 200 || empty($this->json($response)['cep'])) {
throw new Exception('Could not resolve the CEP.');
}
}
protected function toDTO(ResponseInterface $response): CepResponse
{
$data = $this->json($response);
return new CepResponse('my-provider', $data['cep'], $data['street'] ?? null, $data['neighborhood'] ?? null, $data['city'] ?? null, $data['state'] ?? null);
}
}
Register it in the provider list (and place it in the order you want race() to prefer).
Inject a Guzzle client for proxies, retries, and tests. Provider::useClient() is the single extension point for transport concerns:
Provider::useClient(new Client([
'proxy' => 'http://localhost:8125',
'timeout' => 3,
]));
For tests, pair it with a MockHandler rather than hitting the network:
Provider::useClient(new Client([
'handler' => HandlerStack::create(new MockHandler([
new Response(200, [], json_encode(['cep' => '01310100', 'street' => 'Av. Paulista'])),
])),
]));
The Saloon removal is the upgrade trap. If your app relied on Saloon being installed transitively through this package, require it yourself after upgrading. Run composer why saloonphp/saloon before you delete anything.
MockClient no longer intercepts requests. This is the most dangerous change: existing tests that used Saloon's MockClient will silently start making real HTTP calls instead of failing. Grep your test suite for MockClient and migrate every occurrence to Provider::useClient().
Provider::useClient() is static, global state. It affects every provider for the rest of the process. Reset it with Provider::useClient(null) in tearDown(), and be deliberate about it under Octane or queue workers where the process is long-lived — one request setting a proxy client can leak into the next.
Removed classes will fatal on upgrade. Saade\Cep\Requests\* and Saade\Cep\Connectors\RaceConnector are gone. Any use statement or type-hint referencing them must be deleted, not just unused.
getAsync() / sendAsync() signatures changed. Drop the connector argument; they still return a Guzzle promise. A stale connector argument is a runtime ArgumentCountError, not a deprecation.
Custom providers need a rewrite, not a tweak. The Saloon request class is replaced by request(), and handleErrors() / toDTO() now receive a PSR-7 ResponseInterface. Use the inherited $this->json($response) helper instead of Saloon's response methods.
Check Guzzle first when upgrading. If Composer refuses the update or you get advisory warnings, bump Guzzle before touching this package — the constraint is the blocker, not the CEP code.
CepResponse fields are nullable. Providers differ: some return neighborhood, some don't. Always fall back with ?? and never assume street exists. Treat a response as "partial address" rather than "address".
race() resolves on first success — not first response. Slow-but-correct beats fast-but-empty. If you need deterministic provider preference, call a specific provider (SomeProvider::get()) or order the provider list and use get().
No caching or retries are built in. Add a Guzzle retry middleware via useClient(), or handle retries in your service layer. Doing neither means transient provider failures surface directly to users.
Debug by narrowing to one provider. When race() returns something unexpected, call the individual provider class to isolate whether the issue is the provider's response shape or the race/ordering logic.
How can I help you explore Laravel packages today?