Developers

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.

By Published 7 min read

On this page

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?" had over 324,000 views on 2026-09-25, and "How to validate phone number in laravel 5.2?" 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.

A terminal sends an API request and a JSON response returns true, false and null values.A terminal sends an API request and a JSON response returns true, false and null values.
One REST call. Every answer is true, false or null (unknown).

Which PHP library should you use?

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.

Shell
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)
1234567890invalid: ten digits, but no area code starts with 1
202-555-014invalid: one digit short
(202) 555-01433invalid: 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 (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 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 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 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 covers the full workflow.

Why do the test numbers need special handling?

Our 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 — GitHub (Joshua Gigg), 2026
  2. Validation (Laravel 13.x) — Laravel, 2026
  3. How to validate phone number using PHP? (question 3090862) — Stack Overflow, 2026
  4. 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.

All articles

Know before you send.

Tell us about your use case. We review every request and set you up with test and live keys.