---
title: Validating a Phone Number
description: The phone field formats and binds E.164; deciding whether a number is real belongs on the server, and this is how.
visibility: guest
draft: false
---

# 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

:::preview{title="A phone field in a form"}
<x-wirekit::phone name="phone" label="Phone number" hint="We only use this to confirm a delivery." required />
:::

```bash
# 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
<?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();
        }
    }
}
```

```php
// 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:

:::preview{title="A field showing what the server decided"}
<x-wirekit::phone name="phone" label="Phone number" error="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:

```php
// 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 `error` renders it in a region the field points at with `aria-describedby`, and sets `aria-invalid` on 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.** `announceError` puts 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](../../components/phone.md): the field, its props, and what it binds
- [Country Picker](country-picker.md): a country field with no phone number attached
