# cepis PHP client for the [public CEP API at cep.is](https://cep.is). Official Correios data (DNE), refreshed every two weeks. No Composer dependencies. ```php $address = Cepis\Client::default()->lookup('01001-000'); echo $address->logradouro, ' ', $address->localidade, ' ', $address->uf; // Praça da Sé São Paulo SP ``` ## Installation ```sh composer require cepis/cepis ``` Requires PHP 8.1 or newer, with `ext-curl` and `ext-json`. Both ship enabled in almost every PHP build; they are declared in `composer.json` so a host that lacks them fails at install time rather than at the first lookup. ## Usage ### A single lookup `lookup()` accepts the CEP masked or unmasked and returns an `Address`: ```php use Cepis\Client; $a = Client::default()->lookup('01310-100'); $a->logradouro; // "Avenida Paulista" $a->localidade; // "São Paulo" $a->uf; // "SP" $a->ibge; // "3550308" $a->cep; // "01310100" (always unmasked) $a->formattedCep(); // "01310-100" ``` Fields the database does not have for that CEP come back as empty strings, never null. `$a->field('name')` reads any field of the raw response, including one the API grew after this release. Property names are the API's own JSON keys, in Portuguese, with no translation — what you read at `https://cep.is/01001000` is what you read on the object. That includes the two keys that are not single words: they stay snake_case as the API writes them, `Geo::$raio_m` and `Health::$base_gerada_em`. Methods are English and camelCase as PHP expects. ### Coordinates About 57% of CEPs have a coordinate. When they do not, `geo` is null: ```php if ($a->geo !== null) { $a->geo->lat; // -23.567202 $a->geo->lon; // -46.649659 $a->geo->raio_m; // 1084 $a->geo->precisao; // "logradouro" } ``` **Check `precisao` before computing distance.** There are two kinds of point, and the difference between them is tens of kilometres: | `precisao` | typical radius | what the point is | | --- | --- | --- | | `logradouro` | 110–140 m | the stretch of street — good for maps and distance | | `localidade` | 7–40 km | the municipality centroid: the CEP *is* the whole city | `$a->geo->isPrecise()` is the shorthand for `precisao === 'logradouro'`: ```php if ($a->geo !== null && $a->geo->isPrecise()) { $cost = shippingFrom($a->geo->lat, $a->geo->lon); } else { $cost = shippingFromMunicipality($a->ibge); } ``` ### Many CEPs at once Do not loop over `lookup()`. Use `lookupMany()`, which resolves up to 500 CEPs per request: ```php $ceps = ['01001000', '99999999', '20040020']; $result = Client::default()->lookupMany($ceps); foreach ($result as $i => $address) { if ($address === null) { printf("%s: not found\n", $ceps[$i]); } else { printf("%s: %s\n", $ceps[$i], $address->localidade); } } ``` The result has **the same length and the same order** as the input, indexed from zero. A CEP that does not exist, or is malformed, is `null` at its position — it does not vanish, and it is not an exception. Arrays longer than 500 are chunked automatically. > **Batches carry no coordinate.** The endpoint behind them is CSV, and the CSV > has no geo column. Every `Address` from `lookupMany()` has a null `geo`, > including for CEPs that do have one. Need the coordinate? Use `lookup()`, one > at a time. ### API health Useful for two things: checking the API answers, and knowing **which version of the database** is live: ```php $h = Client::default()->health(); $h->isOk(); // true $h->dne; // "26071" identifier of the fortnightly database $h->ceps; // 1596168 $h->base_gerada_em; // "2026-07-24T03:48:28Z" ``` An API reporting itself degraded is not an exception — that arrives in `$h->status`. ## Configuration For anything beyond the defaults, build a `Client` and reuse it. It keeps no state between calls. Named arguments make the settings readable at the call site: ```php use Cepis\Client; $client = new Client( baseUrl: 'https://cep.is', // point at another instance timeout: 10.0, // seconds for one attempt maxAttempts: 3, // counting the first; 1 disables retrying backoffBase: 0.5, backoffMax: 8.0, totalTimeout: 15.0, // cap on the whole call userAgent: 'myapp/1.0', ); $address = $client->lookup('01001000'); ``` `timeout` bounds a single attempt — connection, response and body read together. A call may make several attempts with waits between them; the cap on all of that is `totalTimeout`, which is unset by default. Retries cover 429, 5xx and network failures, with exponential backoff and full jitter; 400 and 404 are never retried. `Client::default()` is a shared instance with the default settings, for scripts that need no configuration. It applies process-wide, third-party libraries included — anyone needing different settings should build their own. `Client::normalizeCep()` strips the mask and validates the 8 digits on its own, for checking a form field before spending a request: ```php Client::normalizeCep(' 01001-000 '); // "01001000" Client::normalizeCep('123'); // throws InvalidCepException ``` ### Pointing at a local instance `baseUrl` is the only setting that usually changes between environments. The API repo brings a local instance up with `just up`: ```php $client = new Client(baseUrl: 'http://localhost:8080'); ``` ### Using another HTTP layer By default the client talks to the API with `ext-curl` directly. Hosts that require their own HTTP layer can supply a `Cepis\Transport` instead. The interface is one method: ```php public function get(string $url, array $headers, float $timeout): Cepis\Response; ``` It returns a `Cepis\Response` — status, headers and body — for any HTTP status, and throws `Cepis\NetworkException` only when the request did not complete at all. Retries, backoff and `Retry-After` stay the client's job, so an implementation makes exactly one request and never retries. This is what a **WordPress or WooCommerce plugin** needs: WordPress expects HTTP to go through the WP HTTP API, which is what gives site owners proxy support and the `http_request_args` filters they rely on. ```php final class WpTransport implements Cepis\Transport { public function get(string $url, array $headers, float $timeout): Cepis\Response { $response = wp_remote_get($url, [ 'headers' => $headers, 'timeout' => $timeout, ]); if (is_wp_error($response)) { throw new Cepis\NetworkException($response->get_error_message()); } return new Cepis\Response( wp_remote_retrieve_response_code($response), wp_remote_retrieve_headers($response)->getAll(), wp_remote_retrieve_body($response), ); } } $client = new Cepis\Client(transport: new WpTransport()); $address = $client->lookup($_POST['billing_postcode']); ``` Header names may be in any case and values may be a single string or a list of them; `Response::header()` handles both, so whatever your HTTP layer produced can be passed straight through. ## Errors Every exception implements `Cepis\CepisException`, so one `catch` handles any failure. Below that, the split is by **the decision you have to make**, not by the HTTP status that caused it: | exception | when | does retrying help? | | --- | --- | --- | | `InvalidCepException` | not 8 digits | no | | `CepNotFoundException` | well-formed, not in the database | no | | `RateLimitedException` | 429; carries `retryAfter` | yes, later | | `ServerErrorException` | 5xx; carries `status` | yes, later | | `NetworkException` | DNS, connection, timeout, TLS | yes, later | | `InvalidResponseException` | unintelligible response | probably not | ```php use Cepis\CepisException; use Cepis\CepNotFoundException; use Cepis\RateLimitedException; try { $address = Cepis\Client::default()->lookup($cepFromForm); } catch (CepNotFoundException) { // ask the user for another CEP } catch (RateLimitedException $e) { reschedule($e->retryAfter); } catch (CepisException) { // network, server, unreadable response: retry later } ``` `CepisException` is an interface rather than a base class, so each exception can also extend the SPL type you would already be catching: `InvalidCepException` is an `\InvalidArgumentException` and `CepNotFoundException` is an `\OutOfBoundsException`. Everything else extends `\RuntimeException`. `InvalidResponseException` almost always means something between you and the API is answering instead of it — a captive Wi-Fi portal, a corporate proxy, an HTML error page. Useful as a diagnosis: the problem is not in your code. ## Development Everything runs through `make`: ```sh make # list the targets make test # run the suite with PHPUnit make lint # php -l over the tree, touches nothing make format # apply a formatter (writes) make build # validate composer.json and the PSR-4 autoloading make check # lint, then test, then build ``` PHPUnit is the only dev dependency and `make test` installs it. The suite stands up a real HTTP server with `php -S` and points the client at it over a socket — no network, and nothing stubbed in place of the transport. The code is PSR-12. `make lint` always runs `php -l` over every file; it also runs `php-cs-fixer` or `phpcs` when either is installed, but neither is a dependency of this package, so on a bare checkout `lint` is the syntax check alone. ## Licence ISC. See [LICENSE](LICENSE).