murillo-cardoso-by-hasan-ahani/filament-otp-input
A Filament form component for entering one-time passwords and PIN codes with separate digit boxes. Add a clean, customizable OTP input field to admin panels and forms, with smooth keyboard navigation and paste support.
Minimal steps
composer require murillo-cardoso-by-hasan-ahani/filament-otp-input
PanelProvider; Filament auto-discovers form field classes and assets via the package's service provider. If a FilamentOtpInputServiceProvider is present, verify it extends the package's own provider and that filament:assets was run after install.php artisan filament:assets
php artisan vendor:publish --tag=filament-otp-input-assets # only if the package ships publishable assets
Where to look first
Given the 0-star / 0.1 score and a repo name (Unesc-PMP/filament-otp-input-component) that doesn't match the Packagist vendor, treat the source as the spec. Before writing any code:
src/ — find the field class (likely OtpInput.php or similar) and read its public setters. That is your real API; the README may be stale or absent.resources/views/ — the Blade template shows exactly how state is bound and what JS is embedded.composer.json — check the require block for the Filament constraint (^3.0 vs ^4.0) and PHP version. This is the single most likely source of install failures.CHANGELOG.md / tags — last release was 2024-11-12, so align with the Filament major current at that time (v3).First use case
The canonical integration is a verification form: a second step after login/registration where the user types a 6-digit code. In a Filament Login/Registration page, add a custom field to the form schema and validate it against a stored code:
use MurilloCardoso\FilamentOtpInput\Forms\Components\OtpInput;
public function form(Form $form): Form
{
return $form->schema([
OtpInput::make('code')
->length(6)
->numeric()
->required()
->rule('digits:6'),
]);
}
Confirm the actual namespace and method names against src/ before committing — the vendor path in the class name will differ from the repo owner.
1. Verification-step pattern (most common)
Keep the OTP field on a separate Filament page or form step rather than on the credentials form. This keeps state small and makes auto-submit sane:
TextInput::make('phone')->tel()->required(),
OtpInput::make('code')
->length(6)
->required()
->afterStateUpdated(fn ($state, Set $set) => strlen((string) $state) === 6
? null // trigger your custom action
: null),
Because Filament fields are Livewire-bound, every keystroke round-trips unless the package debounces. Prefer wire:model.debounce defaults already in the component, and only react when length is complete.
2. Storing the code
Do not add the OTP to the same $this->data array as the rest of the form schema if that array is later mass-assigned to a Model. Store it in a separate attribute:
protected array $data = [];
public string $otpCode = '';
…and map $this->otpCode into the field with ->statePath('otpCode') (or pass ->livewireModel() if the package exposes it). This avoids leaking verification codes into create()/update().
3. Validation and expiry
Treat the OTP field like any other Filament field — attach Laravel rules, not package-specific ones:
OtpInput::make('code')
->required()
->rule('digits:6')
->rule(fn () => function (string $attribute, $value, Closure $fail) {
if (! Hash::check($value, $this->user->otp_hash)) {
$fail('The code is invalid.');
}
if ($this->user->otp_expires_at->isPast()) {
$fail('The code has expired.');
}
});
Keep rate limiting and attempt counting in a dedicated action/resend button rather than in validation — Filament re-runs validation on every Livewire update.
4. Resend / countdown workflow
Pair the field with a Filament\Forms\Components\Actions\Action (or a custom Action on the form) to resend the code. A Cache::put("otp:resend:{$user->id}", true, 60) guard combined with a polling Placeholder showing remaining seconds is the standard, dependency-free approach:
Action::make('resend')
->requiresConfirmation()
->visible(fn () => ! Cache::has("otp:resend:{$this->user->id}"))
->action(fn () => $this->sendOtp());
5. Integration with existing notification flows
The OTP component is input-only — it does not generate or send codes. Reuse your existing User::sendOtp() (Fortify, Jetstream, or a custom Notification) and have the Filament page call it. This keeps the package swappable: if you later replace it with a plain TextInput, the backend doesn't change.
6. Testing
Livewire component tests still work; the field is just HTML inputs bound to wire:model:
Livewire::test(VerifyPhone::class)
->fillForm(['code' => '123456'])
->call('verify')
->assertHasNoFormErrors();
If the package splits the code into per-digit inputs (likely), fillForm with a single string still works because the field's getState() reassembles it — but assert on the resulting state to be sure, since a per-digit implementation may require ['1','2','3','4','5','6'].
Package maturity risk is the headline issue. Zero stars, zero dependents, one release in Nov 2024, and a repo owner (Unesc-PMP) that doesn't match the Packagist vendor namespace (murillo-cardoso-by-hasan-ahani) are all red flags. Pin the exact version ("murillo-...": "0.1.*"), commit the lockfile, and read the field class before designing around it. Budget a fork option — the component is small enough that copying src/ and resources/ into app/Filament/Forms/Components/ is viable and removes the dependency entirely.
Filament major mismatch. A November 2024 release targets Filament v3. Filament v4 renamed several field internals (Field lifecycle, getState/setState signatures, asset registration). If you're on v4, expect a Class not found or a silently non-rendering field. Check composer.json's filament/support constraint before anything else.
State shape is the #1 debugging trap. OTP widgets usually render N separate <input maxlength="1"> elements. Depending on the implementation the Livewire property is either:
"123456", or["1","2","3","4","5","6"].If validation fails with "must be 6 digits" but the field visually looks full, dd($this->data['code']) first. Cast/normalize in ->dehydrateStateUsing(fn ($state) => is_array($state) ? implode('', $state) : $state) or the reverse, and keep the normalization in one place.
Paste is not free. Per-input OTP widgets often break on paste (browser fills only the first box) and on mobile numeric keyboards (inputmode="numeric" must be set — the package may not do this). Test on a real iOS/Android keyboard; a one-line JS inputmode and paste handler is a common local patch.
Auto-submit fires too eagerly. If the package auto-triggers on full length and you also have a "Verify" button, you get double submissions. Guard the action with a $this->verifying flag and RateLimiter::attempt() so a stuck network retry doesn't burn through your 5-attempt limit.
Asset registration silently fails. If the field renders as a plain text input with no styling or JS behavior, run php artisan filament:assets and hard-refresh. In production, php artisan filament:cache-components + view:cache will not pick up published assets unless they were published at build time — include filament:assets in your deploy script.
afterStateUpdated runs per keystroke. Anything you hook there (DB lookup, notification, hash check) executes up to six times per code. Debounce via the field's ->debounce(300) if available, or move the logic to a submit action.
Extension points are thin. With no documented API, your safest extension is the published Blade view: php artisan vendor:publish --tag=filament-otp-input-views (tag name likely *-views; check the service provider's publishes() call). Once the view is in resources/views/vendor/..., you can add inputmode, autocomplete=one-time-code, and paste handling without forking the PHP class — this survives minor package updates.
Accessibility and autofill. autocomplete="one-time-code" is what triggers iOS/Android SMS autofill. If the packaged view lacks it, add it via the published view. Screen readers also need a single aria-label on the wrapper group, not six unlabeled boxes.
Fallback plan. If the package proves unmaintained mid-project, swap it for a plain TextInput::make('code')->length(6)->tel()->autocomplete('one-time-code') plus a small Alpine mask in a custom Blade view. The migration cost is one form schema line — keep your OTP generation/verification logic entirely outside the component so this swap stays trivial.
How can I help you explore Laravel packages today?