devloops/phone-number
A Laravel package for validating, formatting, and normalizing international phone numbers. Parse numbers by country, convert to E.164, and integrate cleanly with Laravel validation rules for reliable phone input handling.
The package is a thin Nova field that wraps intl-tel-input so a phone column gets a country selector, live formatting, and a predictable submitted value. It is a small, low-adoption package (8 stars, 0 dependents), so treat the source in vendor/devloops/ as part of your documentation.
Install and wire it up
composer require devloops/phone-number
Check the Nova version constraint in vendor/devloops/phone-number/composer.json before upgrading Nova — a field released in 2025-05 usually supports Nova 4/5, but verify rather than assume. If the package ships front-end assets, publish them with the tag from its README (typically php artisan vendor:publish --tag=... or nova:publish) and commit the compiled output if your deploy pipeline runs composer install --no-dev.
Add the column
Schema::table('contacts', function (Blueprint $table) {
$table->string('phone', 20)->nullable()->after('email');
});
Store E.164 (+447911123456). Twenty characters is enough for E.164 plus a little slack, and it keeps the column useless for arithmetic — which is correct, phone numbers are strings, not integers.
First use case
Add one field to a Contact resource and see what the widget submits:
use Devloops\PhoneNumber\PhoneNumber; // confirm namespace in the README
public function fields(NovaRequest $request)
{
return [
ID::make()->sortable(),
Text::make('Name')->rules('required'),
PhoneNumber::make('Phone')
->defaultCountry('GB')
->rules('required', 'phone:GB'),
];
}
Then create a contact, submit a number, and dd() the raw request payload once. You need to know now whether the field posts E.164, a national string, or a JSON object with country data — every downstream decision (validation, uniqueness, index formatting) depends on it.
If the field does not post E.164 out of the box, normalise immediately (see the next section) before you have data in the wild.
Normalise on fill, never trust the widget
The client-side widget is a UX convenience, not a data contract. Force a canonical format in fillUsing so bulk imports, API writes, and future field swaps all converge:
PhoneNumber::make('Phone')
->rules('required', 'phone:AUTO')
->fillUsing(function ($request, $model, $attribute, $requestAttribute) {
$value = $request->input($requestAttribute);
$model->{$attribute} = filled($value)
? phone($value)->formatE164()
: null;
});
This pairs naturally with propaganistas/laravel-phone for both the phone validation rule and the phone() helper. If you would rather not add that dependency, at minimum strip whitespace and reject anything that does not start with + after normalisation.
Show the pretty version, store the ugly one
Index and detail views should not display +447911123456:
PhoneNumber::make('Phone')
->displayUsing(fn ($value) => blank($value)
? '—'
: phone($value)->formatInternational())
->textAlign('left'),
Nova's displayUsing affects the index and detail views while the form still gets the interactive widget. Test both: some field packages apply displayUsing to the form input too, which then feeds a formatted string back into the widget on edit and produces a double-formatted value.
Constrain the country list per tenant or locale
If your app is single-market, lock the selector down to avoid a support burden of numbers entered under the wrong country code:
PhoneNumber::make('Phone')
->onlyCountries(['GB', 'IE'])
->defaultCountry('GB'),
The fluent setters map onto intl-tel-input options (defaultCountry, onlyCountries, preferredCountries, separateDialCode), so anything you know from that library transfers. Where the package has no setter, ->withMeta([...]) is the usual escape hatch — check the Vue component's props first to confirm the key name.
Factor a reusable field factory
Phone fields appear on users, contacts, leads, and organisations. Wrap the config once so validation and formatting cannot drift:
// app/Nova/Fields/PhoneField.php
class PhoneField extends \Devloops\PhoneNumber\PhoneNumber
{
public function __construct($name, $attribute = null, $resolveCallback = null)
{
parent::__construct($name, $attribute ?? 'phone', $resolveCallback);
$this->defaultCountry(config('app.phone_country', 'GB'))
->rules('nullable', 'phone:AUTO')
->fillUsing(fn ($request, $model, $attribute, $requestAttribute) =>
$model->{$attribute} = filled($request->input($requestAttribute))
? phone($request->input($requestAttribute))->formatE164()
: null
);
}
}
Subclassing rather than a trait is important here: when this package goes unmaintained (few stars, minimal release cadence), you replace one parent class instead of hunting through a dozen resources.
Validation order matters for uniqueness
unique:users,phone runs against whatever is in the database. If you normalise in fillUsing, normalisation happens after validation, so +44 7911 123456 and +447911123456 both pass uniqueness and you get duplicates. Fix it with prepareForValidation on the FormRequest, or by normalising in a Nova NovaRequest macro / ->rules() closure that compares against the E.164 form:
->rules('required', 'phone:AUTO', Rule::unique('users', 'phone')->ignore($request->resourceId))
...which only works if the value is already E.164 at validation time. Add a NovaRequest prepareForValidation or a model mutator (setPhoneAttribute) so normalisation happens exactly once, as early as possible, and fillUsing becomes a redundant safety net rather than the only guard.
Search and sort
Phone columns are searchable via Nova's default searchable() only if the stored format matches what a user types. Since E.164 has no spaces, searchable() will fail for 07911 123456. Either add a computed/searchable index on a normalised column or accept that phone search is exact-match on +44.... Document this for whoever writes the support runbook.
public/vendor/ (or resources/js/ for Nova 4+ custom tooling), re-run the publish command, and look at the browser console for a 404.utils.js bundle from a CDN. If you run a strict Content-Security-Policy, self-host the assets and point the field at them, or the dropdown will render without flags and getNumber() will throw.07911123456, (07911) 123456, or an empty string. The widget tries to infer a country from the value; national-format numbers with no + will be guessed as the default country, which is silently wrong for imported international data. Write a one-off normalisation command and dry-run it before shipping the field.'' versus null. A cleared field often submits an empty string. phone('') returns null but blank('') is true — be consistent, and add ->nullable() to the column so unique indexes and whereNotNull behave.phone rule; otherwise a crafted POST bypasses everything.resources/css and register it via Nova::style() in your NovaServiceProvider; there is no theming hook in the package.document.querySelector('[data-phone-number]') (or inspect the input's class) in the console, and check window.intlTelInputGlobals — if the instance is missing, the field's mounted() never ran, which almost always means an asset or registration problem rather than a data problem.withMeta payloads. When you need behaviour the PHP class does not expose (custom placeholder, separate dial code display, stricter formatting), push meta from PHP and read it in a locally vendored copy of the component. Copy the package's resources/js into your app rather than patching vendor/."devloops/phone-number": "1.0.*" in composer.json (rather than ^1.0) plus the subclass wrapper above gives you a cheap upgrade path — and a cheap exit if the project stalls.How can I help you explore Laravel packages today?