dniccum/phone-number
Simplify phone number handling in Laravel with validation and formatting helpers. This package provides a dedicated validation rule for international numbers, supports country codes, and helps store phone fields consistently across forms, APIs, and user profiles.
Install and pin, in that order.
composer require dniccum/phone-number
php artisan nova:publish
Two things to verify before you write any application code:
composer.json in vendor/. If it pins a Nova major, mirror that pin in your root composer.json (laravel/nova: ^4.0 or ^5.0) so a future composer update can't silently pull a Nova version the field isn't tested against. If it's a loose range, pin it yourself anyway — you want to control when your admin UI changes.vendor/dniccum/phone-number/src/ and read the whole thing (it's short). Confirm use Dniccum\PhoneNumber\PhoneNumber; against the autoload map rather than trusting a README snippet.The spike that must happen before adoption. The materials for this package are contradictory — the description advertises a general-purpose Laravel validation/formatter layer over the Twilio Lookup API (E.164 normalisation, carrier and line-type lookup), while the repository is a Nova input-masking field. Those are different products. Resolve it empirically in staging:
PhoneNumber::make('Phone')->rules('nullable', 'string'),
Put that on a throwaway resource, type (555) 123-4567, save, then dd($model->phone).
(555) 123-4567, the field persists the masked display string. You now have a data-quality problem, and you need a normalisation layer before this touches a real resource.+15551234567, you're in good shape; keep going.dd(app(Dniccum\PhoneNumber\...::class)) or class_exists() check whether the advertised phone()->formatE164() helper and standalone validation rule actually ship. Assume they don't until you see them.First real use case. Take your lowest-traffic internal resource — a Contact, Lead, or Customer holding an incidental phone column — and swap exactly one field. Watch it for a release cycle. The read side is risk-free; all the risk lives on the write side.
Normalise at the model, not in the field. The field is a UI widget; your database invariant is a domain concern. Enforce E.164 at rest with a mutator so every write path is covered — Nova forms, seeders, CSV imports, tinker, API endpoints:
use Illuminate\Database\Eloquent\Casts\Attribute;
protected function phone(): Attribute
{
return Attribute::make(
set: fn (?string $value) => $value
? (new PhoneNumberRule())->normalise($value) // or libphonenumber directly
: null,
);
}
If you normalise only inside the Nova field (via fillUsing or a ->rules() chain), your next CSV import becomes a second source of un-normalised data and the whole exercise is undone. giggsey/libphonenumber-for-php is the workhorse here; treat the field as presentation.
Format for humans at render, never at rest. Index and detail views should be readable; the column should not be:
PhoneNumber::make('Phone')
->displayUsing(fn (?string $value) => $value
? PhoneNumberUtil::getInstance()->format(
PhoneNumberUtil::getInstance()->parse($value, 'US'),
PhoneNumberFormat::NATIONAL
)
: '—'),
Search, dedupe, tel: links, and any SMS provider all want canonical form. Keeping the pretty version in the column breaks all four.
One rule, every surface. The Nova field only guards the admin panel. Form requests, API controllers, imports, and queued jobs need the same definition of "valid", or you'll have three. Write it once:
class PhoneNumberRule implements ValidationRule
{
public function __construct(private ?string $region = 'US') {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
try {
PhoneNumberUtil::getInstance()->parse((string) $value, $this->region ?? 'US');
} catch (NumberParseException) {
$fail('The :attribute is not a valid phone number.');
}
}
}
Use the package's rule in both places only if it genuinely implements Illuminate\Contracts\Validation\ValidationRule and not a Nova-only construct. If it doesn't, write your own and let the field handle presentation only.
Wrap the field in your own subclass. Don't sprinkle PhoneNumber::make(...) across ten resources — you'll want one place to change when the package stops being maintained:
class PhoneField extends PhoneNumber
{
public function __construct($name, $attribute = null, $resolveCallback = null)
{
parent::__construct($name, $attribute, $resolveCallback);
$this->rules('nullable', 'string', new PhoneNumberRule());
}
}
This is also where you centralise any fluent config the field exposes (mask pattern, placeholder, default country) so it's discoverable and greppable.
Backfill as a separate, idempotent job. If you're normalising existing rows, don't do it in a migration that runs inline on deploy. A queued command that re-parses and updates in batches (skipping rows that fail parse, logging them) is repeatable and safe to re-run after you fix a region default.
Persistence ambiguity is the whole risk. Everything else is cosmetic. If the column ends up holding (555) 123-4567, every downstream consumer — SMS/voice, dedupe, CRM sync, analytics, tel: links — inherits a permanent parsing problem. Resolve it with a dd() in staging before you touch production, and write the answer down somewhere a future maintainer will find it.
Mask/validator drift. Client-side masking and server-side validation are two independent implementations of the same rule. When they disagree you either reject valid input (users complain) or accept garbage (you complain). Test the boundaries explicitly: pasted numbers with + prefixes, extensions (x123, ext. 456), leading zeroes, and ten-digit US numbers typed without punctuation. Paste is the single most common failure mode of masked inputs.
International numbers break masks. Masks are country-specific by construction. If your admin ever enters an EU customer or APAC supplier, a US-shaped mask actively obstructs correct entry. Decide your market scope before adoption, not after the first support ticket. If you need multi-region, that's a country-selector-plus-free-text field, not this one.
Extensions get truncated silently. Masked inputs routinely strip or mangle x-suffixes. If your business uses extensions, give them their own column and don't cram them into this field. Nobody notices silent extension loss until a support rep can't reach a customer.
Accessibility is a gate, not a nice-to-have. Test paste, autofill, screen readers, and the mobile numeric keyboard on real devices. If your admin has any accessibility commitment and the field fails, the fallback is a plain text input with server-side validation, plus formatting layered behind a feature flag — not a different masking library.
CSP. Audit the compiled assets for eval() and inline <script> before deploying into a CSP-strict environment. Relaxations get approved in a hurry and tightened never.
Asset staleness masquerades as breakage. "The mask stopped working" after a composer update or Nova upgrade is almost always unpublished assets. Put php artisan nova:publish in the deploy script rather than in your memory. Same for the JS/CSS cache-bust — hard-refresh before you debug anything else.
Nova upgrades will break this quietly. Single maintainer, ~70 stars, zero dependents: there is no ecosystem pressure forcing this package to track the next Nova major. Add a CI job that installs your pinned Nova, publishes assets, and asserts the field renders with the mask active. Do it at adoption, not after the first failure.
Fork or accept the bus factor — decide at adoption. MIT, tiny, fully readable. Forking under your own namespace is a few hours on day one and a much bigger job after ten resources depend on it. If you don't fork, record the risk with a name against it.
Twilio config, if it's real. The description claims Twilio Lookup integration. If the shipped code does call out, then: credentials go in .env and the package config, php artisan config:cache will freeze them, every entry costs money and adds latency to form submits, and a Twilio outage becomes a Nova outage. Check whether the lookup runs on save (blocking) or is optional/best-effort, and make it fail-open — a phone number you couldn't enrich is still a phone number.
Don't store masked strings in searchable columns. Nova's ->searchable() and any LIKE query against a masked column will behave unpredictably. If phone needs to be searchable, search the E.164 column and normalise the query term the same way.
Instrument the column. A monthly audit query grouping by storage format catches silent corruption, truncated extensions, and stripped country codes long before a downstream system does:
SELECT LENGTH(phone) AS len, COUNT(*) FROM contacts GROUP BY len ORDER BY 2 DESC;
If you see a fat row at length 14 with parentheses and dashes, the field won, and you have backfill work to do.
How can I help you explore Laravel packages today?