VAT Number Validation in PHP and Laravel
A PHP application can check a customer's VAT number with one server-side HTTP request. This guide starts with a runnable cURL script, then adds the same request to a Laravel billing form through a service and controller. Both examples use Avatcado's GET /v1/validate endpoint.
Prerequisites
- PHP 8.3 or later with the cURL extension enabled. Run the first example from a terminal.
- An Avatcado account and a test API key from your dashboard, starting with
avat_test_. - For the Laravel section, an existing Laravel 13 application with session authentication configured.
The Free plan supports single VAT validation requests and includes 500 live validations per month. Test-mode requests do not consume that quota. Keep the key in your server environment and out of browser JavaScript.
Validate a VAT number with PHP cURL
Create your free account and create a test key, then save this as validate-vat.php. It encodes the query parameter, sets connection and response timeouts, and checks the HTTP status before using the result.
<?php
// validate-vat.php
$apiKey = getenv('AVATCADO_API_KEY');
$vatNumber = $argv[1] ?? 'DE111111111';
try {
if (!$apiKey) {
throw new RuntimeException('Set AVATCADO_API_KEY first.');
}
$query = http_build_query(
['vat_number' => $vatNumber], '', '&', PHP_QUERY_RFC3986
);
$curl = curl_init('https://api.avatcado.com/v1/validate?'.$query);
if ($curl === false) {
throw new RuntimeException('Could not initialize cURL.');
}
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer '.$apiKey,
'Accept: application/json',
],
]);
$raw = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
$networkError = curl_error($curl);
unset($curl);
if ($raw === false) {
throw new RuntimeException('Network error: '.$networkError);
}
$result = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($result)) {
throw new RuntimeException('Unexpected API response.');
}
if ($status < 200 || $status >= 300 || isset($result['error'])) {
$code = $result['error']['code'] ?? 'unknown_error';
$requestId = $result['meta']['request_id'] ?? 'unknown';
throw new RuntimeException(
"Avatcado HTTP $status ($code); request $requestId", $status
);
}
if (!is_bool($result['data']['valid'] ?? null)) {
throw new RuntimeException('API response is missing a boolean valid field.');
}
// Both true and false are completed validation results.
echo json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;
} catch (JsonException $error) {
fwrite(STDERR, 'Invalid API JSON: '.$error->getMessage().PHP_EOL);
exit(1);
} catch (RuntimeException $error) {
fwrite(STDERR, $error->getMessage().PHP_EOL);
exit(1);
}Set your real test key in the environment and run the script:
export AVATCADO_API_KEY='avat_test_replace_with_your_key'
php validate-vat.php DE111111111The response contains data.valid: true and a test company named Test GmbH. The meta.mode field is test. An API error or a failed request writes a diagnostic to standard error and exits with code 1. A completed check with valid: false exits with code 0.
PHP's http_build_query documentation covers query encoding. The cURL options and json_decode reference describe the request and JSON settings used here.
Read the validity result and nullable company details
A successful HTTP response carries a completed check in data and request details in meta. Read the boolean data.valid to decide which message your form shows. An error response means the check failed, so leave the validation status pending and handle the error.
// After the success checks in the PHP script, or after the Laravel service call:
$isValid = $result['data']['valid'];
$companyName = $result['data']['company']['name'] ?? null;
$companyAddress = $result['data']['company']['address'] ?? null;
$requestId = $result['meta']['request_id'] ?? null;A valid number can have company: null. For example, Germany and Spain do not publish company names or addresses through VIES. The German test fixture above includes a synthetic company; use DE222222222 to exercise the valid result with no company details. Keep the business name entered by your customer available when the register supplies no name.
Preserve the response metadata if you store the check. Fields such as meta.cached, meta.stale and meta.source_status tell you how the result was obtained. A validity result alone does not determine the tax treatment of a sale. The SaaS billing guide covers how checks fit into billing decisions.
Add VAT validation to Laravel 13
Laravel's HTTP client can send the request directly. Add your key to the application's .env file, which should stay outside version control:
AVATCADO_API_KEY=avat_test_replace_with_your_keyCreate config/avatcado.php:
<?php
// config/avatcado.php
return [
'api_key' => env('AVATCADO_API_KEY'),
];The service reads config('avatcado.api_key'). Laravel's configuration caching documentation explains why calls to env() belong in configuration files. If local configuration is cached, run php artisan config:clear after changing the key.
Create the service
Save the following in app/Services/Avatcado.php. The HTTP client encodes the query array and sends the Bearer token. Laravel returns responses for HTTP errors, so this service explicitly checks successful(). Connection failures and timeouts throw ConnectionException.
<?php
// app/Services/Avatcado.php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use JsonException;
use RuntimeException;
class Avatcado
{
public function validate(string $vatNumber): array
{
$apiKey = config('avatcado.api_key');
if (!is_string($apiKey) || $apiKey === '') {
throw new RuntimeException('Set AVATCADO_API_KEY first.');
}
$response = Http::withToken($apiKey)
->acceptJson()
->connectTimeout(5)
->timeout(30)
->withoutRedirecting()
->get('https://api.avatcado.com/v1/validate', [
'vat_number' => $vatNumber,
]);
try {
$result = json_decode($response->body(), true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $error) {
throw new RuntimeException('Invalid API JSON.', 0, $error);
}
if (!is_array($result)) {
throw new RuntimeException('Unexpected API response.');
}
if (!$response->successful() || isset($result['error'])) {
$code = $result['error']['code'] ?? 'unknown_error';
$requestId = $result['meta']['request_id'] ?? 'unknown';
throw new RuntimeException(
"Avatcado HTTP {$response->status()} ($code); request $requestId",
$response->status()
);
}
if (!is_bool($result['data']['valid'] ?? null)) {
throw new RuntimeException('API response is missing a boolean valid field.');
}
return $result;
}
}Connect the service to a controller and route
Create app/Http/Controllers/VatValidationController.php. The controller checks the submitted field, returns completed results as JSON, and logs failed checks through Laravel's exception reporting. A format error gives the customer a correction message. Other failures return a temporary-unavailability message.
<?php
// app/Http/Controllers/VatValidationController.php
namespace App\Http\Controllers;
use App\Services\Avatcado;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;
class VatValidationController extends Controller
{
public function __invoke(Request $request, Avatcado $avatcado): JsonResponse
{
$input = $request->validate([
'vat_number' => ['required', 'string', 'max:32'],
]);
try {
$result = $avatcado->validate($input['vat_number']);
return response()->json($result);
} catch (ConnectionException | RuntimeException $error) {
report($error);
if ($error instanceof RuntimeException && $error->getCode() === 422) {
return response()->json([
'error' => 'Check the VAT number and its country prefix.',
], 422);
}
return response()->json([
'error' => 'VAT validation is temporarily unavailable. Try again later.',
], 503);
}
}
}Add the route below to routes/web.php. It uses your application's existing login session and limits each signed-in caller to 20 requests per minute. Your Avatcado account has a separate plan-wide limit, so several callers can still reach it together.
<?php
// Add to routes/web.php.
use App\Http\Controllers\VatValidationController;
use Illuminate\Support\Facades\Route;
Route::post('/billing/vat-check', VatValidationController::class)
->middleware(['auth', 'throttle:20,1']);Submit vat_number from a signed-in billing form with method="POST", action="/billing/vat-check" and Laravel's @csrf directive. If you submit through JavaScript, send the CSRF token and Accept: application/json to receive JSON field-validation errors. The Laravel routing documentation covers web routes and CSRF protection. The API key stays on your server.
This controller returns Avatcado's success envelope unchanged. Its own error responses are simple messages for your application. A valid: false result still returns HTTP 200; your form can ask the customer to check the number. A 503 leaves the check unresolved so you can offer another attempt.
Test the success and failure paths
Keep the avat_test_ key while running these cases through the PHP script or Laravel form. Test mode uses deterministic fixtures and makes no registry request.
| VAT number | Expected API response | Application behavior |
|---|---|---|
DE111111111 | 200, valid, Test GmbH | Display the completed check |
DE000000000 | 200, invalid | Ask the customer to check the number |
DE222222222 | 200, valid, no company | Keep customer-entered company details |
DE777777777 | 429, rate_limit_exceeded | Keep the check unresolved |
DE999999999 | 503, upstream_unavailable | Offer a later attempt |
php validate-vat.php DE000000000
php validate-vat.php DE222222222
php validate-vat.php DE777777777
php validate-vat.php DE999999999The examples make one attempt per call. Check the API error code before adding retries: a malformed VAT number needs correction, authentication needs a valid key, and an exhausted monthly quota needs account attention. For burst limits or temporary upstream failures, schedule a bounded retry and respect Retry-After when present. Do not automatically convert a timeout into an invalid result.
Switch the integration to live checks
Create a live key in your dashboard and replace AVATCADO_API_KEY with the avat_live_ key in your deployment's secret settings. Keep the endpoint URL unchanged. For Laravel deployments that cache configuration, rebuild it with php artisan config:cache and restart any long-running workers using the service. Submit an actual customer VAT number and check the response and request ID before enabling the flow for all customers.
For another language, use the Python VAT validation guide. For the endpoint and country coverage, read how to validate EU VAT numbers programmatically.
Sources
- PHP cURL documentation PHP, accessed September 22, 2026
- Laravel 13 HTTP client Laravel, accessed September 22, 2026
- Avatcado test mode Avatcado, accessed September 22, 2026
Related guides
How to Validate VAT Numbers in Python
Validate VAT numbers with the Python SDK. Follow working examples for API keys, company details, error handling, test mode, and async requests.
Validate EU VAT Numbers via API: Developer Guide
Learn how to validate EU VAT numbers, plus UK, Swiss, Norwegian, and Australian VAT/GST, via REST API. Covers VIES, HMRC, TypeScript, Python, and test mode.
How to Handle VAT Validation in Your SaaS Billing Flow
Learn how to integrate real-time VAT validation into your SaaS checkout flow. Covers reverse charge logic, Stripe integration, and storing validation proof.