laravel-gus-bdl maintained by beziworld
Laravel GUS BDL
Laravel client for the Local Data Bank API of Statistics Poland (GUS, Bank Danych Lokalnych, BDL): the largest Polish database of economic, social and environmental statistics for Poland, voivodeships, counties, communes and statistical localities, from 1995 onwards.
- subjects (tematy), variables (zmienne), territorial units and statistical localities with search
- data by variable (many units) and by unit (many variables), as typed
DataSerieswith values per year and the attribute (flag) of every value - dictionaries: attributes, measure units, aggregates, years, levels, metadata, version
- lazy pagination with
LazyCollection: further pages are requested only while you iterate - lookup of units by TERYT code (
"1261011"gives the commune of Kraków) - response cache with ETag revalidation (
If-None-Match) - client-side rate limiter that follows the documented quotas and shares the budget between workers
- optional client key (
X-ClientId) for higher limits, Polish and English, JSON, JSON:API and XML bdl:searchandbdl:dataArtisan commands
Why this package
At the time of writing Packagist has no package for the BDL API (searches for "bdl gus" and "bank danych lokalnych" return nothing; the GUS packages that exist cover the REGON registry). Clients exist for Python, R and C#, none for PHP. This package adds a typed, cached and quota-aware Laravel client.
Installation
composer require beziworld/laravel-gus-bdl
php artisan vendor:publish --tag=gus-bdl-config # optional
Everything works anonymously. Optional .env settings:
GUS_BDL_CLIENT_ID= # free key from api.stat.gov.pl, raises the quotas
GUS_BDL_LANG=pl # pl or en
GUS_BDL_PAGE_SIZE=100 # 1-100, records per page of lazy iterators
GUS_BDL_TIMEOUT=30
GUS_BDL_RETRIES=2 # connection errors and HTTP 5xx; 429 is never retried
GUS_BDL_CACHE=true
GUS_BDL_CACHE_STORE= # empty = default store
GUS_BDL_CACHE_TTL= # empty = Cache-Control max-age of the API (30 minutes)
GUS_BDL_RATE_LIMIT=true
GUS_BDL_RATE_LIMIT_STORE= # shared store (redis, database) for several workers
GUS_BDL_RATE_LIMIT_MAX_WAIT=2 # seconds the limiter may wait before throwing
Subjects and variables
use Beziworld\GusBdl\Facades\GusBdl;
GusBdl::subjects()->all(); // top level: K3 "LUDNOŚĆ", ...
GusBdl::subjects('K3')->pluck('name'); // children of a subject
GusBdl::subject('K3')->children; // ["G7", "G8", ...] with years and availability
GusBdl::searchSubjects('samochody')->first(); // Subject "Ciągniki, samochody i maszyny rolnicze"
$variable = GusBdl::variable(60559);
$variable->name(); // "ludność na 1 km2" (dimensions n1-n5 joined with " / ")
$variable->subjectId; // "P2425"
$variable->level(); // Level::Commune
$variable->measureUnitName; // "osoba"
$variable->years; // [2002, ..., 2025]
GusBdl::variables('P2425')->all();
GusBdl::searchVariables('gęstość zaludnienia', level: Level::Commune, years: [2023])->take(20)->all();
Territorial units and TERYT codes
use Beziworld\GusBdl\Enums\Level;
GusBdl::units(levels: [Level::Voivodeship])->all(); // 16 voivodeships
GusBdl::units('011200000000', [Level::County])->all(); // counties of Małopolskie
GusBdl::searchUnits('Kraków', [Level::Commune])->first();
GusBdl::unit('011212161011'); // details with years
$krakow = GusBdl::unitByTeryt('1261011'); // also "12" (voivodeship) and "1261" (county)
$krakow->id; // "011212161011"
$krakow->terytCode(); // "1261011"
$krakow->kind; // "1" (urban commune)
GusBdl::localities('011212001011')->all(); // statistical localities of a commune
GusBdl::unit('011212001011-0981682')->simc(); // "0981682"
A BDL unit id has twelve digits: macroregion, voivodeship, region, subregion, county, commune and commune type.
The voivodeship, county, commune and type parts are the TERC code, so terytCode() works offline. For the
reverse direction unitByTeryt() walks down the hierarchy (three small cached requests at most). To keep a local
copy of the TERYT register itself, see laravel-teryt.
Data
// One variable for many units: one DataSeries per unit
foreach (GusBdl::dataByVariable(60559, years: [2022, 2023], level: Level::Voivodeship) as $series) {
$series->unitName; // "MAŁOPOLSKIE"
$series->value(2023); // 225.9
}
// One unit, several variables: one DataSeries per variable
$series = GusBdl::dataByUnit('011212000000', [60559, 72305], [2022, 2023])->keyBy('variableId');
$series[72305]->toArray(); // [2022 => 768015.0, 2023 => 771436.0]
// Shortcut accepting a BDL id, a locality id or a TERYT code
$density = GusBdl::series(60559, '1261011', [2023]);
$density->latest()->value; // 2466.6
$density->latest()->attributeId; // 1 = regular value
$density->lastUpdate; // CarbonImmutable, Europe/Warsaw
// Statistical localities
GusBdl::localityDataByVariable(420, '011200000000')->take(10)->all();
GusBdl::dataByUnit('011212001011-0981682', 148190); // routed to data/localities/by-unit
Every DataPoint has year, value and attributeId. Attribute 1 marks a regular value; other attributes describe
missing, confidential, estimated or incomplete values, which the API still returns (usually as 0). Read their
meaning from GusBdl::attributes() (symbol x, a, s, M, ...). aggregateId selects another aggregation (see
GusBdl::aggregates()).
Dictionaries, languages and formats
GusBdl::attributes(); GusBdl::attribute(4); // symbol "x": confidential or not applicable
GusBdl::measures(); GusBdl::aggregates(); GusBdl::years(); GusBdl::levels();
GusBdl::metadata('variables'); // resource metadata
GusBdl::version(); // ["id" => "1.0", ...]
GusBdl::inLanguage('en')->subject('K3')->name; // name in English
GusBdl::withClientId($tenantKey)->variable(60559); // another key at runtime
use Beziworld\GusBdl\Enums\Format;
$xml = GusBdl::raw('data/by-variable/60559', ['unit-level' => 0, 'year' => [2023]], Format::Xml);
Quotas, rate limiting and caching
The documented quotas are:
| Window | Anonymous | With X-ClientId |
|---|---|---|
| 1 second | 5 | 10 |
| 15 minutes | 100 | 500 |
| 12 hours | 1 000 | 5 000 |
| 7 days | 10 000 | 50 000 |
The client counts requests in these windows in a cache store before sending them. When the per-second window is
full it waits (up to GUS_BDL_RATE_LIMIT_MAX_WAIT seconds); when a longer window is full it throws
RateLimitExceeded with retryAfter instead of spending the remaining budget. Use a shared store (Redis, database)
so that queue workers and web requests count together. The API may enforce lower anonymous quotas than documented
(its HTTP 429 messages have mentioned 50 requests per 15 minutes and 100 per 12 hours); stricter values can be set in
config/gus-bdl.php under rate_limit.limits, and registering a free key is the practical fix.
On HTTP 429 the client reads Retry-After (sent as "340 sek"), throws RateLimitExceeded and refuses further
requests locally until the block expires, so a loop does not keep hammering the API.
Responses are cached. A cached response is served without any request while it is fresh (the API sends
max-age=1800; override with GUS_BDL_CACHE_TTL). Afterwards it is revalidated with If-None-Match; on
304 Not Modified the cached body is reused. Dictionaries change rarely, so a long TTL is safe for them.
Artisan
php artisan bdl:search "gęstość zaludnienia"
php artisan bdl:search samochody --subjects
php artisan bdl:search ludność --subject=P2425 --level=6 --lang=en --limit=50
php artisan bdl:data 60559 1261011 --year=2022 --year=2023
php artisan bdl:data 60559 011212161011
Errors
| Situation | Result |
|---|---|
| HTTP 404 (unknown id), unknown TERYT code | NotFound (httpStatus, path, errorCode) |
| HTTP 429 from the API | RateLimitExceeded (retryAfter, window, fromServer = true) |
| Local quota exhausted or server block still active | RateLimitExceeded (fromServer = false for a local quota) |
| HTTP 4xx (for example 412 for page size above 100), HTTP 5xx after retries | ApiException (httpStatus, errorCode, isServerError()) |
| Connection failure or timeout after retries | ApiException wrapping the ConnectionException |
| Body that is not JSON | ApiException |
Invalid configuration (GUS_BDL_LANG, page size, URL, limits) |
InvalidConfiguration |
| Empty search phrase, invalid TERYT code length, no variable ids | InvalidArgumentException |
All package exceptions extend Beziworld\GusBdl\Exceptions\GusBdlException.
Development
composer install
composer ci # pint --test, phpstan (level 8), pest
Tests use responses captured from the live API (tests/Fixtures); no request leaves the test suite.
Author
Created and maintained by Kamil Ćwiertnia, beziworld.eu.
- E-mail: biuro@beziworld.eu
- Issues: GitHub issues
Need an integration? Get in touch through beziworld.eu.
Other packages by the author
laravel-teryt · laravel-krs · laravel-company-data · laravel-nbp
License
MIT. See LICENSE.md. The data is published by Statistics Poland (Główny Urząd Statystyczny) under the Creative Commons Attribution 4.0 licence; credit GUS as the source when you republish it. This package is not affiliated with or endorsed by Statistics Poland.