Validate VAT numbers in Laravel
Add EU VAT validation to a Laravel app with a custom validation rule, service container binding, and Livewire integration.
TL;DR
- No PHP SDK yet. Call the REST API directly via Laravel's
Httpfacade. - Register a
Vatverifyservice class as a singleton inAppServiceProvider. - Inject the service into a Form Request validation rule and use it anywhere in the app with zero duplication.
Why a centralised VAT service beats copy-pasted fetch calls
Copying an Http::post() call into every controller, job, and observer that needs VAT validation seems quick. In practice it creates a maintenance burden the moment the endpoint, auth scheme, or error-handling logic changes. You're updating three places instead of one. Laravel's service container was built for exactly this pattern: bind once, resolve everywhere, swap the implementation in tests with a single $this->swap() call.
Installation note
There is no official vatverify-php SDK yet. A PHP SDK is on the roadmap. Until it ships, the recommended approach is to call the REST API directly using Laravel's built-in Http facade. No Composer packages required.
Add your credentials to .env:
VATVERIFY_API_KEY=vtv_live_xxxxxxxxxxxxThen register them in config/services.php:
'vatverify' => [
'key' => env('VATVERIFY_API_KEY'),
'base_url' => env('VATVERIFY_BASE_URL', 'https://api.vatverify.dev'),
],Service class
Create app/Services/Vatverify.php. This class owns all HTTP communication with the API.
<?php
namespace App\Services;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
class Vatverify
{
public function __construct(
private readonly string $apiKey,
private readonly string $baseUrl,
) {}
/**
* Validate a VAT number via the vatverify REST API.
*
* @return array{data: array{valid: bool, vat_number: string, country: array, company: array|null, verified_at: string}, meta: array{source_status: string, source: string, cached: bool, latency_ms: int, request_id: string}}
*
* @throws \RuntimeException on 4xx/5xx responses
*/
public function validate(string $vatNumber): array
{
try {
$response = Http::withToken($this->apiKey)
->acceptJson()
->get("{$this->baseUrl}/v1/validate", [
'vat_number' => $vatNumber,
]);
$response->throw();
return $response->json();
} catch (RequestException $e) {
$status = $e->response->status();
$body = $e->response->json();
$message = $body['error']['message'] ?? 'vatverify API error';
throw new \RuntimeException("[vatverify {$status}] {$message}", $status, $e);
}
}
}Service container binding
Bind the service as a singleton in AppServiceProvider so the same instance (and its underlying HTTP connection pool) is reused for the lifetime of each request.
<?php
namespace App\Providers;
use App\Services\Vatverify;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(Vatverify::class, function () {
return new Vatverify(
apiKey: config('services.vatverify.key'),
baseUrl: config('services.vatverify.base_url'),
);
});
}
}Form Request validation rule
The Rule class
For Laravel 10+, implement Illuminate\Contracts\Validation\ValidationRule:
<?php
namespace App\Rules;
use App\Services\Vatverify;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class ValidVatNumber implements ValidationRule
{
public function __construct(private readonly Vatverify $vatverify) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (empty($value)) {
return;
}
try {
$result = $this->vatverify->validate($value);
} catch (\RuntimeException $e) {
$fail('VAT number lookup failed. Please try again.');
return;
}
if (! ($result['data']['valid'] ?? false)) {
$number = $result['data']['vat_number'] ?? $value;
$fail("The VAT number {$number} is not registered.");
}
}
}For Laravel 9, implement Illuminate\Contracts\Validation\Rule and use passes(string $attribute, mixed $value): bool + a message(): string method instead.
Wiring it into a Form Request
<?php
namespace App\Http\Requests;
use App\Rules\ValidVatNumber;
use App\Services\Vatverify;
use Illuminate\Foundation\Http\FormRequest;
class StoreCompanyRequest extends FormRequest
{
public function rules(Vatverify $vatverify): array
{
return [
'name' => ['required', 'string', 'max:255'],
'vat_number' => ['required', 'string', new ValidVatNumber($vatverify)],
'country' => ['required', 'string', 'size:2'],
];
}
}Laravel resolves Vatverify from the container automatically when it calls rules().
Eloquent casting for invoices (optional)
Store VAT numbers encrypted at rest and fire validation through a model observer before saving.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Invoice extends Model
{
protected $casts = [
'vat_number' => 'encrypted:string',
];
}<?php
namespace App\Observers;
use App\Models\Invoice;
use App\Services\Vatverify;
use Illuminate\Validation\ValidationException;
class InvoiceObserver
{
public function __construct(private readonly Vatverify $vatverify) {}
public function saving(Invoice $invoice): void
{
if (empty($invoice->vat_number)) {
return;
}
$result = $this->vatverify->validate($invoice->vat_number);
if (! ($result['data']['valid'] ?? false)) {
throw ValidationException::withMessages([
'vat_number' => 'VAT number is not registered.',
]);
}
}
}Register the observer in AppServiceProvider::boot():
use App\Models\Invoice;
use App\Observers\InvoiceObserver;
Invoice::observe(InvoiceObserver::class);Livewire integration
Use wire:blur to validate the VAT field as the user leaves it, without a page reload.
<?php
namespace App\Livewire;
use App\Services\Vatverify;
use Livewire\Component;
class VatInput extends Component
{
public string $vatNumber = '';
public ?string $error = null;
public ?string $company = null;
public function updatedVatNumber(Vatverify $vatverify): void
{
$this->error = null;
$this->company = null;
if (strlen($this->vatNumber) < 4) {
return;
}
try {
$result = $vatverify->validate($this->vatNumber);
} catch (\RuntimeException) {
$this->error = 'Lookup failed. Please try again.';
return;
}
if ($result['data']['valid'] ?? false) {
$this->company = $result['data']['company']['name'] ?? null;
} else {
$this->error = 'VAT number is not registered.';
}
}
public function render()
{
return view('livewire.vat-input');
}
}<div>
<input
type="text"
wire:model="vatNumber"
wire:blur="updatedVatNumber"
placeholder="e.g. IE6388047V"
/>
@if ($company)
<p class="text-green-600">✓ {{ $company }}</p>
@elseif ($error)
<p class="text-red-600">{{ $error }}</p>
@endif
</div>Queue job pattern
Dispatch a job on customer creation to validate (or re-validate) VAT numbers in the background.
php artisan make:job ValidateVatJob<?php
namespace App\Jobs;
use App\Models\Company;
use App\Services\Vatverify;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ValidateVatJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 30;
public function __construct(public readonly Company $company) {}
public function handle(Vatverify $vatverify): void
{
if (empty($this->company->vat_number)) {
return;
}
$result = $vatverify->validate($this->company->vat_number);
$this->company->update([
'vat_valid' => $result['data']['valid'] ?? false,
'vat_verified_at' => now(),
]);
}
}Dispatch from a controller or event listener:
ValidateVatJob::dispatch($company)->onQueue('default');Testing
Use Http::fake() to mock API responses without hitting the network.
<?php
namespace Tests\Feature;
use App\Rules\ValidVatNumber;
use App\Services\Vatverify;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class ValidVatNumberRuleTest extends TestCase
{
public function test_valid_vat_number_passes(): void
{
Http::fake([
'*/v1/validate*' => Http::response([
'data' => [
'valid' => true,
'vat_number' => 'IE6388047V',
'country' => ['code' => 'IE', 'name' => 'Ireland'],
'company' => ['name' => 'Acme Ltd', 'address' => '1 Example St'],
'verified_at' => '2026-04-14T15:42:03.451Z',
],
'meta' => [
'request_id' => '0190f8ea-a5b2-7000-a123-000000000000',
'cached' => false,
'source' => 'vies',
'source_status' => 'live',
'latency_ms' => 312,
],
], 200),
]);
$rule = new ValidVatNumber(app(Vatverify::class));
$failed = false;
$rule->validate('vat_number', 'IE6388047V', function () use (&$failed) {
$failed = true;
});
$this->assertFalse($failed);
}
public function test_invalid_vat_number_calls_fail_closure(): void
{
Http::fake([
'*/v1/validate*' => Http::response([
'data' => [
'valid' => false,
'vat_number' => 'DE000000000',
'country' => ['code' => 'DE', 'name' => 'Germany'],
'company' => null,
'verified_at' => '2026-04-14T15:42:03.451Z',
],
'meta' => [
'request_id' => '0190f8ea-a5b2-7000-a123-000000000000',
'cached' => false,
'source' => 'vies',
'source_status' => 'live',
'latency_ms' => 312,
],
], 200),
]);
$rule = new ValidVatNumber(app(Vatverify::class));
$message = null;
$rule->validate('vat_number', 'DE000000000', function (string $msg) use (&$message) {
$message = $msg;
});
$this->assertStringContainsString('not registered', $message);
}
}If you prefer to test against the real API, use a vtv_test_xxx key in your .env.testing file. It returns deterministic responses and never bills.
FAQ
Do I need Laravel 10+?
The Http facade and singleton binding pattern work on Laravel 9+. The ValidationRule interface (used in ValidVatNumber above) was introduced in Laravel 10.0. If you are on Laravel 9, implement Illuminate\Contracts\Validation\Rule with passes() and message() methods instead. It is functionally identical.
When will a PHP SDK land?
A first-class vatverify-php SDK is on the roadmap. Follow vatverify.dev/changelog for release announcements. The Http facade pattern shown in this guide will remain supported after the SDK ships.
How do I handle rate-limiting?
The API returns HTTP 429 when you exceed your plan limit. Laravel's built-in RateLimiter middleware can pre-emptively protect high-traffic endpoints. For job-queue scenarios, set public int $backoff = 30 (seconds) and public int $tries = 3 on your job class. Laravel's queue worker handles the retry with exponential backoff automatically.
Can I cache results in Laravel's cache driver?
Yes, but our API already caches results for up to 30 days at the infrastructure level. Double-caching in Laravel is only worth it if you need sub-millisecond latency and are hitting the same VAT number extremely frequently (for example, on every page render). If you do cache, use Cache::remember('vat:'.$vatNumber, now()->addDay(), fn () => $vatverify->validate($vatNumber)).
What about Jetstream or Breeze scaffolding?
The ValidVatNumber rule and the StoreCompanyRequest Form Request pattern work identically in Jetstream and Breeze projects. Drop new ValidVatNumber($vatverify) into the rules() array of any Form Request. The service container resolves the dependency the same way regardless of which auth scaffolding you used.