# Phone number validation in PHP and Laravel

> Validate phone numbers in PHP with libphonenumber-for-php, write a Laravel rule, and add a live line-type check with Guzzle that never breaks the form.

Canonical: https://mobilevalidate.com/blog/phone-number-validation-php-laravel · Last updated: 2026-09-25

![Cover: Phone number validation in PHP and Laravel](https://mobilevalidate.com/og/blog/phone-number-validation-php-laravel.png)


By MobileValidate team (https://mobilevalidate.com/about) · Published: 2026-09-25 · Category: Developers · Tags: Phone validation, Php, Laravel, Libphonenumber, E.164, API

To validate a phone number in PHP, install `giggsey/libphonenumber-for-php`, parse the input with a default region, check `isValidNumber()`, and store the E.164 format. In Laravel, wrap that in a validation rule. Then, if you need to know whether the number is a mobile or still in service, call a lookup API after validation, and don't let a slow answer fail the form. All the code below was run on PHP 8.3.

## Why isn't a regex or a digit count enough?

Stack Overflow's ["How to validate phone number using PHP?"](https://stackoverflow.com/questions/3090862) had over 324,000 views on 2026-09-25, and ["How to validate phone number in laravel 5.2?"](https://stackoverflow.com/questions/36777840) over 274,000. Many answers suggest `preg_match('/^[0-9]{10}$/', …)` or Laravel's `digits:10`. Those rules accept `1234567890`, which isn't a US number (area codes never start with 1), and reject `+44 7911 123456`, which is a perfectly good one.

Every country has its own lengths and ranges, and they change. Google's libphonenumber shipped 25 metadata releases in 2025. A PHP port that tracks those releases is the practical way to keep up. More failure cases are in [E.164 regex: why a pattern is not enough](/blog/e164-regex-is-not-enough).

## Which PHP library should you use?

[`giggsey/libphonenumber-for-php`](https://github.com/giggsey/libphonenumber-for-php) is the PHP port of Google's library (Apache 2.0). Its version follows Google's metadata: 9.0.40 was published on Packagist on 2026-09-24, one day after Google's release. If you don't need geocoding, carrier names or time zones, `giggsey/libphonenumber-for-php-lite` has the same core API and a smaller footprint.

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

Laravel apps already have Guzzle, so the second package is only needed outside Laravel.

## How do you parse and validate a number in PHP?

This helper returns the E.164 number with its region and type, or a reason. Load Composer's autoloader as usual (Laravel does it for you).

```php
<?php
// app/Support/Phone.php

namespace App\Support;

use libphonenumber\NumberParseException;
use libphonenumber\PhoneNumberFormat;
use libphonenumber\PhoneNumberUtil;

final class Phone
{
    // Our reserved test range (+44 7700 9xxxxx). libphonenumber marks it invalid on purpose.
    private const TEST_RANGE = '/^\+4477009\d{5}$/';

    /** @return array{ok: bool, e164?: string, region?: ?string, type?: string, reason?: string} */
    public static function normalize(?string $raw, ?string $region = null, bool $allowTestRange = false): array
    {
        $raw = trim((string) $raw);
        if ($raw === '' || strlen($raw) > 32) {
            return ['ok' => false, 'reason' => 'unparseable'];
        }
        $util = PhoneNumberUtil::getInstance();
        try {
            $number = $util->parse($raw, $region); // $region like "GB"; null requires a leading +
        } catch (NumberParseException) {
            return ['ok' => false, 'reason' => 'unparseable'];
        }
        $e164 = $util->format($number, PhoneNumberFormat::E164);
        if ($allowTestRange && preg_match(self::TEST_RANGE, $e164)) {
            return ['ok' => true, 'e164' => $e164, 'region' => 'GB', 'type' => 'TEST'];
        }
        if (! $util->isValidNumber($number)) {
            return ['ok' => false, 'reason' => 'invalid'];
        }
        return [
            'ok' => true,
            'e164' => $e164,
            'region' => $util->getRegionCodeForNumber($number),
            'type' => $util->getNumberType($number)->name, // e.g. MOBILE, FIXED_LINE_OR_MOBILE
        ];
    }
}
```

In version 9 of the library, `getNumberType()` returns a PHP enum, hence `->name`. Real output:

```text
["(202) 555-0143","US"]  => {"ok":true,"e164":"+12025550143","region":"US","type":"FIXED_LINE_OR_MOBILE"}
["+44 7911 123456",null] => {"ok":true,"e164":"+447911123456","region":"GG","type":"MOBILE"}
["07911 123456","GB"]    => {"ok":true,"e164":"+447911123456","region":"GG","type":"MOBILE"}
["+1 123 456 7890",null] => {"ok":false,"reason":"invalid"}
["202-555-0143",null]    => {"ok":false,"reason":"unparseable"}
["07700 900001","GB"]    => {"ok":false,"reason":"invalid"}
```

Two surprises worth knowing. `+44 7911` numbers belong to Guernsey (`GG`), so don't derive the country from the dialling code. And a national number without a region can't be parsed, so collect the country on the form.

## How do you validate a 10-digit US phone number?

Parse it with the region `US` and let the library decide. Counting digits accepts impossible numbers and rejects valid ones typed with a leading `1`. Real results from the helper above:

| Input (region `US`) | Result |
|---|---|
| `2025550143` | `+12025550143`, `FIXED_LINE_OR_MOBILE` |
| `12025550143` | `+12025550143` (the leading `1` is the country code) |
| `1234567890` | `invalid`: ten digits, but no area code starts with 1 |
| `202-555-014` | `invalid`: one digit short |
| `(202) 555-01433` | `invalid`: one digit too many |

The type is `FIXED_LINE_OR_MOBILE` because US and Canadian ranges don't separate mobiles from landlines. If you need to know which one it is, for example before sending an SMS, that takes a lookup (below). Also remember that `+1` isn't only the US: Canada and many Caribbean countries share it, and `getRegionCodeForNumber()` tells them apart.

## How do you write a Laravel validation rule for phone numbers?

Laravel's `ValidationRule` interface needs one method. The rule reuses the helper:

```php
<?php
// app/Rules/PhoneNumber.php

namespace App\Rules;

use App\Support\Phone;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class PhoneNumber implements ValidationRule
{
    public function __construct(private ?string $defaultRegion = null) {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $result = Phone::normalize(is_string($value) ? $value : null, $this->defaultRegion);
        if (! $result['ok']) {
            $fail('The :attribute is not a valid phone number.');
        }
    }
}
```

Use it like any other rule: `'phone' => ['required', 'string', new PhoneNumber('GB')]`. We ran it through `illuminate/validation` 13.33: `07911 123456` passes, while `12345` and `+1 123 456 7890` fail with "The phone is not a valid phone number."

If users pick a country, pass it in: `new PhoneNumber($request->input('country'))`. Validation rules don't change the input, so convert to E.164 after validation with `Phone::normalize()` and save that. If you'd rather not maintain a rule, [`propaganistas/laravel-phone`](https://github.com/Propaganistas/Laravel-Phone) (6.0.3) packages the same library as rules and Eloquent casts.

## How do you check whether the number is a mobile and in service?

A valid number can be a landline, a VoIP line or a number nobody uses anymore. A [carrier lookup](/services/carrier-lookup) returns the current `line_type` and carrier. Here's a small Guzzle client for the MobileValidate API. It posts numbers in the body, waits for pending answers and retries only errors the API marks as retryable:

```php
<?php
// app/Services/MobileValidate.php

namespace App\Services;

use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;

final class MobileValidate
{
    private Client $http;

    public function __construct(string $apiKey, string $baseUrl = 'https://api.mobilevalidate.com')
    {
        $this->http = new Client([
            'base_uri' => $baseUrl,
            'headers' => ['Authorization' => "Bearer {$apiKey}", 'Accept' => 'application/json'],
            'timeout' => 45, // the server may long-poll for up to `wait` seconds
        ]);
    }

    /** Checks up to 100 E.164 numbers. Numbers go in the POST body, never in the URL. */
    public function lookup(array $numbers, array $checks = ['carrier']): array
    {
        $res = $this->call('POST', '/v1/lookup', ['json' => ['numbers' => $numbers, 'checks' => $checks, 'wait' => 10]]);
        while ($res['status'] === 'pending') {
            usleep(($res['next']['poll_after_ms'] ?? 2000) * 1000);
            $res = $this->call('GET', "/v1/lookups/{$res['id']}", ['query' => ['wait' => 10]]);
        }
        return $res['results'];
    }

    private function call(string $method, string $path, array $options, int $attempt = 0): array
    {
        try {
            $response = $this->http->request($method, $path, $options);
            return json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR);
        } catch (RequestException $e) {
            $response = $e->getResponse();
            $error = $response ? (json_decode((string) $response->getBody(), true)['error'] ?? []) : [];
            if (($error['retryable'] ?? false) && $attempt < 3) {
                sleep((int) ($response?->getHeaderLine('Retry-After') ?: 2 ** $attempt));
                return $this->call($method, $path, $options, $attempt + 1);
            }
            throw new \RuntimeException($error['code'] ?? 'http_error', $response?->getStatusCode() ?? 0, $e);
        }
    }
}
```

Register it in a service provider with the key from your environment, for example `new MobileValidate(config('services.mobilevalidate.key'))`, where the config value reads `env('MOBILEVALIDATE_API_KEY')`. Keep the key on the server; never put it in a Blade view or front-end bundle.

With a free test key, `lookup(['+447700900001', '+447700900003', '+447700900004'])` printed this (real output, one line per row: E.164, status, line type, reason):

```text
+447700900001 completed mobile
+447700900003 unknown null UPSTREAM_TIMEOUT
+447700900004 completed mobile
```

`…004` is pending for about five seconds in test mode, so the loop polled once. `…402` threw `RuntimeException('insufficient_balance', 402)` immediately, and `…429` retried three times, about a second apart as `Retry-After: 1` asked, before giving up with `rate_limited`.

## How do you handle unknown answers without failing the form?

This is where many integrations go wrong. The user typed a valid number, and your lookup timed out. Rejecting the sign-up punishes the user for your dependency. Split the decision in two:

1. **Format** is checked by the rule, synchronously. It's deterministic and free, so failing the form is fine.
2. **Line type and status** are extra facts. Use them when they're conclusive, and ignore them otherwise.

```php
$e164 = Phone::normalize($validated['phone'], $validated['country'])['e164'];
$lineType = null;
try {
    $row = app(MobileValidate::class)->lookup([$e164])[0];
    $check = $row['checks']['network.carrier'] ?? [];
    if (($check['status'] ?? null) === 'completed') {
        $lineType = $check['attributes']['line_type'] ?? null;
    }
} catch (\Throwable $e) {
    report($e); // log the error code, never the number
}
$user->forceFill(['phone' => $e164, 'phone_line_type' => $lineType])->save();
```

`$lineType` stays `null` when the answer is unknown. Store `null`, not `false` or `'mobile'`, and re-check later with a queued job. Unknown answers aren't billed. When you do have an answer, act on it: skip SMS for `fixed_line` or `toll_free`, and add a step for `voip` if your fraud rules call for it. The [errors reference](/docs/errors) says which codes are worth retrying.

## How do you validate numbers in bulk?

For imports, don't call the lookup row by row. One request takes up to 100 numbers, and a [bulk job](/docs/bulk-jobs) takes up to 50,000. Normalize each row with `Phone::normalize()` first, so obviously invalid rows never leave your server, then send the E.164 values. The API deduplicates after normalization, and invalid or duplicate rows are never charged. The request rate is 10 per second per key, so batching matters more than parallelism. [How to clean a phone number list in bulk](/blog/how-to-clean-a-phone-number-list-in-bulk) covers the full workflow.

## Why do the test numbers need special handling?

Our [test mode](/docs/test-mode) uses `+44 7700 900000` to `900999`, a range the UK regulator Ofcom reserves for drama. libphonenumber knows it's never assigned and reports it invalid, so your rule would reject test numbers. Pass `allowTestRange: true` to `Phone::normalize()` only when the configured key starts with `mv_test_`. In PHPUnit or Pest, a test key gives fixed answers (`…001` data, `…002` and `…003` unknown, `…004` pending) and costs nothing.

## What are the key takeaways?

- Use `giggsey/libphonenumber-for-php`, keep it updated, and validate with `isValidNumber()`, not digit counts.
- Wrap it in a Laravel `ValidationRule`, then save the E.164 form after validation.
- Take the region from the parser (`getRegionCodeForNumber()`), not from the dialling code.
- Call the lookup API from the server, with numbers in the POST body. Retry only retryable errors.
- Never fail a form because a lookup was slow or unknown. Store `null` and re-check later.
- Batch imports: up to 100 numbers per lookup, 50,000 per job.

## Sources

1. [libphonenumber-for-php](https://github.com/giggsey/libphonenumber-for-php) — GitHub (Joshua Gigg), 2026
2. [Validation (Laravel 13.x)](https://laravel.com/docs/13.x/validation) — Laravel, 2026
3. [How to validate phone number using PHP? (question 3090862)](https://stackoverflow.com/questions/3090862) — Stack Overflow, 2026
4. [libphonenumber](https://github.com/google/libphonenumber) — Google, 2026

## Frequently asked questions

### How do I validate a phone number in PHP?

Install giggsey/libphonenumber-for-php, parse the input with PhoneNumberUtil::parse() and a default region, then call isValidNumber(). Store the result of format($number, PhoneNumberFormat::E164).

### How do I validate a 10-digit US phone number?

Parse it with the region "US" and call isValidNumber(). Checking for ten digits isn't enough: 123 456 7890 has ten digits but no US area code starts with 1, so the library rejects it.

### Should a Laravel form fail when the phone lookup API is down?

No. Validate the format synchronously with a rule, then treat the lookup as extra information. If the API times out or returns unknown, accept the number and re-check it later.

### Is there a ready-made Laravel package?

Yes. propaganistas/laravel-phone wraps the same library in validation rules and casts. The custom rule in this guide is a few lines and has no extra dependency.
