Looking to hire Laravel developers? Try LaraJobs

laravel-teryt maintained by beziworld

Description
Laravel integration of the Polish TERYT register (GUS): TERC/SIMC/ULIC import into Eloquent models, TERYT ws1 SOAP client, postal codes and address validation
Last update
2026/11/04 19:36 (dev-master)
License
Downloads
1

Comments
comments powered by Disqus

Laravel TERYT

tests Latest Version License

Laravel integration of TERYT, the official Polish territorial register kept by Statistics Poland (GUS): voivodeships, counties and communes (TERC), localities (SIMC), streets (ULIC) and locality types (WMRODZ).

  • teryt:import downloads the current dictionaries from the TERYT ws1 web service (or reads the ZIP/CSV/XML files downloaded from eteryt.stat.gov.pl) and upserts them in chunks with a flat memory profile (310 000 streets in about 20 s with less than 4 MB of extra PHP memory)
  • teryt:sync applies the official change files (additions, removals, renames, moves) since the last import
  • Eloquent models Voivodeship, County, Commune, Locality, Street, LocalityType, PostalCode with relations and scopes, publishable migrations, configurable table prefix and connection
  • address autocomplete with Polish diacritics folding ("lodz" finds "Łódź", "wincentego b" finds "ul. Wincentego Bałysa")
  • TERYT ws1 SOAP client without ext-soap (WS-Security UsernameToken + WS-Addressing): search localities and streets, verify addresses by identifiers or names, list units, check the catalogue state date
  • postal codes: importer for a postal code (PNA) file you supply, linked to SIMC localities
  • validation rules TerytLocality, TerytStreet, PostalCodeMatchesLocality, PolishPostalCode (EN/PL messages)
  • events CatalogImported, CatalogSynced, PostalCodesImported

Polska wersja dokumentacji

Why this package

Existing PHP packages for TERYT (mrcnpdlk/teryt-api, goosfraba/teryt-sdk) are thin ws1 clients that require ext-soap and an extra WS-Security library; fsi/teryt-database-bundle targets Symfony and Doctrine. None of them is a Laravel package, none synchronises a local copy with the change files, and none offers diacritics-insensitive autocomplete, postal code mapping or validation rules. This package covers the whole path: official files, local Eloquent tables, daily updates and form validation.

Installation

composer require beziworld/laravel-teryt
php artisan migrate
php artisan teryt:import          # downloads and imports WMRODZ, TERC, SIMC and ULIC

Requirements: PHP 8.3+, Laravel 12 or 13, ext-dom, ext-xmlreader, ext-zip, ext-iconv, ext-mbstring. Works on MySQL, MariaDB, PostgreSQL and SQLite.

The migration is loaded automatically. To customise it, publish it and disable auto-loading:

php artisan vendor:publish --tag=teryt-migrations
php artisan vendor:publish --tag=teryt-config      # optional
TERYT_ENV=test                 # test (public account) or production
TERYT_USERNAME=                # production account (free, e-mail teryt_ws1@stat.gov.pl)
TERYT_PASSWORD=
TERYT_VARIANT=official         # official (urzędowy) or address (adresowy) version of the dictionaries
TERYT_TABLE_PREFIX=teryt_
TERYT_DB_CONNECTION=           # empty = default connection
TERYT_RUN_MIGRATIONS=true      # set to false after publishing the migration
TERYT_CHUNK_SIZE=1000
TERYT_TIMEOUT=60
TERYT_DOWNLOAD_TIMEOUT=600
TERYT_RETRIES=2
TERYT_DOWNLOAD_PATH=           # empty = storage/app/teryt

Accounts and environments

GUS documents a public test account (TestPubliczny / 1234abcd) for https://uslugaterytws1test.stat.gov.pl; with TERYT_ENV=test and no credentials the package uses it. The test environment served current data when this package was written, but for production use request a personal account by e-mail to teryt_ws1@stat.gov.pl (free of charge) and set TERYT_ENV=production. The test account does not work on the production host.

Without any account you can still import the files downloaded by hand from eteryt.stat.gov.pl ("Pliki pełne").

Importing the dictionaries

php artisan teryt:import                                   # all four dictionaries from ws1, state as of today
php artisan teryt:import --catalog=simc --catalog=ulic     # selected dictionaries
php artisan teryt:import --date=2026-01-01                 # a historical state
php artisan teryt:import --variant=address                 # address version (TERC_ADR, SIMC_ADR, ULIC_ADR)
php artisan teryt:import --file=storage/SIMC_Urzedowy_2026-11-04.zip --file=storage/ULIC_Urzedowy_2026-11-04.zip
php artisan teryt:import --file=simc:/tmp/localities.csv   # dictionary given explicitly
php artisan teryt:import --no-prune --keep

