How to validate a phone number in PHP
Most of what you need to validate a phone number in PHP ships as a free library — here is how to do it right, and the honest point where a service starts.
Contents
You need to validate a phone number in PHP: reject the typos before they hit your database, and store something you can actually dial or text later. The good news is that most of this is a solved problem you can run locally for free, no API call and no per-request cost. Here is how to do it correctly with the standard library, where its guarantees stop, and the honest point where a hosted service is worth paying for.
Use libphonenumber-for-php
The giggsey/libphonenumber-for-php package is a faithful port of Google's libphonenumber — the same rules that ship inside Android. It carries per-country numbering plans and length rules, so it knows a UK mobile from a US toll-free line without you writing a single regex.
composer require giggsey/libphonenumber-for-php
Parsing is the first step, and it can throw. Always wrap it:
use libphonenumber\PhoneNumberUtil;
use libphonenumber\NumberParseException;
$util = PhoneNumberUtil::getInstance();
try {
$proto = $util->parse('+1 650-447-2983', 'US');
} catch (NumberParseException $e) {
// Not a parseable number — reject it here.
http_response_code(422);
exit('Invalid phone number');
}
The second argument to parse() is a default region. If the input already carries a + country code, the region is ignored. If it does not, parse('650-447-2983', 'US') tells the library which numbering plan to assume. Store the region you expect per user — guessing wrong turns a valid local number into a rejection.
Check validity, do not just parse
Parsing tells you the string is shaped like a phone number. It does not tell you the number is valid within its country's plan. +1 000-000-0000 parses fine and is nonsense. Use isValidNumber() for the real check:
if (! $util->isValidNumber($proto)) {
http_response_code(422);
exit('Number is not valid for its region');
}
isValidNumber() checks the number against the length and prefix rules for its country. That is the gate you want on a signup form. Do not confuse it with isPossibleNumber(), which only checks the length is plausible and will wave through more garbage.
Format to E.164 before you store it
Never store what the user typed. Store one canonical form so +1 (650) 447-2983, 650.447.2983, and +16504472983 all become the same row. E.164 is that form — the +-and-digits international standard your SMS and voice providers expect. If E.164 is new to you, read the primer here.
use libphonenumber\PhoneNumberFormat;
$e164 = $util->format($proto, PhoneNumberFormat::E164);
// "+16504472983"
$national = $util->format($proto, PhoneNumberFormat::NATIONAL);
// "(650) 447-2983" — for display only
Store the E.164 string. Format to NATIONAL at render time when you want it to look local.
Read the line type — and know its limit
getNumberType() returns a PhoneNumberType constant, useful when you only want to SMS mobiles:
use libphonenumber\PhoneNumberType;
$type = $util->getNumberType($proto);
if ($type === PhoneNumberType::MOBILE) {
// safe to queue an SMS
}
Here is the honest catch. In the North American plan, mobile and fixed-line numbers share the same ranges, so libphonenumber returns PhoneNumberType::FIXED_LINE_OR_MOBILE for most US and Canadian numbers. That is not a bug — it is the library telling you the truth: from the digits alone, it genuinely cannot tell. Any tool that answers "mobile" with certainty for a US number is guessing. To actually know, you need a live carrier lookup, which no offline library can do.
Where an API earns its place
For a single PHP service, the library above is very likely all you need. Say so to yourself before you reach for a paid dependency: parse, validate, format, store E.164, done. An API does not make those four steps more correct.
Two things do change the math. First, one contract across languages. The moment your PHP app, a Node worker, and a Python job all validate the same numbers, you are running three ports of libphonenumber at three different versions, drifting apart on edge cases. One HTTP endpoint gives every service the same answer. Boundstone's /tools/phone-validator runs this for free with no key, and the API returns the same metadata-grade result over one contract:
$ch = curl_init('https://api.boundstone.io/v1/verify/phone');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer bs_live_YOUR_KEY',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode(['phone' => '+16504472983']),
]);
$result = json_decode(curl_exec($ch), true);
The response tells you exactly what it did and did not do:
{
"valid": true,
"e164": "+16504472983",
"country": "US",
"line_type": "fixed_line_or_mobile",
"national_format": "(650) 447-2983",
"checks": {
"performed": ["format", "region", "line_type_metadata"],
"not_performed": ["carrier_lookup", "ported_status", "hlr_liveness"]
}
}
That not_performed array is the point. Today the phone endpoint gives you format, region, and line-type metadata — the same offline answer libphonenumber gives, held to a stable contract. By default it does not perform carrier lookup, ported-status, or HLR liveness — send hlr:true and it does, for 5 credits, refunded when the network cannot answer. Those are the second reason to want a service: the layer past the library, telling you a number is not just well-formed but currently assigned and reachable. They run only when you ask for them, priced at what a real dip costs — never implied by a green checkmark you cannot verify. When a response says valid, the not_performed list is precisely how you know what that word is promising.
The short version
- Reach for
giggsey/libphonenumber-for-phpfirst — for one PHP service it is very likely the whole answer, at zero cost. parse()in a try/catch, gate onisValidNumber(), store thePhoneNumberFormat::E164string, format toNATIONALonly for display.getNumberType()returnsFIXED_LINE_OR_MOBILEfor most US numbers — that is honesty, not a defect. Certainty needs a live carrier lookup.- Add an API when you need one consistent contract across languages, or the carrier/HLR layer no offline library can do — and read the
checks.not_performedarray so you know what a "valid" actually claims.
Working in JavaScript too? The Node walkthrough uses the same libphonenumber rules with the same honest boundaries.