laravel-nfz maintained by beziworld
Laravel NFZ
Laravel integration with the Polish National Health Fund (Narodowy Fundusz Zdrowia, NFZ):
- Waiting times ("Terminy leczenia", API 1.4): search waiting lists by benefit, voivodeship, locality, county, provider, urgent or stable case and children age groups, with the predicted waiting time (PCUS), waiting list statistics, contact details and accessibility of the place
- Contracts ("Umowy NFZ", API 1.2): contracts of providers with values and plans per product and month, providers, service types, contract products, orthopaedic supplies
- Statistics: generic read-only access to the benefit, pharmacy refund, chemotherapy and drug programme APIs
- eWUS: verification of a patient's entitlement to publicly funded health care by PESEL, login with session caching and automatic re-login, MFA (TOTP) support, password change, many operator accounts, the signed NFZ confirmation document kept for archiving
InsuredInNfzvalidation rule,nfz:check-insuranceandnfz:waiting-timesArtisan commands- typed
final readonlyDTOs, lazy pagination withLazyCollection, caching, retries of read-only calls, SOAP withoutext-soap, every HTTP call fakeable withHttp::fake()
Why this package
| this package | gilek/ewus |
dartui/ewus |
nextgen-tech/ewus-laravel |
|
|---|---|---|---|---|
| Laravel 12 / 13 | yes | no Laravel integration | Laravel 5 only (2018) | Laravel 6 to 8 (2020) |
| eWUS check, password change | yes | yes | via gilek 2.0-alpha | yes |
| MFA (TOTP) login, query block codes | yes | no | no | no |
| session cache, re-login, daily result cache | yes | no | no | no |
| NFZ open data APIs (waiting times, contracts) | yes | no | no | no |
No other Packagist package covers the NFZ open data APIs.
Installation
composer require beziworld/laravel-nfz
php artisan vendor:publish --tag=nfz-config # optional
The open data APIs work without configuration. eWUS needs the operator account of the provider portal of your NFZ branch:
NFZ_EWUS_DOMAIN=07 # NFZ branch code, 01-16
NFZ_EWUS_LOGIN=
NFZ_EWUS_PASSWORD=
NFZ_EWUS_OPERATOR_TYPE= # SWD (provider) or LEK (doctor); required by branches 01, 04, 05, 06, 08, 09, 11, 12
NFZ_EWUS_OPERATOR_ID= # provider (idntSwd) or doctor (idntLek) identifier
NFZ_EWUS_MFA_SECRET= # Base32 TOTP secret for accounts with MFA; a code is generated for every login
NFZ_EWUS_TEST=false # true = NFZ test environment
NFZ_EWUS_SESSION_TTL=600 # seconds a session is reused
NFZ_EWUS_CACHE_RESULTS=true # cache results until midnight (one check per patient per day)
NFZ_CACHE_TTL=21600 # open data cache in seconds; 0 disables it
NFZ_CACHE_STORE=
NFZ_RETRIES=2 # retries of read-only calls after connection errors and HTTP 5xx
NFZ_TIMEOUT=30
Waiting times
use Beziworld\Nfz\Facades\Nfz;
use Beziworld\Nfz\Enums\ChildAgeRange;
use Beziworld\Nfz\Query\QueueSearch;
$search = QueueSearch::for('PORADNIA KARDIOLOGICZNA') // full-text fragment, e.g. "kardiolog"
->in('mazowieckie') // code "07", name or Province::Mazowieckie
->locality('WARSZAWA')
->urgent() // stable is the default
->forChildren(ChildAgeRange::UpTo3); // or forAdults()
foreach (Nfz::waitingTimes()->search($search)->take(50) as $queue) { // LazyCollection, pages of 25
$queue->benefit; // "PORADNIA KARDIOLOGICZNA"
$queue->provider; // provider name, ->providerCode, ->providerNip, ->providerRegon
$queue->place->name; // "PORADNIA KARDIOLOGICZNA DLA DZIECI"
$queue->place->address; // "DALEKA 11", ->locality, ->county, ->latitude, ->longitude
$queue->place->bookingPhone(); // registration phone, otherwise the general one
$queue->place->accessibility->elevator; // true / false / null (no information)
$queue->forecast->text; // predicted waiting time as published, e.g. "14 dni"
$queue->forecast->days; // 14
$queue->forecast->expectedDate(); // CarbonImmutable
$queue->statistics->awaiting; // people on the list at the end of the month
$queue->statistics->averageWaitDays;
}
$page = Nfz::waitingTimes()->searchPage(['benefit' => 'ortoped', 'province' => '12'], page: 2, limit: 25);
$page->total; $page->lastPage(); $page->hasMorePages();
$queue = Nfz::waitingTimes()->queue($id); // null for an unknown id
$other = Nfz::waitingTimes()->otherPlaces($id); // the same benefit in other places of the provider
Since version 1.4 of the API (July 2026) NFZ publishes the predicted waiting time (PCUS) instead of the former "first available date"; the package follows the current version.
Dictionaries (v2, with province, county, locality and street):
Nfz::waitingTimes()->benefits('kardio'); // LazyCollection<string>
Nfz::waitingTimes()->localities('krak', province: '06'); // LazyCollection<DictionaryItem>
Nfz::waitingTimes()->counties(province: '06');
Nfz::waitingTimes()->streets('długa', '06', locality: 'KRAKÓW');
Nfz::waitingTimes()->places('poradnia', '06');
Nfz::waitingTimes()->providers('szpital', '06'); // ->code holds the provider code
The terms of the API require citing the source of the data: https://api.nfz.gov.pl/.
Contracts
$agreements = Nfz::contracts()->agreements([
'year' => 2025,
'branch' => '06', // required, NFZ branch code or name
'providerName' => 'szpital', // also: serviceType, productCode, providerCode, place, nip, regon,
'updatedSince' => now()->subWeek(), // updatedSince (date), sort ("attribute:asc|desc")
]);
foreach ($agreements as $agreement) {
$agreement->code; // "064/100032/PPZ/08/2025"
$agreement->serviceName; // "Programy pilotażowe"
$agreement->amount; // "54720.00" (decimal string), ->currency "PLN"
}
$details = Nfz::contracts()->agreement($agreement->id); // ->plans (product, quantity, price, from, to)
$plan = Nfz::contracts()->plan($details->plans[0]->id); // ->months, all pages read
$month = Nfz::contracts()->month($plan->months[0]->id); // ->packages with unit products and weights
$provider = Nfz::contracts()->provider(2025, '064/100032', '06'); // provider with all its contracts
Nfz::contracts()->providers(['year' => 2025, 'branch' => '06']); // with the yearly total ("amount")
Nfz::contracts()->serviceTypes(2025); // "01 Podstawowa Opieka Zdrowotna", ...
Nfz::contracts()->contractProducts(2025, name: 'rehabilitacja');
Nfz::contracts()->orthopedicSupplies(2025);
Nfz::contracts()->availableYears(); // [2008, ..., 2026]
Statistics
The statistical APIs expose many report-like resources, so results are returned as arrays. Resource names and parameters follow the Swagger documentation of each API.
use Beziworld\Nfz\Enums\StatisticsApi;
Nfz::statistics(StatisticsApi::PharmacyRefunds)->all('medicine-products', ['name' => 'apap']); // LazyCollection
Nfz::statistics(StatisticsApi::Benefits)->page('sections', page: 1);
Nfz::statistics(StatisticsApi::DrugPrograms)->get('available-periods');
eWUS
$check = Nfz::ewus()->check('79060804378'); // validated locally first; nothing is sent for a malformed PESEL
$check->status; // InsuranceStatus::Insured, NotInsured, NotFound (not in CWU), PeselAnnulled
$check->isInsured();
$check->validUntil; // day of the confirmation
$check->firstName; // "ImięTAK", ->lastName, ->fullName()
$check->prescriptionMark; // "DN" for entitled persons under 18, ->hasDnMark()
$check->additionalInfo; // list of AdditionalInfo: code ("PESEL-UKR-DOROSLY"), level (Information/Warning), value
$check->operationId; // "L1712M01200000001"
$check->checkedAt;
$check->document; // the signed NFZ confirmation (status_cwu_odp XML), cut byte for byte for archiving
$check->maskedPesel(); // "********378", use it in logs
Nfz::ewus()->isInsured($pesel);
Nfz::ewus()->check($pesel, fresh: true); // ignore the result cached for today
The client logs in on the first call and reuses the session (NFZ_EWUS_SESSION_TTL). When eWUS reports an expired
session or token, it logs in again and repeats the check once. Logins are never retried after an authentication
error. NFZ allows checking only patients served on the given day and at most once per patient per day, so results
are cached until midnight under a keyed hash of the PESEL (NFZ_EWUS_CACHE_RESULTS=false disables it).
Nfz::ewus()->login(); // Session: ->messageCode, ->passwordExpiresInDays
Nfz::ewus()->logout();
Nfz::ewus()->changePassword('new password'); // also changes the password in the branch portal
Nfz::ewus()->changeExpiredPassword('new password'); // after login threw PasswordExpired
Many accounts
// config/nfz.php
'ewus' => [
'connections' => [
'clinic' => ['domain' => '07', 'login' => '...', 'password' => '...'],
'lab' => ['domain' => '06', 'operator_type' => 'SWD', 'operator_id' => '...', 'login' => '...', 'password' => '...'],
],
],
Nfz::ewus('lab')->check($pesel);
// credentials stored per tenant
Nfz::ewusUsing(['domain' => $tenant->nfz_branch, 'login' => $tenant->ewus_login, 'password' => $tenant->ewus_password])->check($pesel);
Test environment
NFZ publishes a test environment with shared accounts (domain 15, login TEST1, password qwerty!@#; MFA
variants TEST1_MFA with the TOTP secret from the interface specification). In the test environment any even PESEL
is insured and any odd one is not; 01010153201 is not in the register, 19323197971 has the DN mark,
00060958187 is annulled and 50323199945 triggers the daily query block.
NFZ_EWUS_TEST=true
NFZ_EWUS_DOMAIN=15
NFZ_EWUS_LOGIN=TEST1
NFZ_EWUS_PASSWORD="qwerty!@#"
Validation
use Beziworld\Nfz\Rules\InsuredInNfz;
$request->validate(['pesel' => ['required', new InsuredInNfz()]]); // passes when eWUS is unreachable or blocked
(new InsuredInNfz())->strict(); // fails when eWUS cannot confirm the entitlement
(new InsuredInNfz())->connection('lab'); // named eWUS connection
The PESEL format (checksum and date of birth) is validated before any request. Rejected credentials and an expired password are configuration errors and are thrown.
Artisan
php artisan nfz:check-insurance 79060804378 # PESEL masked in the output; exit code 1 when not insured
php artisan nfz:check-insurance 79060804378 --with-name --connection=lab --fresh
php artisan nfz:waiting-times kardiolog --province=mazowieckie --locality=WARSZAWA --urgent --limit=20
Errors
| Exception | When |
|---|---|
InvalidConfiguration |
missing or invalid option, unknown connection; names the environment variable |
InvalidPesel |
malformed PESEL, raised before sending; the message holds the masked number only |
ApiException |
open data API error: httpStatus, errorCode (e.g. 1200007), reason, solution |
RateLimited |
HTTP 429 (limit of 10 requests per second per IP), never retried; retryAfter |
AuthenticationFailed |
eWUS rejected the credentials; wait at least 15 minutes after a temporary lock |
PasswordExpired |
the operator password has expired; use changeExpiredPassword() |
SessionExpired |
session or token expired (handled automatically during checks) |
AccessDenied |
authorisation refused; blockCode -100 to -105 for query blocks, blockDetail, isAdministrativeBlock() |
InvalidInput |
eWUS rejected the input |
EwusException |
other SOAP faults and transport errors: faultType, faultCode, operation |
All exceptions extend Beziworld\Nfz\Exceptions\NfzException. eWUS messages never contain a PESEL.
Development
composer install
composer ci # pint --test, phpstan (level 8), pest
Tests use responses captured from the live open data APIs and from the eWUS test environment (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-polish-validation · laravel-teryt · laravel-krs · laravel-ceidg
License
MIT. See LICENSE.md. The open data comes from https://api.nfz.gov.pl/ and eWUS is operated by the National Health Fund (Narodowy Fundusz Zdrowia). This package is not affiliated with or endorsed by NFZ.