turbo-php / livewire-phone-input
A headless, publishable Livewire phone number input: country selector, national number field, and inline libphonenumber validation.
Requires
- php: ^8.3
- ext-intl: *
- ext-mbstring: *
- illuminate/contracts: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- livewire/livewire: ^4.0
- propaganistas/laravel-phone: ^6.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.20
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0|^5.0
This package is not auto-updated.
Last update: 2026-08-21 14:19:40 UTC
README
A phone number field for Livewire: pick the country, type the number.
The two halves are separate on screen because they are separate questions —
which country the number is in, and what the number is — and answering them
apart is what makes a bare 050 123 4567 unambiguous. Validation is
libphonenumber by way of
laravel-phone, so "valid"
means a number that country has actually allocated, not one of the right length.
The view ships unstyled. Structure, state and accessibility come from the package; how any of it looks is yours.
<livewire:phone-input wire:model="phone" :label="__('Mobile number')" />
Contents · The one guarantee · Installation · Configuration · Validation · Reading and writing numbers · Countries · Styling · Behaviour worth knowing · Nothing the browser draws itself
The one guarantee
The bound value is always either
nullor a valid E.164 number.
Nothing half-typed reaches the parent. A component that publishes +9715 while
someone is still typing forces every consumer to re-validate, and one of them
will forget. So the parent's property holds a number that can be dialled or it
holds nothing:
public ?string $phone = null; // '+971501234567' or null — never '+9715'
Declare it nullable. A public string $phone = '' will fail the moment the
field clears itself.
Installation
composer require turbo-php/livewire-phone-input
There is nothing to register: the service provider is discovered, the component
is available as <livewire:phone-input />, and the config, views and
translations have working defaults.
Add the validation message laravel-phone translates, in lang/en/validation.php:
'phone' => 'The :attribute field must be a valid phone number.',
Laravel merges its own defaults underneath, so a file containing only this key is enough.
Configuration
php artisan vendor:publish --tag=phone-input-config
| Key | Default | |
|---|---|---|
default_country |
US |
Where a fresh field starts, and the country a bare national number is read against. |
preferred_countries |
[] |
Pinned to the top of the selector, in the order given. |
only_countries |
[] |
Narrows the selector to a fixed set. Also bounds validation. |
excluded_countries |
[] |
Removes individual countries. |
types |
[] |
e.g. ['mobile'] to refuse landlines. |
lenient |
false |
Accept correctly shaped numbers in ranges libphonenumber has not caught up with. |
searchable |
true |
Filter box on the selector. Filtering is client-side, so it costs no round trip. |
search_threshold |
10 |
How long the list must be before the filter box appears. |
validate_while_typing |
true |
Off validates on blur instead. |
debounce |
500ms |
How long to wait while typing. |
reformat |
true |
Rewrite a number into the national form of its country once understood. |
classes |
[] |
A class string per part of the field. See Styling. |
view |
phone-input::phone-input |
Point at your own view without publishing. |
Every one of these is also a prop, for a single field that differs from the rest:
<livewire:phone-input wire:model="phone" :label="__('Mobile number')" default-country="AE" :preferred-countries="['AE', 'SA']" :types="['mobile']" :description="__('We text the voucher to this number.')" required />
Props
label, description, placeholder, name, input-id, error, required,
disabled, readonly, autofocus, plus each configuration key above —
kebab-cased on the tag, camel-cased on the component (default-country sets
$defaultCountry).
placeholder overrides the default, which is a real example number for the
selected country — the expected shape, shown rather than described.
name renders a hidden input carrying the E.164 value, so the field also works
inside a plain HTML form.
Validation
PhoneRule is laravel-phone's own rule with your configuration already applied,
so a form and the field inside it cannot hold different opinions about the same
number. It returns laravel-phone's rule, so the whole fluent API is still there.
use TurboPhp\PhoneInput\Rules\PhoneRule; // A stored E.164 number. Restricted to `only_countries` if you set it. 'phone' => ['required', PhoneRule::make()], // One country only. 'phone' => ['required', PhoneRule::make('AE')], // From anywhere, reading a bare national number as the default country. // This is the rule for an API that takes numbers as they arrive. 'phone' => ['required', PhoneRule::anyCountry()], // A national number validated against a country in another field. 'phone' => ['required', PhoneRule::forCountryField('phone_country')], // Anything laravel-phone can do. 'phone' => ['required', PhoneRule::make()->mobile()->lenient()],
Show the form's own error in the field by passing it down — the message then appears under the field it is about, and takes precedence over the field's inline message, because it is the one that stopped the form saving:
<livewire:phone-input wire:model="phone" :error="$errors->first('phone') ?: null" />
Reading and writing numbers
Phone is total: hand it whatever someone typed and it answers without
throwing. Anything it cannot understand comes back as null.
use TurboPhp\PhoneInput\Phone; Phone::e164('050 123 4567', 'AE'); // '+971501234567' Phone::e164('+9715012345678'); // null — parses, but no such number Phone::national('+971501234567'); // '050 123 4567' Phone::international('+971501234567'); // '+971 50 123 4567' Phone::country('+447400123456'); // 'GB' Phone::isValid('02 234 5678', 'AE', ['mobile']); // false — that is a landline Phone::split('+447400123456', 'AE'); // ['GB', '07400 123456'] Phone::digits('+971 (50) 123-4567'); // '+971501234567' — last resort
Validity is the bar rather than parseability on purpose. +9715012345678 has a
country code and digits, but no such number can be dialled, and storing it
would mean your register holds a number nobody answers.
Store numbers in E.164 — laravel-phone's E164PhoneNumberCast does this — and
use Phone::international() when showing one to a person.
Countries
Countries builds its catalogue from libphonenumber's own metadata, so the
selector and the validator can never disagree about which countries exist.
Names come from intl in the application's locale.
use TurboPhp\PhoneInput\Countries; Countries::all(); // 245 countries, keyed by ISO 3166-1 alpha-2 Countries::find('ae')->prefix(); // '+971' Countries::find('AE')->flag(); // 🇦🇪 Countries::find('AE')->exampleNumber(); // '050 123 4567' Countries::selectable(only: ['AE', 'GB'], preferred: ['AE']);
Flags are regional indicator symbols rather than images: no asset to host, no sprite sheet to keep in step with the country list, and they inherit the surrounding font size.
Styling
The field is headless in the strong sense: the packaged view renders no class attribute at all, and there is a test asserting it. There are four ways to give it a look, in increasing order of how much you take over.
1. Class parts
Every element asks the component for its classes by part name, so the whole field can be styled without touching a template. Set the house style once in config:
// config/phone-input.php 'classes' => [ 'control' => 'flex w-full items-stretch rounded-lg border bg-white', 'controlIdle' => 'border-zinc-200 focus-within:border-indigo-500', 'controlInvalid' => 'border-red-500', 'controlValid' => 'border-green-600', 'input' => 'w-full bg-transparent px-3 py-2 focus:outline-none', 'trigger' => 'flex items-center gap-1.5 border-e px-3', 'popover' => 'absolute start-0 z-30 w-80 rounded-lg border bg-white shadow-lg', 'popoverAbove' => 'bottom-full mb-1.5', 'popoverBelow' => 'top-full mt-1.5', 'option' => 'flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-start', 'optionSelected' => 'font-medium text-indigo-600', 'feedback' => 'mt-2 text-xs font-medium text-red-600', ],
…and let one field be the exception:
<livewire:phone-input wire:model="phone" :classes="['control' => 'rounded-full']" />
The prop is merged over the config, key by key, so a field overrides only what it names.
Important
Tailwind will not see class names in a config file. Tailwind scans the
paths it is told about, and config/ is not usually one of them, so every
class you put here gets stripped from the build. Add the file as a source:
/* resources/css/app.css */ @source '../../config/phone-input.php';
The same applies wherever you keep them. This costs an afternoon if you do not know it and five seconds if you do.
The parts, in the order they appear in the markup:
root rootIdle rootInvalid rootValid rootDisabled |
the wrapper |
label required |
the label, and the asterisk inside it |
leading |
slot wrapper, before the country selector |
control controlIdle controlInvalid controlValid controlDisabled |
the box around selector and field |
country trigger flag dialCode caret |
the country selector's button |
popover popoverAbove popoverBelow |
the menu |
searchWrapper search |
the filter box |
options option optionSelected optionFlag optionName optionDialCode |
the list |
divider empty |
between preferred and the rest; the no-results line |
input check trailing |
the number field, the valid indicator, slot wrapper |
description feedback |
the two lines under the field |
A variant is added to its base part rather than replacing it, so it holds
only what differs. Exactly one of Idle, Invalid and Valid applies at a
time, which is what makes a focus style expressible: put it on Idle, and
focusing a field can never override the fact that it is wrong.
Because a variant is added rather than substituted, a variant that sets the same property as its base part is a tie, and which one wins comes down to the order your CSS happened to be emitted in — not the order you wrote them. Either keep the property in one place:
'option' => 'flex w-full items-center gap-2.5', // no colour here 'optionIdle' => 'text-zinc-700', // …not a part; see below
…or, more simply, mark the variant important:
'option' => 'flex w-full items-center gap-2.5 text-zinc-700', 'optionSelected' => 'font-medium text-accent!', // wins regardless of emit order
Only root and control have an Idle variant. Everywhere else, put the
conflicting property in the variant and mark it important.
popoverAbove and popoverBelow are bound rather than printed — whether the
menu opened upwards is decided in the browser, after measuring.
2. Slots
Five pieces are left empty for you to fill, plus a default slot after the field:
<livewire:phone-input wire:model="phone"> <livewire:slot name="caret"><x-icon.chevron-down /></livewire:slot> <livewire:slot name="check"><x-icon.check class="text-green-600" /></livewire:slot> <livewire:slot name="empty">Nowhere by that name</livewire:slot> <livewire:slot name="leading"><x-icon.phone /></livewire:slot> <livewire:slot name="trailing"><button type="button">Clear</button></livewire:slot> <p>We only ever text you about an appointment.</p> </livewire:phone-input>
caret and check are empty by default: the package reports that there is a
menu to open and that the number is good, and drawing either is your decision.
empty falls back to the packaged message. leading and trailing sit inside
the control, and their wrappers are not rendered at all unless filled.
Slots are evaluated in the parent's context, so anything they reference — properties, methods, other components — belongs to the parent, not to the field.
3. A stylesheet
Every element also carries a data-phone-input-* attribute, so none of the above
is required if you would rather style from CSS:
| Attribute | |
|---|---|
data-phone-input |
Root. Also data-phone-input-valid, -invalid and -disabled. |
data-phone-input-label |
The label, with data-phone-input-required inside it. |
data-phone-input-control |
Wraps the selector and the number field. |
data-phone-input-leading / -trailing |
Slot wrappers inside the control. |
data-phone-input-trigger |
Opens the selector. Contains -flag, -dial-code, -caret. |
data-phone-input-popover |
The selector. Carries data-phone-input-placement="above" or "below". |
data-phone-input-search-wrapper / -search |
The filter box. |
data-phone-input-option="AE" |
One country. data-phone-input-selected on the current one, data-phone-input-group is preferred or others. |
data-phone-input-divider |
Between the preferred countries and the rest. |
data-phone-input-empty |
Shown when a search matches nothing. |
data-phone-input-number |
The number field. |
data-phone-input-check |
Present only while the number is valid. |
data-phone-input-description / -feedback |
The two lines under the field. |
4. Your own template
When the shape itself is wrong, not just the styling, point a field at a view of your own. It keeps every bit of its behaviour:
<livewire:phone-input wire:model="phone" view="components.my-phone-field" />
Or set phone-input.view to replace it everywhere, or publish the packaged one
and edit it:
php artisan vendor:publish --tag=phone-input-views
Note
Publishing pins your copy at the version you published from — of the view and
of the config alike. A published config/phone-input.php will not grow keys
that later versions add: the package merges its own defaults underneath, so
nothing breaks, but you will not discover a new option by reading your own
config file. After upgrading, diff against
vendor/turbo-php/livewire-phone-input/config/phone-input.php.
A custom template has the component available as $this, so
$this->selectedCountry, $this->countryOptions, $this->feedback,
$this->isValid, $this->placeholderText, $this->classAttribute(...) and the
public props are all there. Start from the packaged view — it is the reference
implementation, and publishing pins your copy at the version you published from,
so re-publish to a scratch path and diff after an upgrade.
Behaviour worth knowing
- A pasted international number moves the selector. Type
+44 7400 123456with the UAE selected and the field switches to the United Kingdom, because a number carrying its own country code answers for itself. - A valid number from a country the field does not offer is refused, with a message saying so rather than a generic one.
- The country is checked on the server. The list of options is markup, and markup is a suggestion.
- A misconfigured default is survivable. If
default_countryis not selectable, the field starts on the first country it does offer rather than on nothing. - Filtering is client-side, over the rendered options; only choosing a
country goes to the server. The full list is 245 options — set
only_countriesif that is more DOM than you want. Escapecloses the selector, arrow keys move through it,HomeandEndjump to the ends, andEnterin the search box takes the first match.- A valid number is confirmed, a rejected one is not. The root carries
data-phone-input-validand an empty[data-phone-input-check]appears while the field holds a good number. Anerrorfrom the surrounding form suppresses both: a number can dial perfectly well and still be one this form will not have, and a tick beside a rejection contradicts the words next to it. - The selector opens upwards when there is no room below, reported as
data-phone-input-placementon the popover. The packaged view positions nothing, so styling that attribute is what makes it take effect — a field at the bottom of a form is the common case, not the rare one.
Nothing the browser draws itself
Two attributes would hand presentation to the user agent, so neither is used:
requiredis rendered asaria-required="true", not the native attribute. Nativerequiredmakes the browser block submission and show its own tooltip, which no stylesheet can reach and which would appear instead of your message. Enforcement isPhoneRulewithrequiredon the server, and the field still reports itself to assistive technology.- The country filter is
type="text"withrole="searchbox",inputmode="search"andenterkeyhint="search".type="search"gets you the same semantics plus a clear button that WebKit and Blink draw themselves.
For the same reason the packaged view renders no class attribute at all, and
[data-phone-input-caret] and [data-phone-input-check] are empty unless you
fill their slots. The package states that there is a menu to open and
that the number is good; a triangle, a chevron, a tick or nothing at all is your
decision. Nothing is drawn for you, which also means nothing has to be undone.
Requirements
PHP 8.3+, Laravel 12–13, Livewire 4, ext-intl, ext-mbstring.
Laravel 11 is not supported: every 11.x release currently carries an unresolved security advisory, so Composer's audit refuses to install it. Nothing in the package depends on a 12-only API, so if that changes the constraint can widen.