ialpro / bundesland
Laravel package for resolving German federal states from postal codes with caching, Unicode-aware city validation, and typed results.
Requires
- php: ^8.2
- ext-mbstring: *
- illuminate/cache: ^12.0 || ^13.0
- illuminate/http: ^12.0 || ^13.0
- illuminate/routing: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.0
- orchestra/testbench: ^10.0 || ^11.0
- phpunit/phpunit: ^11.5
This package is auto-updated.
Last update: 2026-08-23 15:20:21 UTC
README
A Laravel package for resolving German federal states from postal codes with typed results, deterministic caching, Unicode-aware city validation, replaceable provider integration, and an optional HTTP API.
Overview
Calling a postcode API directly leaves validation, failure handling, caching, and test isolation to every application. Laravel Bundesland provides those boundaries once: Laravel-native dependency injection, an explicit result model, replaceable providers, consistent exceptions, city validation, and fully mockable external I/O.
Features
- Validates an exact five-digit German postal code before external I/O.
- Returns typed
Success,NotFound, andCityMismatchoutcomes. - Separates provider/network failures from expected lookup outcomes.
- Matches city names with Unicode case and whitespace normalization.
- Explicitly supports
ä/ae,ö/oe,ü/ue, andß/sstransliteration. - Caches successful provider results by country and postal code.
- Exposes a facade, service contract, compatibility facade, and graceful helper.
- Keeps its configurable API route disabled by default.
Requirements
- PHP 8.2 or newer
- Laravel 12 or 13
- PHP
mbstringextension
Installation
composer require ialpro/bundesland
Laravel discovers the service provider and the Bundesland and ZipLookup facade aliases automatically. Publish the optional configuration with:
php artisan vendor:publish --tag=bundesland-config
Quick start
use Ialpro\Bundesland\Enums\LookupStatus; use Ialpro\Bundesland\Facades\Bundesland; $result = Bundesland::lookup('10115'); if ($result->status === LookupStatus::Success) { echo $result->state; // Berlin }
For callers that only need the state:
$state = Bundesland::stateByZip('20095'); // Hamburg or null when not found $state = bundesland('80331', 'München'); // Bayern or null on any failure
The bundesland() helper intentionally remains failure-tolerant. Prefer lookup() when your application needs to distinguish outcomes or infrastructure errors.
Lookup with city validation
$result = Bundesland::lookup('80331', 'München'); $result->status; // LookupStatus::Success $result->city; // München (canonical provider spelling) $result->state; // Bayern $result->matchedCities; // all cities returned for this postcode
Matching trims input, collapses whitespace, performs Unicode-aware lowercase conversion, and—by default—applies the documented German transliterations. It does not perform fuzzy matching.
Handle all expected outcomes explicitly when they matter to the application:
$message = match ($result->status) { LookupStatus::Success => "State: {$result->state}", LookupStatus::NotFound => 'Postal code not found.', LookupStatus::CityMismatch => 'City does not match the postal code.', };
Result object
PostalLookupResult is readonly and exposes:
| Property | Type | Meaning |
|---|---|---|
status |
LookupStatus |
Success, NotFound, or CityMismatch |
postalCode |
string |
Validated postcode |
state |
?string |
Matched/first provider state |
city |
?string |
Canonical city, or requested city for a mismatch |
matchedCities |
list<string> |
All provider cities for the postcode |
provider |
?string |
Provider identifier |
The legacy readonly properties ok, zip, and message remain available to ease migration from 1.x.
Error handling
Expected lookup outcomes are returned as statuses. Invalid input and infrastructure failures are exceptions:
use Ialpro\Bundesland\Exceptions\InvalidPostalCode; use Ialpro\Bundesland\Exceptions\MalformedProviderResponse; use Ialpro\Bundesland\Exceptions\PostalProviderUnavailable; try { $result = Bundesland::lookup($postalCode, $city); } catch (InvalidPostalCode $exception) { // Input is not exactly five digits. } catch (PostalProviderUnavailable|MalformedProviderResponse $exception) { // Retry, degrade gracefully, or report an upstream incident. }
| Condition | Package behavior |
|---|---|
| Invalid postal code | Throws InvalidPostalCode before cache or provider access |
| Postal code not found | Returns LookupStatus::NotFound |
| City mismatch | Returns LookupStatus::CityMismatch with provider cities |
| Provider 404 | Returns LookupStatus::NotFound |
| Provider timeout/connection failure | Throws PostalProviderUnavailable |
| Provider 500 or other non-404 failure | Throws PostalProviderUnavailable |
| Malformed provider payload | Throws MalformedProviderResponse |
The optional HTTP API converts these into sanitized HTTP responses; direct service and facade consumers receive the typed statuses or exceptions above.
Caching
Caching is enabled by default and can be disabled with BUNDESLAND_CACHE_ENABLED=false. When disabled, every valid lookup reaches the configured provider and neither reads nor writes cache entries.
Successful provider results use the deterministic key bundesland:{provider}:{country}:{postalCode}, for example bundesland:zippopotam:de:10115. Provider and country prevent cross-provider/country collisions. City input is intentionally excluded so city validation can reuse the same postcode response.
The default TTL is 86,400 seconds and is configurable with BUNDESLAND_CACHE_TTL. Not-found responses, timeouts, server errors, and malformed payloads are not cached. The package deliberately does not implement negative caching.
Provider architecture
Application
│
▼
Bundesland facade / ZipLookupServiceInterface
│
▼
ZipLookupService
├── GermanPostalCode validation
├── CityNameNormalizer
└── Laravel cache
│
▼
PostalCodeProvider
│
▼
ZippopotamProvider → https://www.zippopotam.us
Add a provider without changing lookup logic by implementing PostalCodeProvider and rebinding it in an application service provider:
$this->app->singleton( \Ialpro\Bundesland\Contracts\PostalCodeProvider::class, App\PostalCodes\CompanyProvider::class, );
Engineering Decisions
- Provider-specific HTTP and payload mapping stay behind
PostalCodeProvider; lookup rules do not depend on Zippopotam fields. - Caching sits above the provider so every implementation receives the same deterministic success-only policy.
- City matching performs explicit Unicode normalization and documented German transliteration; fuzzy matching is intentionally avoided.
- Expected domain outcomes are enum values, while network and malformed-response failures remain exceptions that callers can handle operationally.
- The HTTP API is disabled by default and inherits configurable middleware and rate limiting when enabled.
- Tests fake the provider or Laravel HTTP client, keeping the suite deterministic and independent of the live service.
Configuration
Published configuration lives at config/bundesland.php:
return [ 'provider' => [ 'driver' => 'zippopotam', 'country' => env('BUNDESLAND_COUNTRY', 'de'), 'base_url' => env('BUNDESLAND_BASE_URL', 'https://api.zippopotam.us'), 'timeout' => (int) env('BUNDESLAND_TIMEOUT', 5), 'connect_timeout' => (int) env('BUNDESLAND_CONNECT_TIMEOUT', 2), 'retries' => (int) env('BUNDESLAND_RETRIES', 2), ], 'cache' => [ 'enabled' => (bool) env('BUNDESLAND_CACHE_ENABLED', true), 'ttl' => (int) env('BUNDESLAND_CACHE_TTL', 86400), ], 'city' => [ 'transliterate' => (bool) env('BUNDESLAND_CITY_TRANSLITERATE', true), ], 'api' => [ 'enabled' => (bool) env('BUNDESLAND_ENABLE_API', false), 'prefix' => env('BUNDESLAND_API_PREFIX', 'api/bundesland'), 'middleware' => ['api', 'throttle:60,1'], ], ];
Environment access is confined to configuration. Applications may override any value normally through Laravel config.
Optional HTTP API
Set BUNDESLAND_ENABLE_API=true to expose GET /api/bundesland/{zip}. Add ?city=München for city validation. The prefix and middleware list are configurable.
| Outcome | HTTP status |
|---|---|
| Success | 200 |
| Postal code not found | 404 |
| Invalid postcode or city mismatch | 422 |
| Provider/network/malformed response | 503 |
Provider exception details are never returned. The defaults apply Laravel's api middleware and a 60 requests/minute throttle; review them for your application before enabling the endpoint.
Testing
composer test
composer format:test
composer analyse
composer check
The suite uses Orchestra Testbench and Laravel HTTP fakes. It never calls a live provider. Static analysis runs at Larastan/PHPStan level 8.
Version compatibility
The CI matrix and supported versions are identical:
| Laravel | PHP | Testbench |
|---|---|---|
| 12.x | 8.2, 8.3, 8.4, 8.5 | 10.x |
| 13.x | 8.3, 8.4, 8.5 | 11.x |
Laravel 11 is not supported by this major version.
Upgrading from 1.x
Version 2.0 introduces breaking configuration, dependency, provider-contract, and failure-semantics changes. Follow the 2.0 upgrade guide before updating an existing installation. The ZipLookup facade, stateByZip(), result compatibility fields, and bundesland() helper remain available.
Project policies
See CONTRIBUTING.md, SECURITY.md, CHANGELOG.md, UPGRADE.md, RELEASING.md, and ROADMAP.md.
License
Laravel Bundesland is open-source software licensed under the MIT License.