Files are processed as streams: the base64 payload of the SOAP response is decoded straight to disk and the CSV member of the ZIP archive is read line by line. Every row of a run gets the same imported_at stamp; after a complete import the rows missing from the file (abolished units, removed streets) are deleted, unless --no-prune is given. An empty file never removes anything.

The same from code:

use Beziworld\Teryt\Enums\Catalog;
use Beziworld\Teryt\Facades\Teryt;

Teryt::import(Catalog::Ulic);                            // ImportResult: rows, removed, stateDate, source
Teryt::importFile(Catalog::Simc, storage_path('SIMC_Urzedowy_2026-11-04.zip'));

Keeping the data current

ULIC changes almost every working day; TERC and SIMC usually once a year. Schedule the synchronisation:

// routes/console.php
Schedule::command('teryt:sync')->dailyAt('05:00');
php artisan teryt:sync                         # terc, simc, ulic since the last import or sync
php artisan teryt:sync --catalog=ulic --from=2026-10-01 --to=2026-11-04
php artisan teryt:status --remote              # local state vs. state dates published by ws1

teryt:sync downloads PobierzZmiany* change files and applies the correction types documented by GUS: TERC D/U/M, SIMC D/U/Z/P (including moves between communes), ULIC D/U/M/Z (including name split changes applied to every location of a street). The end of the applied period is stored in the teryt_states table.

Models

Model Key Relations Scopes
Voivodeship code (2) counties, communes, localities search, named
County code (4: WOJ+POW) voivodeship, communes, localities search, named, inVoivodeship; isCity()
Commune code (7: WOJ+POW+GMI+RODZ) voivodeship, county, localities, streets search, named, ofType, inCounty, inVoivodeship
Locality symbol (7, SIMC) commune, county, voivodeship, parent, parts, streets, postalCodes, localityType search, named, cities, main, ofKind, inCommune, inCounty, inVoivodeship, within
Street id; unique (locality_symbol, symbol) locality, commune search (main name or full name), inLocality
LocalityType code (RM) localities
PostalCode id locality, commune forCode, startingWith
use Beziworld\Teryt\Enums\CommuneType;
use Beziworld\Teryt\Enums\LocalityKind;
use Beziworld\Teryt\Models\Commune;
use Beziworld\Teryt\Models\Locality;

$wadowice = Locality::with(['commune', 'county', 'voivodeship'])->find('0926921');
$wadowice->label();                 // "Wadowice (gm. Wadowice, pow. wadowicki, woj. małopolskie)"
$wadowice->kind();                  // LocalityKind::City
$wadowice->streets()->search('lwow')->get();
$wadowice->parts;                   // integral parts such as "Zaskawie" (część miasta)

Locality::search('łódź')->cities()->first();
Locality::ofKind(LocalityKind::Village)->inCommune('121809')->count();   // 6 characters: the whole commune
Commune::ofType(CommuneType::UrbanRural)->inVoivodeship('12')->get();

An urban-rural commune (type 3) is divided into a town (type 4) and a rural area (type 5); localities point at the type 4 or 5 record. A 6-character code in inCommune() covers all of them.

Autocomplete

use Beziworld\Teryt\Facades\Teryt;

Teryt::localities('wado');                       // exact matches, cities, main localities and shorter names first
Teryt::localities('gor', limit: 20, within: '1218');   // within a voivodeship (2), county (4) or commune (6/7)
Teryt::streets('0926921', 'wincentego b');       // "ul. Wincentego Bałysa"
Teryt::postalCodes('0926921');                   // ["34-100"] (requires imported postal codes)
Teryt::localitiesForPostalCode('34-100');

Each name is stored with a folded key (name_search: lowercase, without Polish diacritics, punctuation turned into spaces), so searches use an index-friendly LIKE 'key%' on every database engine.

Live lookups (TERYT ws1)

use Beziworld\Teryt\Enums\Catalog;
use Beziworld\Teryt\Facades\Teryt;

$client = Teryt::client();

