Phone number validation in PHP takes two tools: libphonenumber-for-php checks numbers against each country’s numbering plan and formats them, offline; the numlookupapi API adds carrier, line type and location for any number when you need them. This guide shows both in plain PHP and in Laravel, plus batch validation for lists. We ran every snippet with PHP 8.5, libphonenumber-for-php 9.0.42 and the Laravel 13 components; the API calls ran against a local stub that returns the documented responses.
Validate and format with libphonenumber for PHP
composer require giggsey/libphonenumber-for-php
<?php
require __DIR__ . '/vendor/autoload.php';
use libphonenumber\NumberParseException;
use libphonenumber\PhoneNumberFormat;
use libphonenumber\PhoneNumberToCarrierMapper;
use libphonenumber\PhoneNumberType;
use libphonenumber\PhoneNumberUtil;
$util = PhoneNumberUtil::getInstance();
try {
$number = $util->parse('07400 123456', 'GB');
var_dump($util->isValidNumber($number)); // bool(true)
echo $util->format($number, PhoneNumberFormat::E164), "\n"; // +447400123456
echo $util->format($number, PhoneNumberFormat::INTERNATIONAL), "\n"; // +44 7400 123456
echo $util->format($number, PhoneNumberFormat::NATIONAL), "\n"; // 07400 123456
var_dump($util->getNumberType($number) === PhoneNumberType::MOBILE); // bool(true)
echo $util->getRegionCodeForNumber($number), "\n"; // GB
echo PhoneNumberToCarrierMapper::getInstance()->getNameForNumber($number, 'en'), "\n"; // Three
} catch (NumberParseException $e) {
echo 'Not a phone number: ', $e->getMessage(), "\n";
}
parse($input, $region)reads the input; the region is the default country for numbers without+. For text that is not a phone number at all it throwsNumberParseException(“The string supplied did not seem to be a phone number.”).isValidNumber()checks length and prefix against the numbering plan.+1 555 555 5555parses but is not valid.- In version 9,
PhoneNumberTypeandPhoneNumberFormatare PHP enums, so compare with===against a case such asPhoneNumberType::MOBILE. US and Canadian numbers returnFIXED_LINE_OR_MOBILE, since both share the same ranges there. - The carrier mapper names the network a number block was originally assigned to. For a US number it returned an empty string.
The version 9 releases support PHP 8.1 to 8.5.
Carrier and line type with the numlookupapi API
Get a free API key on the pricing page (250 requests a month for testing and personal projects, no credit card; commercial use from the Basic plan) and set it as NUMLOOKUPAPI_KEY. The function handles the three kinds of answers: a result, a 422 for malformed input, and errors such as a wrong key or a used-up quota:
<?php
// phone_api.php – PHP 8.1 or later with the curl extension
function lookupPhone(string $number, ?string $countryCode = null): array
{
$url = 'https://api.numlookupapi.com/v1/validate/' . rawurlencode($number);
if ($countryCode !== null) {
$url .= '?' . http_build_query(['country_code' => $countryCode]);
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['apikey: ' . getenv('NUMLOOKUPAPI_KEY')],
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 10,
]);
$body = curl_exec($ch);
if ($body === false) {
throw new RuntimeException('numlookupapi: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$json = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
if ($status === 422) {
return ['valid' => false, 'error' => $json['message']]; // invalid_number, invalid_country_code
}
if ($status !== 200) {
throw new RuntimeException("numlookupapi $status: " . ($json['error']['code'] ?? $json['message']));
}
return $json; // an unknown number is 200 with "valid" => false
}
$result = lookupPhone('+14158586273');
echo $result['line_type'], ' ', $result['carrier']; // mobile AT&T Mobility LLC
There is no curl_close(): the handle is freed automatically since PHP 8.0, and PHP 8.5 deprecates the function. The answer contains valid, country_code, location, carrier, line_type, number_type (with voip for internet-based numbers), timezones and the number in E.164, national, international and RFC 3966 formats; the validate endpoint documentation lists every field. Check valid rather than the HTTP status: a number that is not in use returns 200 with "valid": false. A 429 means the monthly quota is used up or, on the free plan, more than 10 requests a minute were sent.
In Laravel: the HTTP client
use Illuminate\Support\Facades\Http;
$result = Http::withHeaders(['apikey' => config('services.numlookupapi.key')])
->timeout(10)
->get('https://api.numlookupapi.com/v1/validate/' . rawurlencode($phone))
->throw()
->json();
Put the key in .env and read it in config/services.php ('numlookupapi' => ['key' => env('NUMLOOKUPAPI_KEY')]), so config:cache keeps working. throw() turns 401, 422 and 429 answers into a RequestException; validate the format with the rule below first, so user typos never reach the API.
A Laravel validation rule
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use libphonenumber\NumberParseException;
use libphonenumber\PhoneNumberUtil;
class ValidPhone implements ValidationRule
{
public function __construct(private string $defaultRegion = 'US') {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$util = PhoneNumberUtil::getInstance();
try {
$number = $util->parse((string) $value, $this->defaultRegion);
} catch (NumberParseException) {
$fail('The :attribute is not a valid phone number.');
return;
}
if (! $util->isValidNumber($number)) {
$fail('The :attribute is not a valid phone number.');
}
}
}
$request->validate(['phone' => ['required', new ValidPhone('GB')]]);
In our run through Laravel’s validator, 020 7946 0958 and +1 415 858 6273 passed with the default region GB, while 07400 12 and hello failed with “The phone is not a valid phone number.” After validation, store the E.164 form ($util->format($number, PhoneNumberFormat::E164)). If you need the carrier or VoIP detection, call the API in a queued job and save line_type and number_type next to the number. The propaganistas/laravel-phone package offers a ready-made rule and cast built on the same library.
Validate a list in batches
POST /v1/validate takes up to 100 numbers per request. Each number counts as one request, identical entries in the same batch are validated and billed once, and the answer is keyed by the number exactly as you sent it:
$results = [];
foreach (array_chunk(array_values(array_unique($numbers)), 100) as $chunk) {
$response = Http::withHeaders(['apikey' => config('services.numlookupapi.key')])
->timeout(30)
->post('https://api.numlookupapi.com/v1/validate', ['numbers' => $chunk])
->throw();
$results += $response->json('data'); // keyed by the number as sent
}
Add 'country_code' => 'GB' to the body for numbers without a country prefix. A number that cannot be read gets "error": "invalid_number" in its own entry instead of failing the batch. The same number in two spellings counts twice, so normalise to E.164 first. Without code, the bulk phone validator checks a list in your browser.
What validation cannot tell you
A valid number exists in the numbering plan, but may be switched off or ported to another network. The carrier field (in the library and in the API) is the network the number range was assigned to. For the live status and the network after porting, an HLR lookup asks the mobile network: add hlr=1 on a paid plan (see the HLR lookup API).
More languages: phone number validation in JavaScript and in Python.