giggsey/libphonenumber-for-php-lite
A lightweight PHP library for parsing, formatting, storing, and validating international phone numbers, based on Google's libphonenumber. This lite version includes core phone number utilities and requires PHP 8.1+ with mbstring.
This is a library, not a Laravel package — there is no service provider, no config file, no facade. You wire it up yourself, which takes about five minutes and is the first thing to get right so the rest of the codebase has one obvious entry point.
Install
composer require giggsey/libphonenumber-for-php-lite
Requires PHP 8.1+ and the mbstring extension (already present in most Laravel runtimes; check if you're on a slim Docker image). The lite build ships the core PhoneNumberUtil only — no geocoder, carrier lookup, or short-number metadata. If you need those, install giggsey/libphonenumber-for-php instead; the API surface for parsing/formatting is identical, so you can upgrade without rewriting call sites.
Bind it once
// app/Providers/AppServiceProvider.php
$this->app->singleton(\libphonenumber\PhoneNumberUtil::class, fn () => \libphonenumber\PhoneNumberUtil::getInstance());
$this->app->alias(\libphonenumber\PhoneNumberUtil::class, 'phone.util');
PhoneNumberUtil::getInstance() is itself a singleton over compiled PHP metadata classes, so this binding is mostly about injectability and test swapping, not performance.
Add your own config
// config/phone.php
return [
'default_region' => env('PHONE_DEFAULT_REGION', 'GB'),
];
Do not derive the default region from app()->getLocale(). en is a language, not a region; mapping locale→region will silently parse every US number as if it were British.
First use case: normalize on registration. Point a FormRequest at it before anything hits the database:
protected function prepareForValidation(): void
{
if (! $this->filled('phone')) return;
try {
$number = app(PhoneNumberUtil::class)
->parse($this->input('phone'), $this->input('phone_country', config('phone.default_region')));
$this->merge(['phone_e164' => app(PhoneNumberUtil::class)
->format($number, PhoneNumberFormat::E164)]);
} catch (NumberParseException $e) {
// leave it alone; the validation rule will produce the user-facing error
}
}
Where to look first: docs/PhoneNumberUtil.md in the repo. It's short. The four methods you will use 95% of the time are parse(), isValidNumber(), format() and getNumberType(). Everything else is a specialization of those.
Where to look when a result looks wrong: Google's online demo. If Google agrees with this library, the metadata is "correct" and you're arguing with Google, not with the package. This is the documented triage path and it will save you a wasted day.
The single highest-value pattern. One rule object, reused by FormRequests, API controllers, and imports:
class PhoneNumber implements ValidationRule
{
public function __construct(
private ?string $region = null,
private bool $allowExtension = true,
) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$util = app(PhoneNumberUtil::class);
$region = $this->region ?? config('phone.default_region');
try {
$number = $util->parse((string) $value, $region);
} catch (NumberParseException $e) {
$fail(match ($e->getErrorType()) {
NumberParseException::INVALID_COUNTRY_CODE => 'The :attribute has an unknown country code.',
NumberParseException::TOO_SHORT, NumberParseException::TOO_LONG => 'The :attribute has the wrong number of digits.',
default => 'The :attribute is not a valid phone number.',
});
return;
}
if (! $this->allowExtension && $number->getExtension() !== null) {
$fail('The :attribute must not include an extension.');
return;
}
if (! $util->isValidNumber($number)) {
$fail('The :attribute is not a valid phone number.');
}
}
}
Note the deliberate split: NumberParseException means unparseable, isValidNumber() === false means parseable but not a real number shape. Collapsing both into "invalid phone" makes support tickets unanswerable.
Never store a formatted display string as the source of truth.
$table->string('phone_e164', 32)->nullable()->index(); // +447700900123
$table->string('phone_raw')->nullable(); // exactly what the user typed
$table->char('phone_country', 2)->nullable(); // ISO region used to parse
phone_e164 is your lookup key, dedupe key, and the thing you hand to Twilio/Vonage/Africa's Talking. phone_raw is the audit trail you'll want when a user swears they entered their number correctly. Display formatting is a rendering concern — derive it, don't persist it.
class PhoneResource extends JsonResource
{
public function toArray(Request $request): array
{
$util = app(PhoneNumberUtil::class);
$number = $util->parse($this->phone_e164, PhoneNumberUtil::UNKNOWN_REGION);
return [
'phone_e164' => $this->phone_e164,
'phone_display' => $util->format($number, PhoneNumberFormat::INTERNATIONAL),
'phone_national' => $util->format($number, PhoneNumberFormat::NATIONAL),
];
}
}
If you render phone numbers in Blade, a small <x-phone :number="$user->phone_e164" /> component wrapping formatOutOfCountryCallingNumber($number, $viewerCountry) gives you correct dial-from-anywhere strings on profile pages for free.
The interesting method for CRM/lead-gen work is isNumberMatch(), which returns EXACT_MATCH, NSN_MATCH, SHORT_NSN_MATCH, or NO_MATCH. Two strings that differ only in format match exactly; two that differ in country code but share a national significant number return NSN_MATCH — which is exactly the case you want a human to look at, not an automatic merge.
$match = $util->isNumberMatch($existing->phone_e164, $incoming);
if ($match === MatchType::EXACT_MATCH) { /* safe to dedupe */ }
Importing a 50k-row CSV? Parsing is CPU-bound metadata lookup, not I/O. Chunk it onto a queue rather than inside the request, and consider isPossibleNumber() as a cheap pre-filter before the full isValidNumber() — it's a length/prefix check and rejects obvious garbage much faster.
Metadata ships with every release and patterns genuinely change (number ranges get reassigned). After bumping the library, formatting for existing rows can differ. Keep an artisan command that re-parses phone_e164 and rewrites phone_country/normalized columns, so a metadata upgrade is a deploy step rather than a mystery.
$util->getExampleNumber('GB'); // any valid GB number
$util->getExampleNumberByType('DE', PhoneNumberType::MOBILE);
Use these instead of hand-typed numbers in factories — hand-typed test numbers go stale when metadata changes and produce confusing CI failures.
parse() throws far more often than you expect. Every call site needs a try/catch, and the catch must be narrow (NumberParseException, not \Throwable). Wrap it in a helper and never call the util directly from controllers — one uncaught parse in a checkout flow is a 500 on the last step of a funnel.
The region argument is not optional for national-format input. parse('07700900123', null) throws INVALID_COUNTRY_CODE. If the string starts with +, the region is ignored; if it doesn't, the region is the parse. This is the #1 source of "it works for some users" bugs — always pass a region, from a country select field where you have one and from config otherwise.
Validity is not deliverability. isValidNumber() checks the number against length and prefix range metadata. It cannot know whether the line is allocated, active, or a ported number. Test numbers like +1 650 253 0000 pass validation happily. If you need "this phone actually rings," that's an SMS verification flow or a paid lookup API, and it's a separate decision — don't let anyone conflate the two in a ticket.
Lite really means lite. No PhoneNumberOfflineGeocoder, no PhoneNumberToCarrierMapper, no ShortNumberInfo. If a product requirement is "show the user's city from their area code" or "detect emergency numbers," install the full package; don't hand-roll area-code tables.
v9 replaced constant classes with backed enums. PhoneNumberType::MOBILE still works as a reference, but if you persisted integer type codes in a column (e.g. phone_type = 1), you now need PhoneNumberType::from($int). Any switch on integers needs updating. The upgrade notes say most projects need no code changes — that's true unless you stored the integers, which is a common thing to have done.
Version numbers track Google, not SemVer. A major bump (8.x → 9.x) was mostly internal; a minor bump can change metadata in ways that alter formatting output. Read release notes before bumping, and pin the version in composer.json rather than floating — the package releases roughly every two weeks following Google's cadence.
Don't store serialized PhoneNumber objects. It's tempting (they're PHP-serializable) but couples your database to the library's internal shape; there's literally a bug-fix in the changelog about unserializing old v8 data. Store the E164 string, re-parse on read. Parsing one number is microseconds.
Watch the extension and leading zeros. parse('+41 44 668 18 00 ext. 123') populates getExtension(); if you format to E164 and drop the extension silently, call tracking breaks. Conversely, italianLeadingZero matters for a handful of regions — trust format(), never reconstruct numbers by string concatenation of country code + national number.
nationalNumber is a double in the PhoneNumber object. Fine for real-world numbers (well under 2^53), but never do arithmetic on it or round-trip it through an int cast.
Centralize the singleton, but know it's already global. PhoneNumberUtil::getInstance() returns the same instance everywhere; rebinding it in AppServiceProvider helps with testing and reads better, but don't expect per-request config injection to affect metadata — metadata is fixed at install time.
Metadata loading is now compiled PHP classes (v9 change), which is meaningfully faster than the old arrays and plays well with OPcache. If you're parsing hundreds of thousands of numbers in a single worker, that improvement matters — but so does isPossibleNumber() pre-filtering.
When a number is wrong, check Google's demo first. If Google agrees, it's metadata and belongs upstream. If Google disagrees with this library, it's an issue here. This triage flow is explicitly documented in the README and keeps you from filing duplicate issues against Google's repo.
How can I help you explore Laravel packages today?