ibericode/vat
Validate European VAT numbers and retrieve company details via VIES and other services with this PHP library. It provides a clean API, Laravel integration, caching, and multi-country support, making VAT checks reliable and easy to add to your app.
Setup (≈2 minutes). The package is framework-agnostic, so Laravel integration is just a container binding — no service provider ships with it.
composer require ibericode/vat
php -m | grep soap # VIES validation needs ext-soap
Where to look first. Three classes carry the whole surface area:
| Class | Responsibility | Network? |
|---|---|---|
Ibericode\Vat\Validator |
VIES lookup for a VAT number | Yes (SOAP) |
Ibericode\Vat\Rates |
Country → standard VAT rate, from bundled JSON | No |
Ibericode\Vat\Vat |
Facade combining both + price math | Only for validation |
Start by reading Vat::calculate() and the bundled rates.json. That tells you everything about rounding, return shape, and data freshness.
First use case — a B2B checkout. The smallest useful slice is: customer types a VAT number → validate against VIES → if valid and in a different EU country, zero-rate the order with a reverse-charge note → persist the rate used.
use Ibericode\Vat\Vat;
$vat = new Vat();
$vat->validateVatNumber('NL123456789B01'); // true|false
$vat->getVatRateForCountry('NL'); // 21.0
$vat->calculate(100.00, 21.0, Vat::PRICE_EXCLUDES_VAT);
// ['vat' => 21.0, 'priceExclVat' => 100.0, 'priceInclVat' => 121.0]
Bind it once so you're not constructing it per call:
// app/Providers/AppServiceProvider.php
$this->app->singleton(Vat::class, fn () => new Vat());
Assessment task 1. Build the checkout slice above with a ValidateVatNumber form request rule, a vat_number / vat_validated_at pair on the customer, and a feature test that never touches the network.
1. Wrap, don't scatter. The package has no interfaces, so introduce your own seam. One VatService with validate(), rateFor(), price() methods gives you a single place to add caching, logging, and a fake in tests. Every other class depends on that, never on Ibericode\Vat\Vat directly.
2. Validation rule as a first-class citizen.
final class VatNumber implements ValidationRule
{
public function __construct(private VatService $vat) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$normalized = Str::upper(preg_replace('/[\s\.\-]/', '', (string) $value));
try {
if (! $this->vat->validate($normalized)) {
$fail('This VAT number is not registered with VIES.');
}
} catch (VatUnavailable $e) {
// Fail open + flag for async re-check, don't punish the customer
report($e);
}
}
}
3. Reverse-charge decision table. Encapsulate the matrix instead of if-ing in controllers:
| Customer | Country | Result |
|---|---|---|
| Consumer, no VAT ID | any | Charge destination rate |
| Business, valid VAT | same as merchant | Charge local rate |
| Business, valid VAT | other EU | 0% + "VAT reverse charged, Art. 196 Directive 2006/112/EC" |
| Business, invalid/expired VAT | any | Treat as consumer, charge rate |
| Business outside EU | non-EU | Outside scope — zero-rate with export note |
4. Snapshot the rate on the line item. Never recompute an old invoice. Store vat_rate, price_excl_vat, vat_amount, price_incl_vat as integers in minor units at order time. Vat::calculate() returns floats — convert immediately, don't let floats leak into the DB.
$r = $vat->calculate($cents / 100, $rate, Vat::PRICE_EXCLUDES_VAT);
$line->update([
'price_excl_vat' => (int) round($r['priceExclVat'] * 100),
'vat_amount' => (int) round($r['vat'] * 100),
'price_incl_vat' => (int) round($r['priceInclVat'] * 100),
'vat_rate' => $rate,
]);
5. Cache validation results, positively and negatively. VIES is slow (300 ms–2 s) and rate-limited. Key on sha256(normalized_number) with a 24-hour TTL, store the requestDate VIES returns, and re-validate on a schedule for subscriptions/renewals.
6. Queue the non-critical path. Validate synchronously at checkout only if you must; otherwise accept the order, mark vat_status = pending, and reconcile in a job that fires VatValidationFailed so finance can follow up. This is the single biggest UX win.
7. Config-driven fallback table. Ship config/vat.php with overrides keyed by country so ops can hotfix a rate change without a deploy, falling back to the package's bundled data:
$rate = config("vat.overrides.{$country}") ?? $vat->getVatRateForCountry($country);
Assessment task 2. Model the reverse-charge table as a testable service, add a queued re-validation job, and prove with Pest/PHPUnit that a valid DE VAT number on a FR merchant order produces a 0% line with the correct legal note.
"Invalid" and "VIES is down" are not the same thing. A SOAP fault, timeout, or MS_MAX_CONCURRENT_REQ is an outage, not a bad VAT number. Catch it, record it, and retry — marking a legitimate business customer as a consumer silently overcharges them and creates a tax liability.
Normalize before you validate. VIES wants no spaces, dots, or dashes, and the country prefix uppercase. NL 1234.567.89 B01 → NL123456789B01. Garbage in gives false negatives that look like real failures.
Greece is two country codes. ISO is GR, but VAT numbers are issued as EL…. If you key your rates table on the first two characters of the VAT number you will miss Greece entirely. Special-case it.
Northern Ireland (XI) is a trap. Post-Brexit, XI numbers exist for goods movements but are not in any EU rate table. Decide explicitly what you do with them rather than letting getVatRateForCountry('XI') throw.
These are standard rates only. Books, food, children's clothing, and hotels carry reduced rates the package does not model. If your catalogue has mixed rates, the country lookup is your default, not your answer — you need per-product rate assignment.
Rounding drift. calculate() rounds at the price level. If you compute VAT per line and then sum, you will be off by a cent versus computing on the invoice total. Pick one policy (legally, most jurisdictions expect per-line or per-invoice consistently), document it, and assert it in tests with awkward values like 19.99 @ 21%.
Pin the version and treat rates as stale data. Bundled rates only change when you composer update. Add a monthly scheduled command that diffs the package data against a known source and alerts, or at minimum check the changelog before tax season.
Rate-limit yourself before VIES does. A naive per-keystroke AJAX validation will get you throttled. Validate on blur/submit only, debounce, and cache.
VAT numbers are personal data. Encrypt the column at rest, redact from logs and exception payloads, and remember APP_DEBUG stack traces will happily print the SOAP request body.
Testing. There's no interface to mock, so either bind a fake VatService or construct new Vat($customRates, $stubValidator) with fixed rate arrays to freeze time and rates. Never let the test suite hit ec.europa.eu — it will make CI flaky and possibly get your IP blocked.
Debugging checklist when a number "should be valid": extension loaded → number normalized → country code correct (GR/EL) → VIES status page → try the number manually on the EU VIES website. If the website says valid and your code says invalid, it's almost always normalization or an unhandled fault being cast to false.
How can I help you explore Laravel packages today?