brick/phonenumber
Brick\PhoneNumber is a PHP library for parsing, formatting, and validating international phone numbers. A port of Google's libphonenumber, it offers robust utilities for formatting, validation, and type detection in PHP applications.
brick/phonenumber is a pure-PHP port of Google's libphonenumber. It does one job well: parse, validate, format, and classify phone numbers. It ships no country metadata, so you almost always install the companion data package.
composer require brick/phonenumber brick/phonenumber-metadata
First use case: accept messy user input, validate it, and store it as E.164.
use Brick\PhoneNumber\PhoneNumberFormat;
use Brick\PhoneNumber\PhoneNumberParseException;
use Brick\PhoneNumber\PhoneNumberUtil;
$util = PhoneNumberUtil::getInstance();
try {
$number = $util->parse('+1 (415) 555-2671', 'US');
} catch (PhoneNumberParseException $e) {
// unparseable input (letters, too short, nonsense)
}
$number->isValidNumber(); // true
(string) $number; // "+14155552671" (E.164)
$number->format(PhoneNumberFormat::NATIONAL); // "(415) 555-2671"
$number->format(PhoneNumberFormat::INTERNATIONAL); // "+1 415-555-2671"
Where to look first:
PhoneNumberUtil — parsing, region lookups, example numbers (getExampleNumber('US')), country-code helpers.PhoneNumber — immutable value object; every per-number operation lives here (isValidNumber, getNumberType, format, getRegionCode, getExtension).PhoneNumberFormat — the four output constants: E164, NATIONAL, INTERNATIONAL, RFC3966.PhoneNumberType — constants used by getNumberType() (MOBILE, FIXED_LINE_OR_MOBILE, TOLL_FREE, VOIP, …).The core mental model: parse() returns a PhoneNumber or throws; isValidNumber() is a separate check on the parsed object.
The lowest-friction convention: persist (string) $number (E.164, e.g. +14155552671), and keep the user's region in a separate column if you need to render a national format later.
public function setPhoneAttribute(?string $value): void
{
if ($value === null || $value === '') {
$this->attributes['phone'] = null;
return;
}
$number = PhoneNumberUtil::getInstance()->parse($value, $this->phone_region ?? 'US');
if (! $number->isValidNumber()) {
throw ValidationException::withMessages(['phone' => 'Invalid phone number.']);
}
$this->attributes['phone'] = (string) $number; // E.164
}
Use a ValidationRule rather than a closure so the region is explicit and the rule is reusable:
final class PhoneNumberRule implements ValidationRule
{
public function __construct(private string $region = 'US') {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
try {
$number = PhoneNumberUtil::getInstance()->parse((string) $value, $this->region);
} catch (PhoneNumberParseException) {
$fail('The :attribute is not a phone number.');
return;
}
if (! $number->isValidNumber()) {
$fail('The :attribute is not a valid number for the '.$this->region.' region.');
}
}
}
Then in a form request: 'phone' => ['required', new PhoneNumberRule($request->user()->country)].
Pair the E.164 write with a read-side cast so controllers deal in PhoneNumber objects, not strings:
final class PhoneNumberCast implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes): ?PhoneNumber
{
if ($value === null) {
return null;
}
try {
return PhoneNumberUtil::getInstance()->parse($value, 'ZZ');
} catch (PhoneNumberParseException) {
return null;
}
}
public function set($model, string $key, $value, array $attributes): array
{
if ($value === null) {
return [$key => null];
}
$number = $value instanceof PhoneNumber
? $value
: PhoneNumberUtil::getInstance()->parse((string) $value, 'US');
return [$key => (string) $number];
}
}
getNumberType() answers "is this a mobile?" which drives SMS vs voice decisions:
use Brick\PhoneNumber\PhoneNumberType;
$smsCapable = in_array($number->getNumberType(), [
PhoneNumberType::MOBILE,
PhoneNumberType::FIXED_LINE_OR_MOBILE,
], true);
Treat FIXED_LINE_OR_MOBILE as "mobile-capable" in markets where portability blurs the line (US, UK, etc.).
Use formatOutOfCountryCallingNumber($viewerRegion) when showing a number to someone who has to dial it from another country — it inserts the correct IDD prefix (e.g. 00 1 415… for a UK caller) rather than a hardcoded +.
$number->formatOutOfCountryCallingNumber($request->user()->country ?? 'US');
PhoneNumberUtil::getInstance() is a singleton and caches its metadata source, so the load cost is paid once per request/process. For containers where the full metadata package is too heavy, generate a trimmed metadata source containing only your supported regions and inject it:
$util = new PhoneNumberUtil($customMetadataSource);
Bind that instance as a singleton in a service provider and inject PhoneNumberUtil rather than calling the static everywhere — it makes test substitution possible and keeps the dependency visible.
parse() throwing is not the same as invalid. A number can parse successfully and still be a fake/invalid number. Always follow parse() with isValidNumber(). isPossibleNumber() is only a cheap length check — useful for progressive UI validation, useless as a final gate."020 7946 0958" without GB will succeed against the wrong region or fail. Always pass the user's country; never default to US for a globally-served app.PhoneNumberParseException everywhere. It's an exception, not a boolean return, and it silently bubbles past validation-only code paths. A missing try/catch in a model mutator will 500 the request.(string) $number drops it. If extensions matter, store $number->getExtension() alongside, or use RFC3966 formatting (tel:+1-415-555-2671;ext=123).parse('+14155552671 ext. 123', 'US') succeeds; validate the base number and treat the extension as opaque data.+1 415 555 2671 and (415) 555-2671 are the same number; raw string comparison is wrong. Compare E.164 strings, or use isEqualTo().brick/phonenumber-metadata is thousands of generated PHP files; it dominates vendor size and can show up in cold-start profiling. Trim it in production or at least verify your deploy strategy handles it.brick/phonenumber-metadata on a schedule; stale metadata produces false negatives on new ranges.isValidNumber() is false for 911, 112, shortcodes, and internal extensions. If your app accepts those, don't route them through the same rule.getInstance() is a static singleton — mildly hostile to tests. Wrap it. Tests that need deterministic parsing should construct new PhoneNumberUtil($source) with a stub MetadataSource rather than mutating global state.getRegionCode() can return null. Numbers on international/toll-free ranges don't always map to a single region; don't assume a non-null region for display logic.'ZZ' is your friend for E.164 reads. When reparsing a stored value that already begins with +, the default region is ignored, so pass 'ZZ' to make that explicit.getExampleNumber('US') is excellent for factories and seeders. It returns a structurally valid number per region — far better than hardcoding +15551234567 in test data.How can I help you explore Laravel packages today?