# How to validate a phone number in PHP

_2026-09-02 · Boundstone (https://boundstone.io/blog/validate-phone-php)_


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.

```bash
composer require giggsey/libphonenumber-for-php
```

Parsing is the first step, and it can throw. Always wrap it:

```php
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:

```php
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](/blog/what-is-e164).

```php
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:

```php
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`](/tools/phone-validator) runs this for free with no key, and the API returns the same metadata-grade result over one contract:

```php
$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:

```json
{
  "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-php` first — for one PHP service it is very likely the whole answer, at zero cost.
- `parse()` in a try/catch, gate on `isValidNumber()`, store the `PhoneNumberFormat::E164` string, format to `NATIONAL` only for display.
- `getNumberType()` returns `FIXED_LINE_OR_MOBILE` for 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_performed` array so you know what a "valid" actually claims.

Working in JavaScript too? The [Node walkthrough](/blog/validate-phone-javascript) uses the same libphonenumber rules with the same honest boundaries.
