canducci/zipcode
canducci/zipcode is a PHP/Laravel package for Brazilian CEP (zip code) lookups, providing a simple API to fetch address data from supported web services and add postal-code search to your applications. It is lightweight, installs via Composer, and is useful for forms, shipping, and address validatio...
canducci/zipcode is a small Laravel-oriented wrapper for looking up Brazilian addresses from a CEP ( postal code ). You give it eight digits, it gives you back street, neighborhood, city and state — typically sourced from a public web service. That is the entire surface area, which makes it easy to adopt but also means most of your real work is in how you wrap it.
Install and boot
composer require canducci/zipcode
On Laravel 5.5+ the service provider and ZipCode facade are auto-discovered. On older versions (or Lumen) register manually in config/app.php:
Canducci\ZipCode\ZipCodeServiceProvider::class,
'aliases' => [
'ZipCode' => Canducci\ZipCode\Facades\ZipCode::class,
],
Publish the config if you need to change the endpoint or HTTP timeout:
php artisan vendor:publish --provider="Canducci\ZipCode\ZipCodeServiceProvider"
First call
The quickest sanity check is from php artisan tinker:
$item = app(\Canducci\ZipCode\ZipCode::class)->find('01310-100');
dump($item); // inspect the returned item once
Or through the facade:
ZipCode::find('01310100');
The lookup accepts the masked (01310-100) or unmasked form. find() returns an address item on success and null/false (depending on version) when the CEP does not exist or the remote service fails — always dd() the result once so you know the exact property names (street, neighborhood, city, state, or their Portuguese equivalents) for the tag you installed.
Note on v3.0.7: the release is titled "Update Property ZipCode" — a property on the returned item was touched. Treat the property names on the result object as version-sensitive: pin the version in composer.json, dump one real result after upgrading, and read only properties defensively (?? / data_get). This is a patch release, so expect a rename or normalization of the CEP field rather than an API change.
Where to look first
README.md — the usage examples are short and are the canonical API.src/ZipCode.php — confirms the find() signature and what it returns.find() returns) — this is where the zipcode property changed in 3.0.7; check it before trusting field names.config/zipcode.php — endpoint, timeout and any driver options you can override.src/ZipCodeServiceProvider.php — shows the container binding and facade accessor, which is what you mock in tests.First real use case: a CEP field on a checkout or registration form that auto-fills street, neighborhood, city and state as soon as the user finishes typing the postal code. That single feature is 90% of what this package is used for in production.
Wrap it in a lookup service, not scattered calls. Resolve the package once and hide it behind your own class. This gives you one place to normalize, cache, catch failures and later swap the implementation.
namespace App\Services;
use Canducci\ZipCode\ZipCode;
use Illuminate\Support\Facades\Cache;
class CepService
{
public function __construct(private ZipCode $zipCode) {}
public function lookup(string $cep): ?array
{
$cep = preg_replace('/\D/', '', $cep);
if (strlen($cep) !== 8) {
return null;
}
$key = "cep:{$cep}";
// Cache a sentinel, not null — see Gotchas.
$cached = Cache::get($key);
if ($cached !== null) {
return $cached['found'] ? $cached['data'] : null;
}
try {
$item = $this->zipCode->find($cep);
} catch (\Throwable $e) {
report($e);
return null; // fail open; let the user type the address manually
}
$data = $item ? $this->normalize($item) : null;
Cache::put($key, ['found' => $data !== null, 'data' => $data], now()->addDay());
return $data;
}
/**
* Flatten the package item into a stable array shape.
* Insulates the rest of the app from property renames like the 3.0.7 change.
*/
private function normalize(object $item): array
{
return [
'zipcode' => data_get($item, 'zipcode') ?? data_get($item, 'cep'),
'street' => data_get($item, 'street') ?? data_get($item, 'logradouro'),
'neighborhood' => data_get($item, 'neighborhood') ?? data_get($item, 'bairro'),
'city' => data_get($item, 'city') ?? data_get($item, 'cidade'),
'state' => data_get($item, 'state') ?? data_get($item, 'uf'),
];
}
}
The normalize() step matters more than it looks: every property touch on the package item lives in one method, so a release like 3.0.7 costs you one line, not a grep across the codebase.
Inject CepService into controllers/jobs; never instantiate new ZipCode() inside a loop.
Normalize on the way in, format on the way out. Store CEPs as eight digits in the database (char(8) or varchar(8)) and format as 00000-000 in Blade/API responses. Every entry point — form request, API, importer — should strip non-digits before it reaches the service. If the package's returned zipcode property comes back masked or unmasked inconsistently, re-strip it in normalize() rather than persisting whatever the remote sent.
Validate the shape before spending a network call.
$request->validate([
'cep' => ['required', 'regex:/^\d{5}-?\d{3}$/'],
]);
Then optionally confirm existence with CepService::lookup() and return a 422 if it is not found. Keep the two checks separate: a malformed CEP is a validation error; a well-formed but nonexistent CEP is a lookup miss.
Expose a small JSON endpoint for the front end. A dedicated controller keeps the AJAX contract stable even if you change providers later:
public function cep(string $cep, CepService $service)
{
$address = $service->lookup($cep);
return $address
? response()->json($address)
: response()->json(['message' => 'CEP não encontrado'], 404);
}
On the client, debounce the input, abort in-flight requests, and re-enable the address fields for manual editing when the lookup returns 404. Never lock the form on a failed lookup.
Enrich models on save, but never overwrite user edits. A common pattern is to fill address columns only when they are blank:
protected static function booted(): void
{
static::saving(function (self $model) {
if (! $model->isDirty('zipcode') || filled($model->street)) {
return;
}
$address = app(CepService::class)->lookup($model->zipcode);
if ($address) {
$model->fill([
'street' => $address['street'] ?? null,
'neighborhood' => $address['neighborhood'] ?? null,
'city' => $address['city'] ?? null,
'state' => $address['state'] ?? null,
]);
}
});
}
Because you consume the normalized array and not the package object, a property rename in a patch release cannot silently null out your columns.
Queue bulk enrichment. Importing a customer spreadsheet means thousands of remote calls. Dispatch one job per CEP (or chunked batches) with rate limiting rather than calling the service from the import loop:
Bus::batch($chunks)->name('cep-enrichment')->dispatch();
Add RateLimited middleware or ->delay() between jobs; the upstream free services will throttle or ban aggressive clients.
Make it swappable and testable. Bind an interface in a service provider and let the package be one implementation:
$this->app->bind(CepLookup::class, PackageCepLookup::class);
In tests, mock the container binding rather than hitting the network:
$this->mock(\Canducci\ZipCode\ZipCode::class, function ($mock) {
$mock->shouldReceive('find')->with('01310100')->andReturn($fakeItem);
});
Build $fakeItem from a fixture captured after upgrading to 3.0.7, so your tests fail loudly if the real property names drift again. If you expect to move off this package eventually, this interface is the cheapest insurance you can buy.
Property names are version-sensitive — 3.0.7 proves it. "Update Property ZipCode" touched a property on the result object. Even in a patch release, do not read $item->zipcode (or whatever the CEP field is called) directly from many places. Normalize once, defensively, and keep a real-response fixture in your test suite to catch drift on the next bump.
Cache::remember will not cache misses. Laravel treats a cached null as a cache miss and re-queries on every request, which is exactly the behavior you do not want for typo'd CEPs. Cache an array/sentinel (['found' => false]) as in the example above, and give negative results a shorter TTL than successful ones.
The network is the failure mode. The package calls an external web service. Wrap calls in try/catch, set a short timeout in the published config if one is exposed, and always degrade to manual address entry. A checkout that hard-fails because ViaCEP is down is a worse bug than a missing autofill.
Check the null contract. find() may return null, false, or an empty item depending on the version and the upstream response. Check truthiness before reading properties, and treat "not found" and "service error" the same way at the UI level.
HTTP fakes may not intercept it. If the package uses Guzzle directly rather than Laravel's Http client, Http::fake() will do nothing and your test suite will hit the live service. Mock the ZipCode class through the container instead, or check composer.lock for the HTTP layer it actually uses.
Facade alias collisions. The alias ZipCode is also the natural name for your own class or import. If you import both Canducci\ZipCode\Facades\ZipCode and a local ZipCode, you will get confusing resolution bugs. Prefer injecting the class and skip the facade in application code.
Do not call it inside accessors, loops, or withCount-style queries. Each find() is an HTTP round trip. One call per form submission or per queued job; batch and cache everything else.
Response shape varies by CEP type. Large-user CEPs, caixa postal and newly created streets can come back with missing neighborhood or a generic street. Treat every address field as nullable in your schema and your Blade templates.
Encoding matters. Brazilian street names carry accents (São, Conceição). Store tables as utf8mb4 and make sure the JSON response is UTF-8; otherwise you will see mojibake in the autofilled fields and blame the package.
Config caching. After editing config/zipcode.php, run php artisan config:cache (and config:clear in local). A stale cached config is a classic "why is it still hitting the old URL" moment.
Version and framework constraints. Releases are infrequent and the user base is small; 3.0.7 is a patch with no changelog beyond its title. Pin the version, check the illuminate/support constraint against your Laravel version before upgrading, and re-run your lookup tests after every bump — you cannot rely on release notes to tell you what changed.
Low adoption is a real risk (45 stars, zero dependents). Read src/ZipCode.php — it is small enough to audit in ten minutes. Keep your CepLookup interface thin so replacing the package with a direct ViaCEP Http::get() call (or another provider) is a one-class change, not a refactor.
Extension points are the container binding, the facade, and the config file. There is no event system or driver manager here. If you need retries, circuit breaking, or multiple providers, build that in your wrapper service rather than expecting the package to do it.
How can I help you explore Laravel packages today?