beziworld / laravel-teryt
Laravel integration of the Polish TERYT register (GUS): TERC/SIMC/ULIC import into Eloquent models, TERYT ws1 SOAP client, postal codes and address validation
Requires
- php: ^8.3
- ext-dom: *
- ext-iconv: *
- ext-json: *
- ext-mbstring: *
- ext-xmlreader: *
- ext-zip: *
- guzzlehttp/guzzle: ^7.8
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- illuminate/validation: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.7|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
- phpstan/phpstan-strict-rules: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-04 18:37:20 UTC
README
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:importdownloads 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:syncapplies the official change files (additions, removals, renames, moves) since the last import- Eloquent models
Voivodeship,County,Commune,Locality,Street,LocalityType,PostalCodewith 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
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 byforLocality()orfield().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: formatNN-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.
- E-mail: biuro@beziworld.eu
- Issues: GitHub issues
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.