leandrocfe/filament-ptbr-form-fields
Brazilian-ready form fields for Filament: CPF/CNPJ, CEP, phone, and currency inputs with masks, validation, and PT-BR labels. Drop-in Laravel components to speed up admin panels and forms for Brazil.
leandrocfe/filament-ptbr-form-fields is a small Filament plugin that swaps generic TextInputs for pt-BR–aware fields (CPF, CNPJ, CPF/CNPJ, CEP, phone, currency/BRL) with input masks and validation baked in. It is a convenience layer, not a full localization package.
composer require leandrocfe/filament-ptbr-form-fields
No panel registration or config publish is required — the components are ordinary Filament form fields resolved by class name. First place to look is the README's field table (class names + supported chainable methods), then the src/Filament/Forms/Components directory if a method is undocumented.
First use case — replace a plain document input in a Resource form:
use Leandrocfe\FilamentPtbrFormFields\Filament\Forms\Components\CpfCnpj;
CpfCnpj::make('document')
->label('CPF/CNPJ')
->required()
->unique(ignoreRecord: true),
Then run the form once in the browser, submit an invalid and a valid document, and inspect the Livewire payload to see what actually lands in $state — that single check answers most of the questions you'll have later.
Version note for 5.0.1: this is a patch release (dependency bumps plus removal of the illuminate/contracts constraint and added Laravel 13.x compatibility). It is a drop-in upgrade from 5.0.0; the 5.x line targets Filament 4. If you are still on Filament 3, stay on the 4.x package line.
Normalize on dehydrate, mask on display. The mask lives in the UI; your database should store digits only. Do the conversion at the field boundary so models, policies, and API resources never see punctuation:
Cpf::make('cpf')
->required()
->dehydrateStateUsing(fn (?string $state) => preg_replace('/\D/', '', (string) $state))
->rule('digits:11');
Validation belongs on the unmasked value. Filament runs rules against the dehydrated state in most flows, but if you also validate on the client/Livewire side, prefer digits: or a dedicated Rule object over regex patterns written for the masked format. If you need both, add a ->rule() for the raw digits and let the component handle formatting.
CEP autofill is a two-field pattern, not a package feature. Wire the lookup yourself with afterStateUpdated, keeping it debounced and guarded:
Cep::make('zip')
->live(onBlur: true)
->afterStateUpdated(function (?string $state, Set $set) {
$digits = preg_replace('/\D/', '', (string) $state);
if (strlen($digits) !== 8) return;
$data = Cache::remember("cep:$digits", now()->addDay(),
fn () => Http::timeout(3)->get("https://viacep.com.br/ws/{$digits}/json/")->json());
if (empty($data) || ($data['erro'] ?? false)) return;
$set('street', $data['logradouro']);
$set('district', $data['bairro']);
$set('city', $data['localidade']);
$set('state', $data['uf']);
});
Currency: store cents, cast on read. Treat the currency field as a formatted string in the form and an integer (or decimal:2) in the database:
Currency::make('price')
->dehydrateStateUsing(fn ($state) => (int) round(
(float) str_replace(['.', ','], ['', '.'], (string) $state) * 100
));
and in the model: protected $casts = ['price' => 'integer']; with an accessor if you need R$ 1.234,56 in the UI.
Wrap for house style. If several resources share the same CPF setup (label, uniqueness, normalization, helper text), subclass the package component once and reuse it. That also gives you a single place to change when the package bumps a major.
Testing. Use Filament's form test helpers, filling with the unmasked value and asserting on the persisted attribute:
livewire(CreateClient::class)
->fillForm(['document' => '12345678909'])
->call('create')
->assertHasNoFormErrors();
$this->assertDatabaseHas('clients', ['document' => '12345678909']);
If a test fails only when a masked value is filled, that's the mask/validation mismatch described below — fix the field, not the test.
cpf stored as 123.456.789-09 and then unique() or digits: failing on unrelated records. Always pair the field with dehydrateStateUsing stripping non-digits, and apply the same stripping in importers/seeders so both paths agree.unique() on a masked column. ->unique() compares what the field produces. Dehydrate first, or the rule will query for the punctuated string.'', not null. With ->nullable(), a cleared masked field can still dehydrate to an empty string, breaking nullable DB columns and unique semantics. Normalize with fn ($state) => filled($state) ? $state : null.Rule with the check-digit algorithm).afterStateUpdated will hit the network in Livewire tests. Wrap in Cache::remember, guard with Http::timeout, and use Http::fake() in tests.float and store it; round-trip through cents as integers.illuminate/contracts constraint means fewer version conflicts, but if you pinned that package for other reasons, re-run composer update --dry-run to confirm the resolution before deploying. Laravel 13.x support is claimed here — verify against your own laravel/framework constraint rather than assuming.dd($state) inside dehydrateStateUsing and inside afterStateUpdated: the value at those two points is usually different, and knowing which one your validation sees resolves most confusion.How can I help you explore Laravel packages today?