id-sign / business-registers
Typed PHP client for Czech business registers: ARES, VAT register (ADIS), VIES and the insolvency register (ISIR).
Requires
- php: >=8.4
- ext-dom: *
- symfony/http-client-contracts: ^3.5
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.94
- phpstan/phpstan: ^2.1
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^13.0
- symfony/http-client: ^7.4 || ^8.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-09 14:58:20 UTC
README
Typed, stateless PHP client for Czech business registers. One call gives you a company profile that combines four sources, so you never deal with JSON, SOAP or per-source quirks yourself:
- ARES — company identity: name, seat, legal form, VAT ids, status in 16 source registers; bulk lookup and search.
- VAT register (ADIS) — VAT payer status, unreliable payers, published bank accounts.
- VIES — validity of an EU VAT id, with a consultation number.
- Insolvency register (ISIR) — insolvency proceedings of a subject by IČO, birth number or name and birth date, ongoing or ended.
Not covered: register extracts (public register, trade register), ARES code lists and the ARES change feed.
What you can get
Company identity (ARES) — by IČO, for up to 100 IČO per request, or by search:
- name, IČO (or the ARES id of a subject without one), legal form code
- VAT id (DIČ) and, for a member of a VAT group, the group's VAT id
- registered seat: full text, street with house numbers, city, district, postal code, county, region, country, RÚIAN address point and municipality codes; delivery address lines
- date of establishment, end of existence or registration (
ceasedOn, ARESdatumZaniku) and last update - CZ-NACE activity codes (2025 and 2008 classification)
- public register file number (e.g.
B 1581/MSPH), tax office code, primary source register - status in 16 source registers (public register, trade register, VAT, VAT group, insolvency, …): active, historical, ended, suspended, …
- search by name, address text, municipality, legal form, CZ-NACE or tax office (up to 1 000 results)
VAT register (ADIS) — by DIČ, for up to 100 DIČ per request:
- subject type: VAT payer, VAT group, identified person, unreliable person
- unreliability (ADIS
nespolehlivyPlatce, also set for unreliable persons and for identified persons the register marks unreliable) and the date it was published - published bank accounts, with publication dates, including accounts no longer published; a check whether a given account (domestic form or Czech IBAN) is published
- name, address and tax office of the subject
- the complete list of unreliable payers
EU VAT id (VIES) — for any EU member state:
- whether the VAT id is valid
- name and address, where the member state discloses them
- a consultation number as evidence that the check was made, when you pass your own VAT id (one of the elements of evidence for the exemption of intra-community supplies, not a proof on its own)
- whether a declared name, street, postal code, city and company type match the register, where the member state compares them
Insolvency register (ISIR) — by IČO, or for a natural person by birth number or by name and birth date; one request per lookup:
- every proceeding the register currently lists for the subject, ongoing and ended: case reference, court, state, date of the decision on insolvency, end date, link to the case file
- whether a proceeding is ongoing, a filed petition included
- the debtor as the register publishes it: name, address, birth number and birth date of natural persons
Company profile — one call that combines the sources above, reports per source whether it answered, and derives risk flags: ceased, in liquidation, insolvency record, ongoing insolvency proceeding, unreliable VAT payer, unreliable person, VAT registration ended, VAT payer without a published bank account, VAT id invalid in VIES. For a list of IČO, one call builds all profiles with one ARES and one ADIS request per 100 companies.
Validation without a request — IČO (including the check digit) and DIČ (format; strict digits-only check for CZ) are normalised and validated locally before any register is asked. An IČO that ARES itself returns is taken as the register holds it, even when it fails the check digit (see § What the data means).
Requirements
- PHP 8.4+
ext-dom- any implementation of
symfony/http-client-contracts(for examplesymfony/http-client)
Installation
composer require id-sign/business-registers symfony/http-client
Symfony
The library is a plain PHP library, not a bundle. Register the services in config/services.yaml; the HTTP client is
autowired:
IdSign\BusinessRegisters\Ares\AresClient: ~ IdSign\BusinessRegisters\Ares\CompanyDirectory: '@IdSign\BusinessRegisters\Ares\AresClient' IdSign\BusinessRegisters\Adis\VatRegisterClient: ~ IdSign\BusinessRegisters\Adis\VatRegister: '@IdSign\BusinessRegisters\Adis\VatRegisterClient' IdSign\BusinessRegisters\Vies\ViesClient: ~ IdSign\BusinessRegisters\Vies\Vies: '@IdSign\BusinessRegisters\Vies\ViesClient' IdSign\BusinessRegisters\Isir\InsolvencyClient: ~ IdSign\BusinessRegisters\Isir\InsolvencyRegister: '@IdSign\BusinessRegisters\Isir\InsolvencyClient' IdSign\BusinessRegisters\CompanyLookup: ~
Without a framework
use IdSign\BusinessRegisters\Adis\VatRegisterClient; use IdSign\BusinessRegisters\Ares\AresClient; use IdSign\BusinessRegisters\CompanyLookup; use IdSign\BusinessRegisters\Isir\InsolvencyClient; use IdSign\BusinessRegisters\Vies\ViesClient; use Symfony\Component\HttpClient\HttpClient; $http = HttpClient::create(); $ares = new AresClient($http); $vatRegister = new VatRegisterClient($http); $vies = new ViesClient($http); $isir = new InsolvencyClient($http); $lookup = new CompanyLookup($ares, $vatRegister, $vies, $isir);
Each client takes $endpoint (default: the production service) and $timeout in seconds (default 10.0) as optional
constructor arguments. The clients keep no state, so one instance can serve the whole process (FrankenPHP workers
included).
AresClient and VatRegisterClient also take $maxConcurrency: how many batches of one findMany() call are sent at
the same time, 1 to 4 (anything else is an \InvalidArgumentException). Defaults: ARES 2, ADIS 4 — the ADIS
operator allows at most 4 parallel requests per source IP address. Lower it when several workers share one IP address,
e.g. new VatRegisterClient($http, maxConcurrency: 1), or in Symfony services.yaml
IdSign\BusinessRegisters\Adis\VatRegisterClient: { arguments: { $maxConcurrency: 1 } }. $timeout applies to each
request and includes the time the request waits queued at the source, so with a shared IP address or a tight timeout
lower $maxConcurrency too. The batches really run in parallel only with an asynchronous client such as
CurlHttpClient; NativeHttpClient (the fallback of HttpClient::create() without ext-curl) sends them one after
another. The library does not count requests per minute, hour or day.
Usage
Company profile (facade)
CompanyLookup::byCompanyId() asks ARES and the sections you request. Without sections it makes exactly one request.
use IdSign\BusinessRegisters\RiskFlag; use IdSign\BusinessRegisters\Section; use IdSign\BusinessRegisters\SectionStatus; $profile = $lookup->byCompanyId('452 746 49', Section::Vat, Section::Vies, Section::Insolvency); if (null === $profile) { // ARES does not hold the subject (usually also true for deleted subjects) return; } echo $profile->company->name; // "ČEZ, a. s." $profile->isComplete(); // false if a requested section is Unavailable or Rejected, or Insolvency is NotApplicable $profile->status(Section::Vat); // SectionStatus::Ok $profile->vat?->type; // SubjectType::VatPayer $profile->vies?->valid; // true $profile->insolvencies?->hasOngoing(); // false $profile->flags(); // list<RiskFlag>, e.g. [RiskFlag::UnreliableVatPayer] $profile->hasFlag(RiskFlag::Ceased); // false if (SectionStatus::Unavailable === $profile->status(Section::Vies)) { $profile->error(Section::Vies); // the ServiceUnavailable or InvalidResponse that caused it }
The sections are Section::Vat (VAT register, $profile->vat), Section::Vies ($profile->vies) and
Section::Insolvency (insolvency register ISIR, $profile->insolvencies). Vat and Vies are looked up under the
company's DIČ (Company::vatLookupId()), Insolvency under its IČO, so a company without a DIČ still gets an
Insolvency answer. Insolvency is Ok also when ISIR lists nothing (an empty collection); it is never NotFound.
The IČO may be given as a string in any spacing or a CompanyId. A section whose source is down does not fail the
lookup: its status becomes Unavailable and the other sections are still filled. A section whose source rejects the
request (an InvalidInput from ADIS, VIES or ISIR, e.g. VIES INVALID_REQUESTER_INFO for a wrong requester) becomes
Rejected the same way, with the exception and its errorCode in error(); the answer is unknown and asking again
will not help until the input or configuration is fixed. Only ARES errors are thrown, because without ARES there is no
profile.
Five meanings of null
$profile->vat, $profile->vies and $profile->insolvencies are null in five different situations; status()
tells them apart:
status($section) |
Meaning |
|---|---|
NotRequested |
the section was not passed to byCompanyId() / byCompanyIds() |
NotFound |
the source answered that it does not hold the subject |
NotApplicable |
the subject has no id the section is asked under (no DIČ for Vat and Vies, no IČO for Insolvency) |
Unavailable |
the source could not answer — the answer is unknown; error() holds the exception |
Rejected |
the source rejected the request — unknown; fix the input or configuration; error() holds the exception |
SectionStatus is string-backed (not_requested, ok, not_found, not_applicable, unavailable, rejected) and
json_encode($profile) works. The status values are a stable contract. A stored exception in errors encodes its
public properties only — source (ServiceUnavailable, InvalidResponse), errorCode (all three exceptions) and
connectionFailed (ServiceUnavailable), e.g. {"source":"isir","errorCode":"WS1"}; the message and the trace are
not part of the JSON.
Flags
flags() returns the raised RiskFlag cases in enum order. Flags are computed from ARES and only from sections whose
status is Ok. An absent flag means "clean" only when isComplete() is true and the section the flag comes from was
requested.
| Flag | Raised when | Needs |
|---|---|---|
Ceased |
ARES datumZaniku — the day the subject ceased to exist (zánik, NOZ § 185: deletion from the register) or its registration ended — is today or in the past (midnight Europe/Prague). Not the dissolution (zrušení): a company in liquidation is still active, see InLiquidation. A future date raises nothing (it stays in $company->ceasedOn) |
— |
InLiquidation |
the name contains the standalone phrase "v likvidaci" anywhere: at the end, before the legal form (… v likvidaci, s.r.o.), between dashes, slashes or parentheses; case-insensitive. Mandatory suffix for a legal person in liquidation (NOZ § 187 odst. 2); never raised for natural persons, foreign persons and branches, or a dissolution without liquidation |
— |
InsolvencyRecord |
ARES lists the subject in the insolvency register — a record, possibly a closed one | — |
Insolvency |
ISIR lists at least one ongoing proceeding (InsolvencyProceeding::isOngoing()), a filed petition included; a closed proceeding raises only InsolvencyRecord |
Section::Insolvency |
UnreliableVatPayer |
the VAT register marks a VAT payer or VAT group as unreliable (nespolehlivyPlatce); implies isVatPayer() === true |
Section::Vat |
UnreliablePerson |
the subject is an unreliable person under §106aa of the VAT Act: the register keeps it as an unreliable person, or marks a non-payer identified person as unreliable | Section::Vat |
VatRegistrationEnded |
ARES VAT registration is Ended or Historical, the subject is not in an active VAT group, and the VAT register (when Section::Vat is Ok) does not say the subject is a VAT payer |
— |
NoPublishedBankAccount |
a VAT payer or VAT group without any active published bank account | Section::Vat |
ViesInvalid |
VIES answered that the VAT id is not valid | Section::Vies |
Shortcuts: isVatPayer(), hasPublishedAccount() and isInInsolvency()
$profile->isVatPayer(); // ?bool $profile->hasPublishedAccount('27-5868650297/0100'); // ?bool
Both are tri-state and read Section::Vat:
status(Section::Vat) |
Result |
|---|---|
Ok |
from the VAT register: true / false |
NotFound, NotApplicable |
false — definitive |
Unavailable |
null — unknown, ask again later; never read it as "not a payer" or "account not published" |
Rejected |
null — unknown, fix the input or configuration; asking again will not help |
NotRequested |
throws \LogicException — you forgot to pass Section::Vat |
isInInsolvency() answers "is the subject in insolvency now?" from Section::Insolvency, tri-state as well, except
that NotApplicable is unknown:
$profile->isInInsolvency(); // ?bool
status(Section::Insolvency) |
Result |
|---|---|
Ok |
true when ISIR lists an ongoing proceeding (a filed petition included), else false |
NotApplicable |
null — unknown; the subject has no IČO, and ISIR can list a person under the birth number alone; isComplete() is false |
Unavailable |
null — unknown, ask again later; never read it as "not in insolvency" |
Rejected |
null — unknown, fix the input or configuration |
NotRequested |
throws \LogicException — you forgot to pass Section::Insolvency |
The facade asks ISIR by IČO only. For a company that is complete: the court always records a company's IČO. A natural
person with an IČO (a self-employed person) is recorded mainly under the name and birth number, and the court may leave
the IČO out; ISIR then finds nothing by IČO although the person is in insolvency. So for a natural person false (and
no RiskFlag::Insolvency) means "no proceeding recorded under this IČO", not "certainly not in insolvency". The legal
form in $profile->company tells the two apart; to be sure about a natural person, call
InsolvencyClient::findByBirthNumber() or findByNameAndBirthDate() with the data from your own records (name as on
the identity document), see Insolvency register (ISIR).
Requesting a section whose client was not passed to the CompanyLookup constructor also throws \LogicException,
before any request is made.
VAT group members
A member of a VAT group has a group DIČ (groupVatId); its own vatId in ARES is either null (Komerční banka,
45317054: group DIČ CZ699001182) or its former own DIČ (21985685, 05666112; ARES Vat = Ended or
Nonexistent). ADIS answers for the group under the group DIČ; in rare cases it still answers the member's own DIČ as a
VAT payer as well. The facade asks the group only: it looks a member up in ADIS and VIES under the group DIČ
(Company::vatLookupId(), groupVatId ?? vatId), so $profile->vat and $profile->vies describe the group. Do the
same when you call the clients yourself: send $company->vatLookupId(), never $company->vatId and never a DIČ derived
from the IČO.
VIES requester
Pass your own VAT id as the fifth constructor argument viesRequester to get a consultation number in
$profile->vies:
use IdSign\BusinessRegisters\VatId; $lookup = new CompanyLookup($ares, $vatRegister, $vies, $isir, viesRequester: VatId::parse('CZ12345678'));
Many companies at once
CompanyLookup::byCompanyIds() builds the profiles of a list of IČO with as few requests as possible: ARES is asked
with one findMany(), section Vat with one ADIS findMany() over the companies' vatLookupId() values (a VAT
group is asked once for all its members). 100 IČO with Section::Vat are 1 ARES and 1 ADIS request (both clients send
batches of 100). VIES has no bulk call: Section::Vies makes one check() per distinct lookup DIČ (a VAT group is
checked once for all its members), one after another, 1.3–2 s each, so 100 companies with Vies take about 3 minutes.
ISIR has no bulk call either: Section::Insolvency makes one find() per company, one after another in ARES response
order — typically 0.03–0.25 s each (100 companies ≈ 3–25 s). After two connection failures in a row (timeout, refused
or blocked connection, e.g. port 8443 closed by a firewall; ServiceUnavailable::$connectionFailed) to VIES or to
ISIR, each counted on its own, no further request to that source is sent in that call, so an unreachable source costs
two timeouts, not one per company; a single slow answer does not stop the call. Every company not yet asked gets that
section Unavailable with its own ServiceUnavailable ("… was not sent after 2 connection failures in a row",
connectionFailed true, the last failure as the previous exception). An answer, an error code or an HTTP error resets
the count.
use IdSign\BusinessRegisters\CompanyId; // ids your database stored from $company->id: re-hydrate with fromRegister(); parse() is for user input only $requested = array_map(CompanyId::fromRegister(...), $icosFromDatabase); // list<CompanyId> $profiles = $lookup->byCompanyIds($requested, Section::Vat); foreach ($profiles as $profile) { // values only, in ARES response order: each batch of 100 ascending $profile->status(Section::Vat); // by IČO, batches in request order — no global order } $profiles->get('45274649')?->isVatPayer(); // ids in any form, as in Companies foreach ($profiles->missing($requested) as $id) { // list<CompanyId> — not held by ARES }
The statuses mean the same as for one company. If the ADIS call fails (outage, invalid response), Vat is
Unavailable for every profile asked in that call; a company whose lookup DIČ is not Czech is not sent to ADIS and
gets Rejected. Apart from the stop after connection failures, a VIES outage or rejection affects only the companies
with that lookup DIČ, which share the stored exception. Once VIES rejects the requester (INVALID_REQUESTER_INFO), no further VIES request is sent in that
call: every company whose lookup DIČ was not yet checked gets Rejected with that same exception instance, whose
message names the DIČ of the request that was rejected; a company whose lookup DIČ was already checked keeps that
answer. The next call asks VIES again. Any other ISIR failure (an error code, an HTTP error, an invalid response)
affects only that company. Every IČO and the section clients are checked before
the first request; ARES errors are thrown.
Change tracking
To detect changes between runs, store snapshots of the DTOs ($profile->company, $profile->vat, $profile->vies,
$profile->insolvencies), not of the whole profile. serialize($profile) can fail when a stored exception carries a stack trace with
non-serialisable arguments (zend.exception_ignore_args=0, the php.ini-development default).
Storing identifiers
IČO is a string, never an int: it has leading zeros (00064581), and PHP turns digit-only array keys into
integers. Store it in a CHAR(8) / VARCHAR(8) column, edit it with a Symfony TextType, keep it in string DTO
properties. A single id parameter is typed CompanyId|string (find(), get(), has(), byCompanyId()): an
int is a TypeError under strict_types and is silently coerced to a string without it ($companies->has(64581)
is then true). The elements of an id list (findMany(), byCompanyIds(), missing()) are checked by the library:
an element that is neither a CompanyId nor a string is an InvalidInput. A legacy INT column must be cast to a
string at your boundary (better: migrate the column). CompanyId::parse() restores the leading zeros. DIČ is a
string for the same reason. The ISIR birth number (InsolvencyProceeding::$birthNumber) is a string exactly as the
register sent it, never normalised or validated; it is personal data, store it only if you have a reason to.
use IdSign\BusinessRegisters\CompanyId; CompanyId::parse(' 452 746 49 '); // 45274649 CompanyId::parse('64581'); // 00064581 CompanyId::tryParse('12345678'); // null (check digit) (string) CompanyId::parse('64581'); // "00064581" CompanyId::fromRegister('123562'); // 00123562 — format only, for an IČO ARES uses although it fails the check digit CompanyId::fromRegister('123562')->hasValidCheckDigit(); // false
A string id is always validated strictly, including the check digit, in find(), findMany(), the collections and
CompanyLookup::byCompanyId() / byCompanyIds(); a CompanyId object is taken as it is. The two constructors
belong to two origins: CompanyId::parse() and plain strings are for user input; an IČO that came from ARES — stored
from $company->id or from any other response — is re-hydrated with CompanyId::fromRegister() when you read it
back. ARES holds active subjects whose IČO fails the check digit (00123562, 29340042), and you cannot tell in
advance which stored string is one of them: a single such string makes the whole findMany() / byCompanyIds()
call throw InvalidInput before any request, and missing() / get() of the result throw for it too.
Addresses
ARES seats, ADIS addresses and ISIR debtor addresses share Address with one meaning per field: street is the street
name (or the part of the municipality where there is none; ISIR, which has no part of the municipality, the city)
with the numbers, "Duhová 1444/2"; streetName,
houseNumber and orientationNumber are its parts; postalCode is five digits without a space. ADIS and ISIR fill
the parts only when their value splits unambiguously, otherwise the source value stays in street. A number label
("č.ev.3", "čp.153") sets houseNumberType (2 registration, 1 descriptive) and is left out of street, as in ARES. Names stay as the
source writes them: ADIS uses upper case, ADIS and ISIR can put a city district into city ("PRAHA 4", "Praha 5"),
where ARES has city "Praha" and cityDistrict, and ISIR ulice (streetName) is the village name where there are
no streets ("Libotenice"), where ARES has district. VIES returns free text (ViesResult::$address).
ARES
use IdSign\BusinessRegisters\Ares\CompanySearch; $company = $ares->find('45274649'); // ?Company; null when ARES does not hold the subject $company->name; $company->seat?->street; // "Duhová 1444/2" $company->seat?->postalCodeFormatted(); // "140 00" $company->vatId; // ?VatId — a filled value does not mean "VAT payer" $company->vatLookupId(); // ?VatId — the one to send to ADIS and VIES $company->taxOfficeCode; // ?string — ARES financniUrad: workplace (e.g. "293") or "013" Specialised Tax Office; code list FinancniUrad $company->isNaturalPerson(); // legal form 100, 101–108, 424, 425 $company->hasCeased(); // ARES datumZaniku (end of existence or registration) not after today (Europe/Prague); pass a date to ask for another calendar day $company->registrations->active(); // list<AresRegister>
findMany() removes duplicates, sends the ids in batches of 100 (up to $maxConcurrency batches at a time) and
returns a Companies collection. ARES answers each batch in ascending IČO; with more than 100 ids the batches are
appended in request order, so the collection as a whole is not sorted. Ids that ARES does not hold are absent. If a
batch fails, the first failure in sending order is thrown, the other batches sent at the same time are cancelled and
no further batch is sent; ADIS findMany() works the same way.
// ids your database stored from $company->id: re-hydrate with fromRegister(); parse() is for user input only $requested = array_map(CompanyId::fromRegister(...), $icosFromDatabase); // list<CompanyId> $companies = $ares->findMany($requested); $companies->has('64581'); // ids in any form: "00064581", "452 746 49", a CompanyId $companies->get('00064581')?->name; count($companies); foreach ($companies as $company) { // values only, in ARES response order: each batch of 100 ascending by IČO, } // batches in request order — no global order foreach ($companies->missing($requested) as $id) { // list<CompanyId> — not found in ARES (e.g. deleted) }
The result is a collection and not an array keyed by IČO because PHP would turn the keys 45274649 into integers and a
lookup with a non-normalised key would silently report an existing company as missing. No public method of this library
returns an array keyed by IČO or DIČ. An invalid id passed to has(), get() or missing() is an InvalidInput, not
"missing".
search() returns one page and the total number of matches:
$result = $ares->search(new CompanySearch(name: 'Komerční banka', limit: 20, offset: 0)); $result->total; // all matches, not only this page foreach ($result->companies as $company) { // list<Company> $company->id; // ?CompanyId — null for a subject without IČO $company->aresId; // company id or "ARES_########" for such a subject }
Criteria: name, address, municipalityCode, legalFormCodes, naceCodes, taxOfficeCodes (ARES financniUrad: a
workplace code or 013; regional codes 451–464 match nothing, see "What the data means"), plus limit (1–1 000,
default 20), offset (0 or greater) and orderBy. At least one criterion must be filled; a blank string is not a
criterion. An invalid query throws InvalidInput before any request.
VAT register (ADIS)
Only Czech DIČ are accepted; a string without a country code is read as CZ, a non-Czech one is an InvalidInput.
$subject = $vatRegister->find('CZ45274649'); // ?VatSubject; null when the register does not hold it $subject->type; // SubjectType::VatPayer $subject->isVatPayer(); // true for a VAT payer and a VAT group $subject->unreliable; // bool $subject->taxOfficeCode; // ?string — ADIS cisloFu: regional office 451–464 (e.g. "461") or "013"; code list FinancniUrad $subject->activeBankAccounts(); // list<BankAccount> (string) $subject->activeBankAccounts()[0]; // e.g. "71504011/0100" $subject->hasPublishedAccount('27-5868650297/0100'); $subject->hasPublishedAccount('CZ6501000000275868650297');
hasPublishedAccount() compares against active accounts only. Czech accounts match in any domestic form (with or
without prefix and leading zeros, spaces and any dash tolerated) or as a Czech IBAN (check digits are not verified);
the bank code must have four digits. A non-standard account matches literally (case-insensitive). Anything not
recognisable is false, never an exception.
findMany() returns a VatSubjects collection that works like Companies (get, has, missing, all, count,
values-only iteration; ids in any form, read as Czech):
$subjects = $vatRegister->findMany(['CZ45274649', '699001182']); $subjects->get('45274649')?->type; foreach ($subjects->missing(['CZ45274649', 'CZ11111111']) as $vatId) { // list<VatId> echo $vatId; // "CZ11111111" }
unreliablePayers() returns the whole list of unreliable VAT payers as list<UnreliablePayer> (vatId, since,
taxOfficeCode — the ADIS office code, see "What the data means"). The list has about 4 300 entries and 500 kB;
consider a longer $timeout than the default 10 s. The list holds the unreliable payers and only some unreliable
persons, and not reliably the identified persons the register marks unreliable; it does not carry the subject type. To
screen for UnreliablePerson, use find() / findMany() (VatSubject::$type, $unreliable) or the facade, not the
list. VatSubject::$unreliable is likewise true for an unreliable person and for an identified person the register
marks unreliable (14227843); such a subject raises UnreliablePerson, never UnreliableVatPayer.
VIES
check() needs the country prefix (there is no default country); GR is normalised to EL.
$result = $vies->check('CZ45274649', requester: 'CZ12345678'); $result->valid; // bool — an invalid VAT id is a result, not an exception $result->name; // ?string — null when the member state does not disclose it (Germany) $result->address; // ?string $result->consultationNumber; // ?string — issued only when a requester was given $result->checkedAt; // DateTimeImmutable, UTC
An empty-string requester is an InvalidInput and sends no request, so a misconfiguration fails loudly instead of
silently dropping the consultation number. Map an empty configuration value to null yourself; in Symfony:
parameters: vies_requester: '%env(default::VIES_REQUESTER)%' # null when the variable is unset or empty
Trader matching
Pass TraderDetails and VIES compares each given field with the register of the member state; null and blank
(whitespace-only) fields are not sent, the others are sent unchanged:
use IdSign\BusinessRegisters\Vies\MatchResult; use IdSign\BusinessRegisters\Vies\TraderDetails; $result = $vies->check('ESA15075062', trader: new TraderDetails( name: 'Industria de Diseño Textil, S.A.', city: 'Arteixo', )); $result->nameMatch; // ?MatchResult — Valid, Invalid or NotProcessed $result->streetMatch; // ?MatchResult — likewise postalCodeMatch, cityMatch, companyTypeMatch
A match property is null when VIES did not return the field. Without TraderDetails VIES may still answer
NotProcessed, or omit the field (null). Many member states, CZ and IE among them, always answer NotProcessed.
Spain does not disclose name and address ($result->name === null), so matching is the official way to verify a
Spanish trader.
Insolvency register (ISIR)
find() takes an IČO (a string is checked strictly, including the check digit, before any request) and returns every
proceeding the register currently lists for it, ended ones included:
use IdSign\BusinessRegisters\Isir\InsolvencyClient; $isir = new InsolvencyClient($http); $proceedings = $isir->find('25083325'); // InsolvencyProceedings; empty when the subject is not listed $proceedings->hasOngoing(); // true $proceedings->ongoing(); // list<InsolvencyProceeding> $proceedings->synchronisedAt; // ?DateTimeImmutable — how fresh the register data are foreach ($proceedings as $proceeding) { // also $proceedings->proceedings, count($proceedings) $proceeding->reference(); // "95 INS 12575/2022" $proceeding->isOngoing(); // true $proceeding->stateCode; // ?string, e.g. "KONKURS" $proceeding->insolvencyDeclaredOn; // ?DateTimeImmutable — decision on insolvency took legal force $proceeding->endedOn; // ?DateTimeImmutable — end of the proceeding took legal force }
isOngoing()istrueunless the proceeding has an end date (endedOn) or one of the ended statesODSKRTNUTA,PRAVOMOCNA,VYRIZENA,MYLNÝ ZÁP.; a missing or unknown state counts as ongoing (a false alarm is safer than a missed insolvency). A filed petition (NEVYRIZENA) is ongoing.stateCodeis the register's raw value, never an enum. Observed:NEVYRIZENA,ÚPADEK,KONKURS,REORGANIZ,ODDLUŽENÍ,PRAVOMOCNA,ODSKRTNUTA,VYRIZENA.KONKURSorÚPADEKdoes not mean the proceeding is still running (České aerolinie45795908:ÚPADEKwith an end date).- An empty collection means "not on the list of debtors now", not "never insolvent": the court removes a debtor 5 years after the end of the proceeding took legal force, sooner in some cases (Act No. 182/2006 Sb. § 425).
- A row is one debtor of a proceeding. Rows sharing a
reference()can be co-debtors (with the co-debtor's personal data) or the same debtor listed twice (two addresses), socount()can count a proceeding twice.otherDebtorInProceedingis the register's rawdalsiDluznikVRizeni; its meaning is undocumented and observed to vary by query, so do not read it as "has co-debtors". A natural person is found by IČO only if the court recorded it; look it up by birth number or by name and birth date (below). insolvencyDeclaredOncan benullalthough insolvency was declared (older proceedings);stateCodedecides.addressKindis the rawdruhAdresy, observedSÍDLO FY,SÍDLO ORG.,TRVALÁ.- The ministry runs the successor eISIR in verification operation and has announced a change of the web services
without a date. The client sits behind
InsolvencyRegister. - Every row carries what the register publishes (§ 420), including the birth number (
birthNumber, as received), birth date, name and address of natural persons. Your application is the controller of that personal data. synchronisedAtis the register's own freshness hint, read as Prague local time (verified in summer time only); the register omits it for an empty result, and an unreadable value isnull. It is never part of a verdict.- The service listens on port 8443 (
https://isir.justice.cz:8443/...); allow it in your egress firewall. Each lookup is one request, 0.03–0.25 s observed. find()returns at most 100 proceedings (a proceeding can have several rows). A subject with more than 100 listed proceedings throwsInvalidResponserather than returning an incomplete list that might leave out an ongoing one.
A natural person is recorded mainly under the birth number and the name. ARES does not publish either, so these two
lookups take what your own records hold and return the same InsolvencyProceedings as find():
$isir->findByBirthNumber('000000/0000'); // exact match on the birth number, with or without the slash $isir->findByNameAndBirthDate('Nováková', 'Jana', new \DateTimeImmutable('1980-01-01')); // all three must match exactly, case-insensitive
findByBirthNumber()accepts six digits, an optional slash and three or four digits (surrounding whitespace is ignored); anything else isInvalidInputbefore any request. The check digit is not verified: an unknown number gives an empty collection.findByNameAndBirthDate()sends the calendar date of$bornOnin its own time zone. Surname and first name must match the register exactly, ignoring letter case, so pass them as on the identity document; white space around them, no-break and zero-width spaces included, is ignored. A different spelling (diacritics, a double surname) or a shortened name gives an empty collection. A surname or first name without a letter, a name that is not valid UTF-8, holds a character XML 1.0 forbids or decomposed diacritics (a letter followed by a combining mark, as macOS file names or PDF copies hold it; the register holds Unicode NFC only), or a birth year outside 1–9999 isInvalidInputbefore any request.- Both ask the service for that match only; an answer reporting a weaker match, or no match kind at all
(
relevanceVysledku), isInvalidResponse, never another person's proceedings. There is no lookup by name alone. - Exception messages name "a birth number" or "a person", never the birth number, name or date. The parameters that
carry them are
#[\SensitiveParameter], so stack traces leave them out too.
Error handling
Every exception implements IdSign\BusinessRegisters\Exception\ExceptionInterface.
| Exception | Meaning | What to do |
|---|---|---|
InvalidInput (extends \InvalidArgumentException) |
the input is invalid or the source rejected it | fix the input or ask the user; repeating will not help |
ServiceUnavailable |
the source could not answer: transport error, timeout, outage, overload | try again later; it is never a business answer such as "not a payer" |
InvalidResponse |
the source answered something the library cannot read | an error to investigate; report it |
InvalidInput has ?string $errorCode; ServiceUnavailable has Source $source, ?string $errorCode and
bool $connectionFailed (the source was not reached: a transport error or timeout, not an error status or code);
InvalidResponse has Source $source and ?string $errorCode. Source is Ares, Adis, Vies or Isir.
CompanyLookup::byCompanyId() throws only ARES errors; an exception from a section is recorded in the profile
(Unavailable or Rejected, see Five meanings of null).
use IdSign\BusinessRegisters\Exception\InvalidInput; use IdSign\BusinessRegisters\Exception\InvalidResponse; use IdSign\BusinessRegisters\Exception\ServiceUnavailable; try { $profile = $lookup->byCompanyId($input, Section::Vat); } catch (InvalidInput $e) { // show a validation error; $e->errorCode may hold the source's code } catch (ServiceUnavailable $e) { // retry later; $e->source says which register } catch (InvalidResponse $e) { // log and report; $e->source and $e->errorCode, the message names the key path }
Rules for messages:
- When
errorCodeis set, the exception appends(error code X)to the message (for example… (error code VSTUP_NEVALIDNI_FORMAT_ICO),ADIS is in scheduled maintenance (error code 2)). Log normalisers such as Monolog's do not log custom exception properties, so the code has to be in the message. The suffix is appended only when the code looks like a token ([A-Za-z0-9_.:-], 1–64 characters);$e->errorCodealways holds the raw value and should be treated as untrusted text. Branch on$e->errorCodeand$e->source, never parse the message. - Messages never contain response text (ARES
popis, ADISstatusText, ISIRtextChyby/popisChyby, SOAPfaultstring, VIESmessage) or record data. They may contain the id you passed in, the HTTP status and, for a transport failure, the error text of the HTTP client. InvalidResponsemessages contain only the source label, the key path, the expected type and the error-code suffix, for exampleARES: expected string at sidlo.nazevObce. JSON paths are jq-style with 0-based indices (zaznamy[0].ico); XML paths are XPath with 1-based indices (s:Body/r:StatusNespolehlivySubjektRozsirenyResponse/r:statusSubjektu[3]/@typSubjektu).
Per source:
| Source | Situation | Exception |
|---|---|---|
| ARES | 404 "not found" | find() returns null; findMany() leaves the id out |
| ARES | HTTP 400 | InvalidInput, errorCode = ARES subKod; more than 1 000 search results is VYSTUP_PRILIS_MNOHO_VYSLEDKU — ask the user to refine the query |
| ARES | other status, transport error, timeout | ServiceUnavailable |
| ADIS | status code 1, unknown code | InvalidResponse, errorCode = the status code |
| ADIS | status code 2 (nightly maintenance), 3, SOAP Fault, HTTP ≠ 200, transport error, timeout | ServiceUnavailable, errorCode = status code 2 or 3, or the SOAP faultcode (also with HTTP ≠ 200); otherwise null |
| ISIR | empty result (WS2) |
find() returns an empty collection |
| ISIR | WS4 (data not current), SQL1, SERVER1, SOAP Fault, HTTP ≠ 200, transport error, timeout |
ServiceUnavailable, errorCode = the ISIR code or the SOAP faultcode (also with HTTP ≠ 200); otherwise null |
| ISIR | WS1, WS3, unknown code, truncated answer (pocetVysledku above the rows returned), more than 100 proceedings |
InvalidResponse, errorCode = the ISIR code for WS1, WS3 and an unknown code; otherwise null |
| ISIR | a weaker or missing match kind (relevanceVysledku) for findByBirthNumber() or findByNameAndBirthDate() |
InvalidResponse, errorCode null |
| VIES | INVALID_INPUT, INVALID_REQUESTER_INFO, HTTP 400 |
InvalidInput, errorCode = VIES code |
| VIES | every other code (MS_UNAVAILABLE, TIMEOUT, *_MAX_CONCURRENT_REQ*, VAT_BLOCKED, IP_BLOCKED, unknown codes), other statuses |
ServiceUnavailable, errorCode = VIES code when the body is readable |
| any | element of the wrong type in an id list, a duplicate in a collection constructor | InvalidInput |
VIES reports its errors in an HTTP 200 body; the library never turns them into valid === false.
What the data means
The ARES status in the 16 source registers ($company->registrations) is a pointer, not an answer:
| Do not assume | Reality |
|---|---|
| VAT id filled in ARES = VAT payer | the VAT id stays after the registration ended (26863154 has a DIČ and Vat = Ended); Vat = Active also covers identified persons. Only ADIS decides whether a subject is a VAT payer; Vat = Ended can lag behind ADIS (10803351 is a payer in ADIS), so VatRegistrationEnded yields to an Ok VAT section |
| every company has a DIČ of its own | a VAT group member has a group DIČ and its own vatId is null or a former own DIČ (Komerční banka 45317054: group DIČ CZ699001182, no own; 21985685: former own DIČ, Vat = Ended). Use vatLookupId(), never vatId |
| a DIČ can be derived from the IČO | natural persons have a nine- or ten-digit DIČ: the birth number (nine digits for births before 1954) or a nine-digit identifier assigned by the tax administrator (starts with 6; daňový řád § 130 odst. 4), which also foreign persons and VAT groups (CZ699…) get, and a derived DIČ of a group member is usually not found in ADIS. Always take the DIČ from ARES |
Insolvency = Active means in insolvency now |
it stays Active after the proceedings ended (České aerolinie 45795908). Only the insolvency register tells whether it is current (Section::Insolvency, isInInsolvency(), flag Insolvency) — hence the ARES flag is named InsolvencyRecord |
Bankruptcy (CEÚ) reflects insolvency |
it does not: CEÚ (centrální evidence úpadců) holds only bankruptcy (konkurs) and composition (vyrovnání) proceedings under the former Act No. 328/1991 Sb., i.e. opened before 1 January 2008; everything since is in the insolvency register (Act No. 182/2006 Sb. § 432). Sberbank CZ 25083325 in bankruptcy: Nonexistent. Do not use it |
| every IČO in ARES satisfies the check digit | no: 00123562, 29340042 are active. $company->id may have hasValidCheckDigit() === false; strings you pass stay strict, so re-hydrate ids stored from ARES with CompanyId::fromRegister() (§ Storing identifiers); the check digit is a convention of the register administrator, not a legal requirement |
taxOfficeCode of ARES and ADIS name the same office |
not always. Both are codes of the list FinancniUrad. ARES financniUrad is the competent workplace (293 = Územní pracoviště Brno-venkov) or 013 Specialised Tax Office; ADIS cisloFu is a regional office 451–464 (461 = Finanční úřad pro Jihomoravský kraj) or 013. They are equal only for Specialised Tax Office subjects; never compare or join them across sources |
| only legal forms 101–108 are natural persons | also 100 (domestic self-employed natural person, in source rzp also a non-entrepreneur natural person), 424 (foreign natural person) and 425 (its branch, named after a person); isNaturalPerson() covers all eleven forms |
datumZaniku marks the dissolution |
it is the end of existence (zánik, NOZ § 185) or of the registration, not the dissolution (zrušení, NOZ § 168); a dissolved company in liquidation is active in ARES (InLiquidation). ARES carries a future datumZaniku for some active subjects (72396067, a natural person with an authorisation recorded until 2035-12-10); Ceased is raised only once the date has come (Company::hasCeased()) |
| a deleted subject is returned | ARES usually answers 404, so find() returns null; it can serve a subject that has ceased for some days after datumZaniku, then Ceased can appear next to PersonsRegister = Active |
| statuses are complete | all 16 registers are always present; a key ARES omits is Nonexistent; a value ARES adds later is Unknown |
The insolvency register answers for the moment of the query and only for what the court recorded:
| Do not assume | Reality |
|---|---|
the service's filtrAktualniRizeni means ongoing |
it hides only ODSKRTNUTA and PRAVOMOCNA, other ended proceedings stay; the library asks for every listed proceeding and decides with InsolvencyProceeding::isOngoing() |
KONKURS or ÚPADEK = the proceeding is running |
the state can stay after the end (České aerolinie 45795908: ÚPADEK with an end date); isOngoing() checks the end date and the ended states |
| an empty result = never insolvent | it means "not on the list of debtors now": the court removes a debtor 5 years after the end of the proceeding took legal force, sooner in some cases (Act No. 182/2006 Sb. § 425) |
| a self-employed person is always found by IČO | only if the court recorded the IČO with the debtor |
What the registers do not return: the date a VAT registration started or ended and its history (neither ADIS nor ARES has it), and the reason a subject is an unreliable payer. The library does not cover statutory bodies and members (public register extracts), financial statements, beneficial owners, enforcement proceedings, subsidies or sanction lists.
Limits
- Bulk: 100 ids per request;
findMany()chunks automatically (ARES rejects 101+ ids, ADIS answers status code 1) and sends at most$maxConcurrencybatches at a time (1–4; ARES default 2, ADIS default 4). The bound holds only inside onefindMany()call, never across calls; requests of several workers on one IP address add up. - ARES search: at most 1 000 results; a broader query is an
InvalidInput. - ADIS is unavailable every night from 0:00 to 0:10 (
ServiceUnavailable,errorCode2). - VIES and the member states throttle concurrent requests (
MS_MAX_CONCURRENT_REQ,GLOBAL_MAX_CONCURRENT_REQ); treat these asServiceUnavailableand retry later. Some member states do not disclose name and address. - ISIR has no bulk query: each lookup (
find(),findByBirthNumber(),findByNameAndBirthDate()) is one request. - Response times observed for one company (October 2026, connection already open): ARES ≈ 0.05 s, ADIS ≈ 0.1 s,
ISIR ≈ 0.03 s, VIES 1.3–2 s. A
byCompanyId()with all three sections takes ≈ 1.8 s, about 90 % of it VIES; requestSection::Viesonly when you need the EU validity, and the profile takes ≈ 0.25 s. The first request to each host also opens the connection (0.1–0.25 s), which under PHP-FPM happens again in every process. - Default timeout 10 s per request (idle and total), including the time a request waits queued at the source.
The operators publish terms of use. The library is stateless and does not enforce them; a breach can get your IP address restricted or blocked, so throttle in your application (all workers behind one IP address count together):
| Source | Declared terms | Published at |
|---|---|---|
| ARES | at most 500 requests per minute; no "larger number of simultaneous requests" from automated clients (no figure is published); no repeated identical or mostly invalid requests, no probing with random data | ares.gov.cz › Info pro vývojáře, mf.gov.cz › ARES |
| ADIS | at most 4 requests in parallel, 2 000 requests per hour and 10 000 requests per 24 hours (one request = one call with up to 100 DIČ); no repeated identical requests. Scheduled maintenance every Sunday 3:00–4:00 | MOJE daně › Dokumentace › webová služba |
| VIES | a global and a per-member-state cap on concurrent requests, counted across all users; the thresholds are not published. A request above the cap is rejected (*_MAX_CONCURRENT_REQ*); abusive use gets the IP address blocked (IP_BLOCKED) |
VIES › FAQ (Q15), Technical information |
| ISIR | no limits or terms published; the service is undocumented apart from its XSD, listens on port 8443 and is due to change (eISIR) | — |
- The library does no caching, retrying, rate limiting, scheduling or persistence. Add what you need around it (cache
profiles, retry
ServiceUnavailablewith back-off, queue lookups).
Testing your code
All four registers sit behind interfaces — CompanyDirectory, VatRegister, Vies, InsolvencyRegister — so your
code can depend on them and your tests can substitute doubles. Collections are built from lists, and every company needs
an IČO and ids must be unique, or the constructor throws InvalidInput.
use IdSign\BusinessRegisters\Ares\AresRegister; use IdSign\BusinessRegisters\Ares\Companies; use IdSign\BusinessRegisters\Ares\Company; use IdSign\BusinessRegisters\Ares\CompanyDirectory; use IdSign\BusinessRegisters\Ares\Registrations; use IdSign\BusinessRegisters\Ares\RegistrationStatus; use IdSign\BusinessRegisters\CompanyId; $company = new Company( aresId: '45274649', id: CompanyId::parse('45274649'), name: 'Test s.r.o.', legalFormCode: '112', vatId: null, groupVatId: null, taxOfficeCode: null, seat: null, deliveryAddressLines: [], establishedOn: null, ceasedOn: null, updatedOn: null, naceCodes: [], naceCodes2008: [], fileNumber: null, primarySource: null, registrations: new Registrations([AresRegister::Vat->value => RegistrationStatus::Active]), ); $directory = $this->createStub(CompanyDirectory::class); // PHPUnit $directory->method('findMany')->willReturn(new Companies([$company]));
VatSubjects and CompanyProfiles are built the same way from a list of VatSubject or CompanyProfile;
InsolvencyProceedings from a list of InsolvencyProceeding and the freshness hint
(new InsolvencyProceedings([], synchronisedAt: null) for a subject that is not listed).
CompanyProfile has a public constructor too; use named arguments, and key statuses and errors by Section::name:
use IdSign\BusinessRegisters\CompanyProfile; use IdSign\BusinessRegisters\Section; use IdSign\BusinessRegisters\SectionStatus; $profile = new CompanyProfile( company: $company, vat: null, vies: null, insolvencies: null, statuses: [Section::Vat->name => SectionStatus::Unavailable], errors: [], );
To test the clients themselves, feed them a MockHttpClient:
use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; $client = new AresClient(new MockHttpClient(new MockResponse($json, ['http_code' => 200])));
The official VIES test service can be used through the $endpoint argument
(https://ec.europa.eu/taxation_customs/vies/rest-api/check-vat-test-service); it accepts the numbers 100–601
with any country prefix (DE100) and answers with the corresponding error.
AI assistant skill
.claude/skills/business-registers/SKILL.md is a self-contained guide for an AI assistant working in a project that
uses this library. It is not part of the Composer package. In Claude Code, install it as a plugin from this repository:
/plugin marketplace add id-sign/business-registers
/plugin install business-registers@id-sign
Without the plugin, copy the business-registers directory into your project's .claude/skills/; another assistant
can read SKILL.md directly.
Development
composer install composer test # unit suite, no network composer test:live # live suite against the production registers (ARES, ADIS, VIES, ISIR) composer phpstan # PHPStan, level max, strict rules, over src and tests composer cs # PHP CS Fixer, dry run (composer cs:fix applies) composer check # cs, phpstan and test; run before every commit
Contribution rules are in CONTRIBUTING.md; report a vulnerability as described in SECURITY.md.
make test, make test-live, make phpstan, make cs-fix and make test-matrix run the same in Docker
(PHP_VERSION=8.4 by default; test-matrix covers 8.4 and 8.5). make leaves a root-owned composer.phar
(gitignored) in the project root. The live suite may skip a VIES test when production VIES is throttling; run it again.
Disclaimer
This is an independent project. It is not affiliated with or endorsed by the Ministry of Finance of the Czech Republic, the Czech Tax Administration, the European Commission or the Ministry of Justice of the Czech Republic, which operate ARES, ADIS, VIES and ISIR. The library passes on what these services answer; risk flags are derived from that data by the rules above (see What the data means) and are not a legal assessment of a subject.
License
MIT. See LICENSE.