Looking to hire Laravel developers? Try LaraJobs
This package is not available.

laravel-gus-bdl maintained by beziworld

Description
Laravel client for the GUS Local Data Bank (Bank Danych Lokalnych, BDL) API: subjects, variables, territorial units, statistical data series, ETag caching and quota-aware rate limiting
Last update
2026/11/04 22:08 (dev-master)
License
Downloads
1

Comments
comments powered by Disqus

Laravel GUS BDL

tests Latest Version License

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 DataSeries with 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:search and bdl:data Artisan commands

Polska wersja dokumentacji

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.

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.