$client->isLoggedIn();                                   // CzyZalogowany
$client->catalogStateDate(Catalog::Ulic);                // CarbonImmutable 2026-11-04
$client->searchLocalities('Wadowice');                   // list<Data\Locality>
$client->searchLocalitiesInRegister('Wadowice', scope: '001', date: now());
$client->localitiesInCommune('1218094');
$client->searchStreets('Lwowska', 'ul.', 'Wadowice');   // list<Data\Street>
$client->searchStreetsInRegister('Lwowska');
$client->streetsInLocality('1218094', '0926921');
$client->verifyLocality('0926921');                      // list<Data\VerifiedAddress>
$client->verifyStreet('0926921', '11467');
$client->verifyAddress('małopolskie', 'wadowicki', 'Wadowice', 'Wadowice', 'Lwowska');
$client->voivodeships(); $client->counties('12'); $client->communes('12', '18'); $client->searchUnits('Wadowice');
$client->downloadCatalog(Catalog::Simc, storage_path('teryt'));     // Data\CatalogFile (ZIP on disk)
$client->downloadChanges(Catalog::Ulic, $from, $to, storage_path('teryt'));
$client->call('PobierzSlownikCechULIC');                 // any other ws1 method, parsed into arrays

The ws1 methods return the county and commune symbols in different widths ("18" or "1218", "09" or "1218094"); the DTOs normalise them, and communeTerc() / terc() build the full identifiers.

Postal codes

Poczta Polska sells the official list of postal codes ("Oficjalny Spis Pocztowych Numerów Adresowych", PNA) under a licence that does not allow redistribution, and no official open dataset exists. The package therefore neither ships nor downloads postal codes. Import a file you are entitled to use:

php artisan teryt:import-postal-codes storage/pna.csv
php artisan teryt:import-postal-codes storage/pna.txt --delimiter="\t" --encoding=Windows-1250 \
    --column=code=PNA --column=locality="Miejscowość" --column=street=Ulica

Columns are recognised by header (compared without case and diacritics): code (PNA, kod pocztowy), locality (miejscowość), and optionally street, numbers, commune, county, voivodeship and simc. Extend the aliases in config/teryt.php. Each row is linked to a SIMC locality through the simc column when present, otherwise by name, narrowed by commune, county and voivodeship; ambiguous rows keep locality_symbol = null. A new import replaces the previous one.

Validation

use Beziworld\Teryt\Rules\PolishPostalCode;
use Beziworld\Teryt\Rules\PostalCodeMatchesLocality;
use Beziworld\Teryt\Rules\TerytLocality;
use Beziworld\Teryt\Rules\TerytStreet;

$request->validate([
    'city_id' => ['required', (new TerytLocality())->inVoivodeship('12')],
    'street_id' => ['nullable', TerytStreet::field('city_id')],
    'postal_code' => ['required', new PolishPostalCode(), PostalCodeMatchesLocality::field('city_id')],
]);
  • TerytLocality: 7-digit SIMC symbol present in the database; inCommune(), inCounty(), inVoivodeship(), mainOnly().
  • TerytStreet: 5-digit ULIC symbol of the locality given by forLocality() or field().
  • PostalCodeMatchesLocality: the code belongs to the locality or its main locality (a "część miasta" uses the codes of its city). Passes when no postal code data exists for the locality; strict() fails instead.
  • PolishPostalCode: format NN-NNN; strict() rejects "34100".

Messages are translated (English and Polish); publish them with --tag=teryt-lang.

Errors

Situation Result
SOAP fault (wrong credentials: InvalidSecurityToken, HTTP 500) ApiException (faultCode(), httpStatus(), operation(), isAuthenticationError())
HTTP 5xx without a SOAP fault, connection error retried TERYT_RETRIES times, then ApiException
HTTP 429 ApiException with isRateLimited() and retryAfter(); never retried
Response without the expected file ApiException::invalidResponse
Missing file, unreadable ZIP, missing columns ImportException
teryt:sync before any import ImportException::notSynchronisable
Production without credentials, invalid environment, variant, prefix or URL InvalidConfiguration

All package exceptions extend Beziworld\Teryt\Exceptions\TerytException. All ws1 calls are read-only.

Development

composer install
composer ci   # pint --test, phpstan (level 8 + strict rules), pest

Tests use SOAP envelopes captured from the TERYT ws1 test environment, small extracts of the official TERC, SIMC, ULIC and WMRODZ files (tests/Fixtures) and an in-memory SQLite database; 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-uldk · laravel-polish-validation · laravel-company-data · laravel-poczta-polska

License

MIT. See LICENSE.md. TERYT data comes from the public register kept by Statistics Poland (Główny Urząd Statystyczny) and is provided free of charge through the TERYT ws1 service under its terms of use. This package is not affiliated with or endorsed by GUS or Poczta Polska.