vatverify home
All guides

Validate VAT numbers in PHP: Guzzle, Symfony HTTP Client, and curl patterns

Validate EU and UK VAT numbers in raw PHP without WordPress or a framework dependency. Includes Guzzle, Symfony HTTP Client, and curl examples plus a small caching layer suitable for any PHP application.

TL;DR

  • Call vatverify's REST API from PHP using Guzzle, Symfony HTTP Client, or plain curl. No framework required.
  • Cache valid responses for 24 hours in APCu or Redis on top of vatverify's 30-day server-side cache for sub-millisecond hot-path lookups.
  • For Laravel and WordPress, see the dedicated guides: Laravel, WooCommerce.

Why raw PHP

A lot of EU B2B invoicing still runs on framework-light PHP: small WordPress plugins, classic Symfony bundles, or single-file Slim applications. Bringing in a heavy framework dependency to call one HTTP endpoint is overkill. The patterns below assume PHP 8.1 or later and either Guzzle, Symfony HTTP Client, or just curl.

Install one HTTP client

Either of the two common PHP HTTP clients works. Guzzle is the most widely used; Symfony HTTP Client is lighter-weight and ships with Symfony.

# Guzzle
composer require guzzlehttp/guzzle

# Or Symfony HTTP Client
composer require symfony/http-client

If you cannot add a Composer dependency for any reason, the curl example near the bottom of this guide is a one-file fallback.

Single-number validation with Guzzle

Vatverify.php
<?php

declare(strict_types=1);

namespace App\Vatverify;

use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;

final class Vatverify
{
    public function __construct(
        private readonly Client $http,
        private readonly string $apiKey,
    ) {}

    public function validate(string $vatNumber): ValidationResult
    {
        try {
            $response = $this->http->post('https://api.vatverify.dev/v1/validate', [
                'headers' => [
                    'Authorization' => "Bearer {$this->apiKey}",
                    'Accept' => 'application/json',
                ],
                'json' => ['vat_number' => $vatNumber],
                'timeout' => 10,
                'connect_timeout' => 3,
            ]);

            $body = json_decode((string) $response->getBody(), true);
            $data = $body['data'] ?? null;

            return new ValidationResult(
                valid: $data['valid'] ?? false,
                country: $data['country'] ?? null,
                companyName: $data['company']['name'] ?? null,
                consultationNumber: $data['consultation_number'] ?? null,
            );
        } catch (RequestException $e) {
            $body = $e->hasResponse()
                ? json_decode((string) $e->getResponse()->getBody(), true)
                : null;
            $code = $body['error']['code'] ?? 'unknown';

            throw new VatverifyException("VAT validation failed: {$code}", previous: $e);
        }
    }
}

final class ValidationResult
{
    public function __construct(
        public readonly bool $valid,
        public readonly ?string $country,
        public readonly ?string $companyName,
        public readonly ?string $consultationNumber,
    ) {}
}

final class VatverifyException extends \RuntimeException {}

The class is intentionally small. Nothing in it is framework-specific; it works equally well in a Slim application, a WordPress plugin, or a long-running Swoole worker.

Symfony HTTP Client variant

Symfony's HTTP Client supports the same shape of call with a different idiom:

use Symfony\Contracts\HttpClient\HttpClientInterface;

final class VatverifySymfony
{
    public function __construct(
        private readonly HttpClientInterface $http,
        private readonly string $apiKey,
    ) {}

    public function validate(string $vatNumber): array
    {
        $response = $this->http->request('POST', 'https://api.vatverify.dev/v1/validate', [
            'auth_bearer' => $this->apiKey,
            'json' => ['vat_number' => $vatNumber],
            'timeout' => 10,
        ]);

        return $response->toArray()['data'] ?? [];
    }
}

Symfony's auth_bearer option handles the Authorization: Bearer ... header automatically. The toArray() call doubles as a JSON-decode and a status-code check (it throws on 4xx/5xx unless told not to).

