ialpro/bundesland

Laravel package for resolving German federal states from postal codes with caching, Unicode-aware city validation, and typed results.

Maintainers

Package info

github.com/ialaminpro/bundesland

pkg:composer/ialpro/bundesland

Transparency log

Statistics

Installs: 37

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 1

v2.0.0 2026-08-23 14:43 UTC

This package is auto-updated.

Last update: 2026-08-23 15:20:21 UTC


README

CI Latest Stable Version Total Downloads PHP Laravel License

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, and CityMismatch outcomes.
  • Separates provider/network failures from expected lookup outcomes.
  • Matches city names with Unicode case and whitespace normalization.
  • Explicitly supports ä/ae, ö/oe, ü/ue, and ß/ss transliteration.
  • 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 mbstring extension

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.