Skip to content

EU VAT dataset

EU VAT dataset

// config/tax.php
'eu_tax_data' => [
    'location' => env('TAX_EU_DATASET_LOCATION'),
    'ttl' => (int) env('TAX_EU_DATASET_TTL', 86400),
],
TAX_EU_DATASET_LOCATION=https://raw.githubusercontent.com/cboxdk/eu-tax-dataset/v0.2.0

Unset by default. Configured, it is tried before the live TEDB service and before any hand-built export, because it answers a question a live call cannot: what a rate was on the date of the supply.

Pin a tag. main moves, and a rate changing underneath a running system is the failure the dataset exists to prevent.

What it gives you

Dated rates. The dataset carries a series back to the start of the Commission's records, so an invoice corrected two years later reprices at the rate that applied then:

$estonia2023 = $source->rateFor($place, TaxClass::Electronics, new DateTimeImmutable('2023-06-01'));
// 20% — not today's 24%

Reduced bands without a mapping of your own. The dataset publishes which TEDB headings each product class asks under, most specific first, so a book line finds France's rate even though France carries no BOOKS heading — printed books sit under LOAN_LIBRARIES there.

The three outcomes

Situation Rate Confidence
The class maps to a heading with a band that band Authoritative
The class maps to nothing, or to a heading the state does not rate standard Authoritative
The heading is published as undecided standard Derived

The third is the careful one. Where TEDB rates one heading several ways at once — Hungarian groceries are 5% for meat and fish and 18% for dairy desserts — the dataset publishes the ambiguity rather than picking. The standard rate is the safe fallback, because over-charging is recoverable and silently applying one of several competing reduced rates is not. But the confidence drops, so a caller billing on it can see that a better answer exists.

An undecided heading also stops the search: falling through to the next heading would quietly price the supply under one nobody asked about.

Getting an exact answer: the supply's classification code

The heading cannot say which supply takes which rate. The CN code (goods) or CPA code (services) can, and the source scopes every rate to them — so pass the code and the ambiguity resolves:

new TaxQuery(
    // …
    category: TaxClass::Groceries,
    commodityCode: '0102 21 10',   // live pure-bred breeding cattle
);
What you pass Hungary answers
TaxClass::Groceries alone 27% standard, Derived — the heading is 5% and 18% at once
plus cn:01022110 5%, Authoritative
plus cn:1806 (chocolate) 18%, Authoritative

Nothing is required. A code refines and never restricts: unknown, contested, or on a heading that was already settled, it changes nothing and the class alone decides exactly as before. So you can pass one opportunistically without knowing whether a given country needed it.

Write it however your catalogue holds it — cn:01022110, 01022110, or 0102 21 10. A code with no scheme is read as CN, because that is what a seller of goods has to hand; quote a service as cpa:… explicitly. The scheme is not guessable from the digits (32 is a valid CN chapter and a valid CPA division), so it is stated rather than inferred.

Matching is longest prefix, as tariff classification works: the source scopes some rates to a bare chapter (cn:02, meat) and others to eight digits, and a specific code beats the chapter containing it.

What this does not reach

Of the 2,028 codes sitting under an ambiguous heading, 1,901 resolve to exactly one rate. The rest are published as nothing rather than with a chosen rate — Italy holds 57 of them, where foodstuffs split on something the tariff does not record.

And a rate the source scoped to no codes at all is unreachable this way. Austria's 0% foodstuffs row is one: a supply belonging to it still falls back to the standard rate. Codes only ever add an answer.

What it refuses

A date before the records begin. The archive starts 2016-01-01. Estonia charged 20% in 2015 too, but this dataset never asserted it — answering anyway would put a rate on an invoice that nothing here stands behind.

A country it does not carry. Null, not 0%. The engine denies.

A remote read whose bytes do not match the manifest. The published location is a branch head on a third-party host; one bad push would otherwise reach every deployment within a cache TTL with nobody having released anything. A local path is trusted without a manifest — reading your own disk is a deliberate act.

What it does not do yet

The special territories, and this source makes the gap more visible. Madeira's 12/5 and the Azores' 9/4 reduced bands are not in the dataset.

For a STANDARD-rated supply that is handled: the regime substitutes the territory's own rate after the source answers, marked Derived. But for a supply that matches a reduced band, the regime keeps what the source returned — mainland Portugal's 6% on a Madeira grocery line, where Madeira charges 5% — and appends a caveat saying the band may be up to two points high.

That behaviour predates this source and is unchanged by it. What changed is how often it is reached: before, the EU had few resolved reduced bands, so most territory supplies fell to the standard path. Now that bands resolve across the union, a territory line is far more likely to be priced from the mainland.

Two points high is an over-charge, which is recoverable, and the caveat says so on the assessment. Closing it properly means publishing the territories' own bands in the dataset. See EU territories.

What it does with data it cannot read

The publisher refuses to emit a rate outside 0–100, so a band that will not parse means verification passed and something else went wrong. Throwing there would fail every assessment for every country over one bad heading in one, so:

  • An unreadable band → the standard rate at LowConfidence, with the heading named in the source string. It does not fall through to the next heading: that would price the supply under one nobody asked about and look successful.
  • An unreadable standard rate → null. There is nothing left to fall back to, and the engine refuses rather than inventing a percentage.