curl fallback for environments without Composer

For shared hosting or one-file scripts where Composer isn't available:

function vatverify_validate(string $vatNumber, string $apiKey): ?array
{
    $ch = curl_init('https://api.vatverify.dev/v1/validate');
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST => true,
        CURLOPT_HTTPHEADER => [
            "Authorization: Bearer {$apiKey}",
            'Content-Type: application/json',
            'Accept: application/json',
        ],
        CURLOPT_POSTFIELDS => json_encode(['vat_number' => $vatNumber]),
        CURLOPT_TIMEOUT => 10,
        CURLOPT_CONNECTTIMEOUT => 3,
    ]);

    $body = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($status !== 200 || $body === false) return null;
    return json_decode($body, true)['data'] ?? null;
}

This is the smallest dependency-free version. It works on PHP 7.4 onward.

Caching with APCu

vatverify caches valid responses for 30 days server-side, but every PHP request still incurs a network round-trip to the vatverify API. For high-traffic checkouts, an in-process or Redis cache eliminates the round-trip on cache hits.

function vatverify_cached(string $vatNumber, string $apiKey): ?array
{
    $key = 'vatverify:' . hash('sha256', $vatNumber);
    if (function_exists('apcu_fetch')) {
        $hit = apcu_fetch($key, $found);
        if ($found) return $hit;
    }

    $result = vatverify_validate($vatNumber, $apiKey);

    if ($result !== null && function_exists('apcu_store')) {
        $ttl = ($result['valid'] ?? false) ? 86400 : 3600;
        apcu_store($key, $result, $ttl);
    }
    return $result;
}

Two TTLs:

  • 24 hours for valid matches the typical registration-change cadence at the upstream registries.
  • 1 hour for invalid lets a customer fix a typo or finish their registration without waiting a full day for the local cache to expire.

For multi-server deployments, swap APCu for Redis (predis/predis or phpredis).

Reverse-charge decision

For the full B2B reverse-charge decision (not just buyer-side validation), call /v1/decide instead. It returns a structured decision with the legal basis and an invoice note:

$response = $http->post('https://api.vatverify.dev/v1/decide', [
    'headers' => ['Authorization' => "Bearer {$apiKey}"],
    'json' => [
        'seller_vat' => 'IE6388047V',
        'buyer_vat'  => 'DE811569869',
    ],
]);

$decision = json_decode((string) $response->getBody(), true)['data'];
// $decision['charge_vat']  === false
// $decision['mechanism']   === 'reverse_charge'
// $decision['legal_basis'] === 'EU VAT Directive Article 196'
// $decision['invoice_note'] === 'Reverse charge: VAT to be accounted for by the recipient'

/v1/decide is on the Business plan; for stores with simpler needs the validate-then-branch pattern works on every plan.

Production patterns

A few patterns worth using in real PHP applications:

  • Set a request timeout. The default Guzzle timeout is unset; that means a slow VIES response can hang a checkout indefinitely. The 10-second timeout above is generous; for checkout flows, 5 seconds plus a fallback to "validation pending" is often better.
  • Retry once on 5xx, not on 4xx. A 5xx from vatverify usually means an upstream registry is having a brief outage; one retry after a short delay clears most of these. A 4xx means the request itself was malformed; retrying does not help.
  • Surface request_id to logs. Every vatverify response includes meta.request_id. Logging it next to your own request ID makes support escalation trivial.
  • Don't log the full payload at INFO level. The validation response can include the buyer's company name and address. Treat it as PII; log it only at DEBUG and behind a feature flag.

What this guide does not cover

  • WordPress plugin scaffolding. See the dedicated WooCommerce VAT validation guide for the WordPress hook integration.
  • Laravel-specific DI patterns. See the Laravel guide.
  • The CLI tool for one-off lookups. vatverify does not currently ship a PHP CLI; the curl helper above doubles as one for ad-hoc checks.

Validate VAT in three lines.

Free up to 500 requests per month. No credit card.

Start free