Validating a Phone Number
<x-wirekit::phone> gives you a well-formed E.164 string — +4915123456789. It does not tell you whether anybody answers there, and it deliberately never claims to.
That is not a gap to work around. Deciding a number is real needs per-country length and range data measured in megabytes, and a field that quietly rejects a valid number is worse than one that never claims to know. This recipe is the other half: the server decides, with data it fetched on purpose.
What the Field Already Guarantees
Before reaching for anything, know what you already have. The bound value is either empty or it is a plus sign, a dialing code the package carries, and at least one digit. So these are already impossible:
- a number with no country
- separators, brackets or spaces
- a leading national trunk digit that does not belong in E.164
What is still possible is a number that is well-formed and not real — too short, too long, or in a range the country does not assign.
Blade Code
We only use this to confirm a delivery.
# 1. The lean branch. Its only requirements are PHP 8.1 and a polyfill; the full
# package additionally pulls a locale library you do not need for this.
composer require giggsey/libphonenumber-for-php-lite
<?php
// 2. A rule rather than a closure in every controller: one place to change when
// the policy does, and one name to read in a validation error.
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use libphonenumber\NumberParseException;
use libphonenumber\PhoneNumberUtil;
final class RealPhoneNumber implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
// 3. The field binds an empty string when nothing was typed. Whether that is
// acceptable is `required`'s question, not this rule's — answering it here
// would make the two rules disagree on an empty field.
if (! is_string($value) || $value === '') {
return;
}
$util = PhoneNumberUtil::getInstance();
try {
// 4. Null as the region: the value carries its own country code, so there is
// no default to fall back to. Passing one would let a number without a
// plus sign parse as the wrong country.
$number = $util->parse($value, null);
} catch (NumberParseException) {
$fail('The :attribute is not a phone number we can read.')->translate();
return;
}
// 5. isValidNumber, not isPossibleNumber. The second only checks the length;
// the first checks the range the country actually assigns.
if (! $util->isValidNumber($number)) {
$fail('The :attribute is not a number that can be reached.')->translate();
}
}
}
// 6. In the component or controller that receives the form.
$this->validate([
'phone' => ['required', 'string', new RealPhoneNumber],
]);
Showing the Result
error takes the message and paints the field:
This is not a number that can be reached.
In Livewire the bag is filled for you and the field reads it under its own name, so wire:model="phone" is all the wiring there is. Pass error explicitly, as the preview above does, only when the message comes from somewhere the bag does not reach.
Formatting for Display
E.164 is the right thing to store and the wrong thing to show in a table. The same library formats it back:
// 8. NATIONAL drops the country code for a reader in that country; INTERNATIONAL keeps
// it. Pick per audience, not per record.
$util = PhoneNumberUtil::getInstance();
$number = $util->parse($stored, null);
$display = $util->format($number, \libphonenumber\PhoneNumberFormat::NATIONAL);
⚠️ Do not feed this back into the field. The component shows what the reader typed and binds E.164; writing a formatted string into its value makes the two disagree, and the next save stores whichever one won.
Customization
| What to change | How |
|---|---|
| Accept only some countries | Pass countries to the field so the picker offers them, and check $util->getRegionCodeForNumber($number) in the rule — the field narrows the picker, not what can be pasted |
| Reject landlines | $util->getNumberType($number) returns a type; compare it against PhoneNumberType::MOBILE |
| A softer check | Swap isValidNumber for isPossibleNumber, which only checks the length. Useful while a form is still being filled in, wrong as the final gate |
| Your own message | The two $fail strings are translation keys once ->translate() is called; put them in your language files under the text shown here |
⚠️ Narrowing by country belongs in the rule, not only in the picker. countries decides what the list offers and nothing more — a number pasted from elsewhere reaches the server either way, which is deliberate.
Production Considerations
- The metadata loads on first use and stays in memory for the request. One validation per request costs the load; a hundred cost it once. There is nothing to cache yourself.
- Keep the stored value in E.164 and format on the way out. A record stored in a national format loses its country the moment somebody reads it from a different one.
- The library is a data dependency, so it changes when numbering plans change. Countries open new ranges; a number your rule rejects today can become valid. Treat an update like any other dependency bump — it is the data that moved, not the code.
- A rejected number is the reader's, not a bug report. Log the failure with the attribute name and without the value: a phone number is personal data, and a validation log is the wrong place for it.
Accessibility
The rule produces a message, and the field is what carries it to a reader.
- The message reaches assistive technology because
errorrenders it in a region the field points at witharia-describedby, and setsaria-invalidon the control. - Say what is wrong, not that something is. "The phone number is not a number that can be reached" tells a reader to look at the digits; "Invalid input" tells them nothing they did not already know.
- Do not rely on the red border alone. It is the browser's own marking for a field the reader left invalid, and it says nothing about a number that is well-formed and unreachable — which is exactly what this rule catches.
- Announce it once.
announceErrorputs the message in a live region; the field's own error paragraph is already announced, so a second region on the page would read it twice.
Why This Is Not in the Package
The metadata behind isValidNumber is several megabytes, and most applications never need it — a delivery form that texts a confirmation finds out whether the number works by texting it. Carrying that weight for everyone so that a few can skip a composer require is the wrong trade, and it is the same reasoning that keeps the field from guessing.
Related
- Phone: the field, its props, and what it binds
- Country Picker: a country field with no phone number attached