greg0x46/masked-field
Masked Field is a Laravel package for adding input masks to forms, making it easy to format and validate values such as phone numbers, dates, and documents. It offers reusable mask definitions and a clean, framework-friendly API for consistent user input.
Install the package and confirm it registers its Nova assets:
composer require greg0x46/masked-field
php artisan nova:publish # only if the field ships publishable JS/CSS
Pin to ^1.2 — that's the first release with Nova 5 support (v1.2.0, via PR #3). If you're still on Nova 4, ^1.1 also works, but there's no reason to stay behind.
Before writing any field code, open vendor/greg0x46/masked-field/ and read the two or three files that matter: the field class, its dist/js/field.js, and composer.json. With a package at this scale the source is the documentation. Pay particular attention to the new raw option added in v1.2.0 — it's the single most consequential behavior change and the README may only mention it in passing.
The first use case to wire up is a single text-backed column you can afford to experiment on — a phone number, postal code, or tax ID:
use Greg0x46\MaskedField\Masked; // verify namespace in src/
public function fields(NovaRequest $request)
{
return [
Masked::make('Phone')
->mask('(999) 999-9999')
->raw() // v1.2.0+: persist unmasked — confirm exact fluent name in src/
->rules('required'),
];
}
Then verify, in order:
(555) 123-4567) or the raw value (5551234567). With v1.2.0 the raw option makes this explicit, but read the JS to confirm what "raw" strips: mask characters only, or the separator characters too. "5551234567" and "555-1234" are very different column widths.Do step 2 with dd() or a tinker query before you build anything else on top of the field. Validation rules, search, unique constraints, and API serialization all depend on which form is persisted.
Use raw as your default, and treat the field as a form field only. The v1.2.0 raw option lets you store normalized digits while displaying the mask — that's the combination most applications want. It also means the package masks input; it still does not normalize your database. Establish a convention per column and enforce it in one place (a model mutator or an Attribute cast), not in each resource, because the Nova field is only one of several write paths.
Centralize mask definitions. Masks are magic strings that will drift between resources and front-end components. Put them on a small helper or config:
// config/masks.php
return [
'phone' => '(999) 999-9999',
'zip' => '99999-999',
'vat' => 'AA 999 999 999',
];
Then Masked::make('Phone')->mask(config('masks.phone'))->raw().
Pair every mask with a matching server-side rule against the stored shape. The JS mask is a UX affordance, not validation. With ->raw() the rule now targets the normalized form:
->rules('required', 'regex:/^\d{10}$/')
If instead you omit raw and store the masked string, your rule must match ^\(\d{3}\) \d{3}-\d{4}$. Pick one per column and be consistent — mixed conventions across resources are where this gets painful.
Uniqueness is simpler with raw. unique:users,phone compares whatever the field submits. With raw on, the request and the column agree, so the rule works without a prepareForValidation detour. If you're not using raw, you still need to normalize in a FormRequest first — otherwise the rule checks the masked string against an unmasked column and silently passes.
Compose with Nova's native fields for read-only contexts. For index tables where you want sortable, searchable columns, keep the underlying column as a plain Text and let the masked field own only the form view. With raw storage, display formatting is just Text::make(...)->displayUsing(fn ($v) => format_phone($v)).
Restrict scope for sensitive fields. Masked input shows the full value while typing, and a masked field on the index leaks it in the table. Keep it form-only:
Masked::make('SSN')->mask('999-99-9999')->raw()->onlyOnForms()->hideFromIndex(),
Testing. Assert on the database, not the field object — that's the boundary the package actually influences:
it('stores phone digits only', function () {
$user = User::factory()->create(['phone' => '(555) 123-4567']);
expect($user->fresh()->phone)->toBe('5551234567');
});
Add a second test that posts through the Nova field (or a FormRequest mirroring its payload) so you catch a regression in the raw behavior on upgrade.
Confirm exactly what raw strips. This replaces the old "which side of the wire is masked?" unknown with a narrower one. Read the Vue component in dist/js/ and look at handleChange/fill: does raw remove only mask placeholder characters ((, ), space, -) or does it also drop user-typed separators? 555-1234 and 5551234 differ in length, and a varchar(10) column will truncate the former with an error that looks like a validation bug. Test with a hyphenated paste specifically.
raw only covers the Nova field. Every other write path — API endpoints, CSV imports, tinker, seeders, mass-assignment from jobs — bypasses it entirely. If you rely on the field to normalize, you have a latent bug. Put the normalization in an Attribute cast or mutator so it applies everywhere, and let raw be a redundant convenience.
Asset publishing and Nova version compatibility. v1.2.0 explicitly targets Nova 5, but the tooling differences between Nova 4 and 5 (Vite/Tool build, Nova::script() registration) are exactly where small packages break. If the field renders as a plain text input, or the mask works locally but not in production after nova:publish, it's almost always a compiled-asset path mismatch. Test in a throwaway resource on a staging deploy before rolling it across resources.
Search and sort operate on the raw column. With raw this is now consistent — good — but ->searchable() still sends the typed term to SQL verbatim. Searching for (555 will never match 5551234567. Either document the raw search format for your users or add a ->searchable() override with a whereRaw that strips formatting from the input.
Livewire re-renders can fight the mask. Nova 4+ is Livewire-driven; when a field re-renders (dependency container toggling, validation error), the JS mask may not reattach to the re-initialized DOM node. If a mask silently disappears after a validation failure, that's the cause — the fix is usually a watch/mounted hook in the field component, i.e. patching the vendored code. The Nova 5 work in v1.2.0 may have addressed this; verify rather than assume.
Don't rely on the mask for security or PII compliance. A masked input shows the full value to the user while typing, and raw makes no difference here. If the goal is to conceal an SSN or card number from the operator, use Text::make(...)->displayUsing(fn () => '•••• 1234') on read and a separate confirmation flow for writes.
Version pin deliberately, and reassess the bus factor. v1.2.0 brings the first outside contributor, which is a genuine improvement over the previous single-maintainer picture — but it's still a handful of stars and one release. Pin to ^1.2, and if you depend on it in more than a couple of resources, vendor the field class into app/Nova/Fields/. It's small enough to fork, and forking now is cheaper than discovering a Nova 5.5 breakage on your own.
Where to extend. The field class is your extension point. Add fluent setters for project-specific behavior (e.g. ->placeholder('(555) 123-4567'), ->stripOnSave()) rather than subclassing the Vue component; the PHP→Vue prop bridge (->withMeta([...])) is stable across Nova versions, the compiled JS is not. If you fork, keep your raw handling in the PHP class and let the JS stay upstream-shaped so future merges stay cheap.
How can I help you explore Laravel packages today?