turbo-php/livewire-phone-input

A headless, publishable Livewire phone number input: country selector, national number field, and inline libphonenumber validation.

Maintainers

Package info

github.com/turbophp/livewire-phone-input

pkg:composer/turbo-php/livewire-phone-input

Transparency log

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.1 2026-08-21 11:17 UTC

This package is not auto-updated.

Last update: 2026-08-21 14:19:40 UTC


README

Latest version Tests Downloads License

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 null or 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 123456 with 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_country is 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_countries if that is more DOM than you want.
  • Escape closes the selector, arrow keys move through it, Home and End jump to the ends, and Enter in the search box takes the first match.
  • A valid number is confirmed, a rejected one is not. The root carries data-phone-input-valid and an empty [data-phone-input-check] appears while the field holds a good number. An error from 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-placement on 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:

  • required is rendered as aria-required="true", not the native attribute. Native required makes the browser block submission and show its own tooltip, which no stylesheet can reach and which would appear instead of your message. Enforcement is PhoneRule with required on the server, and the field still reports itself to assistive technology.
  • The country filter is type="text" with role="searchbox", inputmode="search" and enterkeyhint="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.