Going Full Native: How to Extend Filament v5's Register Page
For Developers Product Thinking Engineering

Going Full Native: How to Extend Filament v5's Register Page

10 min read

Filament v5 ships a native register page. Some plugins ignore it and roll their own controller. Here's how we added captcha, honeypot, role assignment, and event bridging — entirely through Filament's documented hooks, without touching `register()` itself.

Filament is one of the best backend frameworks for Laravel. It builds admin panels fast, looks great out of the box, and is deeply extensible. But most non-trivial projects don't stop at the backend. If you're building a SaaS, a web app with a public-facing portion, or anything with real users signing up, you eventually need authentication that lives outside the admin shell.

A common answer is to reach for one of Laravel's official starter kits. The modern lineup — React, Vue, and Livewire flavours, all with auth scaffolding baked in — is the path most teams take today, and earlier kits like Breeze and Jetstream from the Laravel 10.x era are still maintained and widely deployed. They're battle-tested, beautifully documented, and a great fit for many projects: they handle public-facing auth completely while letting Filament focus on the admin side. For plenty of teams that separation is exactly what you want, and these kits absolutely deserve their place in the Laravel ecosystem.

That said, if you'd rather harden your authentication and roll your own using Filament, you don't need to reach outside the framework — Filament v5 already has everything you need to extend. It ships a complete auth flow — \Filament\Auth\Pages\Login, \Filament\Auth\Pages\Register, email verification, password reset, and multi-factor authentication — and these aren't just admin-panel utilities. They're real, themable, extensible pages you can put in front of your users.

The catch: the register page in particular needs work before it's production-ready. Out of the box there's no captcha, no honeypot, no role assignment, no Laravel-event bridge for listeners that other parts of your app already depend on. Most plugins solve this by reaching for a custom Laravel controller, a Blade form, and manual validation — abandoning Filament's auth flow entirely.

We took the other approach while building tallcms/filament-registration(Link). Every production feature — captcha, honeypot, rate limiting, default role assignment, post-registration redirect — landed inside Filament's documented extension points. We never overrode register(). These patterns apply to any Filament v5 project that needs a public register page without leaving the framework.

The Core Idea: Two Hooks, Not One Override

Filament's Register page exposes two extension points :

  • mutateFormDataBeforeRegister(array $data): array — runs after validation but before User::create(). The place for anything that should gate registration.

  • handleRegistration(array $data): Model — wraps user creation. The place for anything that should run after the user exists.

The temptation, especially if you're porting from a legacy controller, is to override register() and rebuild everything inside one method. Don't. Filament's register() already orchestrates throttling, validation, email verification, response dispatch, and event firing. You won't get all those right by rewriting it. You don't need to.

class Register extends \Filament\Auth\Pages\Register
{
    public function form(Schema $schema): Schema
    {
        return $schema->components([
            $this->getNameFormComponent(),
            $this->getEmailFormComponent(),
            $this->getPasswordFormComponent(),
            $this->getPasswordConfirmationFormComponent(),
            HoneypotField::make(),
            CaptchaField::make(),
        ]);
    }

    protected function mutateFormDataBeforeRegister(array $data): array
    {
        $this->checkHoneypot($data);
        $this->throttleCaptcha();
        $this->verifyCaptcha($data);

        unset($data[$this->honeypotField], $data[$this->tokenField]);
        return $data;
    }

    protected function handleRegistration(array $data): Model
    {
        $user = parent::handleRegistration($data);

        $this->maybeMarkEmailVerified($user);
        $this->maybeAssignDefaultRole($user);
        event(new \Illuminate\Auth\Events\Registered($user));

        return $user;
    }
}

That's the entire shape. Everything below is what goes inside those methods.

Layer 1: Honeypot via Validation, Not Stealth

The legacy approach to honeypots is to silently render a fake success page when the field is filled, hoping the bot logs a false positive. It works, until it doesn't — modern bots roll their own success-page detectors.

A cleaner approach inside Filament: throw a ValidationException. Filament catches Laravel's validation exceptions automatically and surfaces the message on the form. Bots that don't fill the honeypot get through validation; bots that do see a generic "Bot check failed" message attached to the honeypot field.

if (! empty($data[$this->honeypotField])) {
    throw ValidationException::withMessages([
        $this->honeypotField => 'Bot check failed. Please try again.',
    ]);
}

The trade-off is real: a determined attacker now knows the honeypot exists. But spending the effort to detect honeypots usually means moving to a different target — and the validation message is generic enough to look like any other rejection.

Layer 2: Rate-Limit Before You Verify

Some Captcha vendors charge per verification. Without a pre-captcha throttle, a bot hammering /register is hammering your captcha vendor too, on your dime.

Wrap captcha verify with a per-IP rate limit before the verify call:

$key = 'captcha:'.request()->ip();

if (RateLimiter::tooManyAttempts($key, 30)) {
    throw ValidationException::withMessages([
        $this->tokenField => 'Too many attempts. Please wait and try again.',
    ]);
}

RateLimiter::hit($key, 60);

30 attempts per IP per minute is generous for humans, restrictive for bots. The vendor throttle catches the rest. Filament's own throttle (2 attempts per minute, 2 per email per minute) sits after this and protects the user-creation path.

Layer 3: Pluggable Captcha via a Contract

Hard-coding Turnstile or reCAPTCHA into the page is the same mistake as hard-coding the form. Define a contract:

interface CaptchaProvider
{
    public function tokenField(): string;
    public function renderSnippet(): string;
    public function verify(string $token, ?string $ip = null): bool;
}

Three implementations ship by default in our Registration Plugin — NullCaptchaProvider (no-op for dev/tests), TurnstileCaptchaProvider, RecaptchaV3CaptchaProvider. A CaptchaManager resolves the active provider based on config, and the form's captcha field renders whatever the provider returns:

class CaptchaField extends Field
{
    protected function setUp(): void
    {
        parent::setUp();

        $this->afterStateHydrated(function (Field $component): void {
            $provider = app(CaptchaProvider::class);
            $component->label('Verification')->hiddenLabel();
            $component->view = 'filament-registration::captcha-field';
            $component->viewData(['snippet' => $provider->renderSnippet()]);
        });
    }
}

Adding a new captcha vendor is implementing the contract and registering a binding — no edits to the page, no edits to the form. The contract pattern is worth it the moment you have more than one provider, and pays off forever.

A note on labels: Filament auto-derives a field label from the field name, which means a hidden token field called cf-turnstile-response shows up in the UI as "Cf turnstile response" above the widget. ->label('Verification')->hiddenLabel() sets a screen-reader-friendly identity and hides the visible label, since the captcha widget speaks for itself.

Layer 4: Two-Layer Config (Env + Admin UI)

Captcha keys are the canonical example of a setting that lives in two places: developers want them in .env (alongside other secrets, version-controlled in .env.example), but admins want a settings page where they can rotate keys without SSH access.

The pattern: env vars provide the default, an admin-managed DB row overrides.

public function register(): void
{
    $this->mergeConfigFrom(
        __DIR__.'/../../config/filament-registration.php',
        'filament-registration'
    );
}

public function boot(): void
{
    $this->mergeDbSettingsIntoConfig();
}

private function mergeDbSettingsIntoConfig(): void
{
    foreach ($map as $dbKey => $configKey) {
        $value = $stored[$dbKey] ?? null;

        if ($value === null || $value === '') {
            continue;  // empty DB row → env wins
        }

        config([$configKey => $value]);
    }
}

Two non-obvious details that took us a production bug each to learn:

1. env() calls must live in a config file, not the service provider. Calling env('FOO') directly in register() works in development. On production with php artisan config:cache, Laravel skips .env loading on subsequent requests for performance — and env() returns null. The fix is mergeConfigFrom() pointing at a config file with env() calls inside; those values are resolved at cache time and baked into bootstrap/cache/config.php.

2. Empty DB values must skip, not override. If an admin opens the settings form, types only the secret key, and presses Save, the site_key row gets stored as an empty string. Without the === '' guard, that empty string clobbers a perfectly valid FILAMENT_REGISTRATION_CAPTCHA_SITE_KEY env var, and the captcha manager falls back to the null provider. Booleans (including false), numbers (including 0), and non-empty strings all still override env — empty/null is the one signal we treat as "not set; let env win."

Layer 5: Container-Bound Redirects

Where to send a user after registration is host-specific. A SaaS app might redirect to onboarding. A community site might redirect to a profile-completion form. A simple app might just go to the dashboard.

Filament already exposes a contract for this: \Filament\Auth\Http\Responses\Contracts\RegistrationResponse. Bind a default in your service provider, and let hosts override:

$this->app->bind(
    \Filament\Auth\Http\Responses\Contracts\RegistrationResponse::class,
    RegistrationResponse::class
);

Hosts swap in their own response by binding a different concrete to the same contract in their own service provider:

app()->bind(
    \Filament\Auth\Http\Responses\Contracts\RegistrationResponse::class,
    OnboardingRegistrationResponse::class
);

No plugin callback API, no chainable ->afterRegister(fn ($user) => ...) — just Filament's contract, used as Filament intends. The plugin API stays small; the host gets full control.

One gotcha: return a \Symfony\Component\HttpFoundation\RedirectResponse directly, not the result of redirect()->intended(). Inside Livewire, redirect() returns a Livewire\Features\SupportRedirects\Redirector wrapper, which doesn't satisfy Filament's return type contract. Build the response with new RedirectResponse(...) and you sidestep the wrapper.

What We Deliberately Didn't Do

  • Didn't override register(). Filament's orchestration of throttling, validation, email verification, and response dispatch is the part that's hard to get right. We extended it via the two hooks Filament documents for exactly this purpose.

  • Didn't ship a custom Blade form. The whole point of Filament v5's schema API is that forms are composable. Adding a captcha field is CaptchaField::make(), not a Blade partial.

  • Didn't add a plugin callback API. No ->afterRegister(), no ->onUserCreated(). Hosts customize via Laravel container bindings (for the redirect) and Laravel events (for post-create work). The plugin API has exactly one chainable method: ->defaultRole('site_owner').

  • Didn't build email verification. Filament's ->emailVerification() panel method already does it. The plugin's only contribution is short-circuiting the verification email when the host panel doesn't require verification.

Key Takeaways

If you're hardening a Filament v5 register page:

  1. Extend \Filament\Auth\Pages\Register via mutateFormDataBeforeRegister() and handleRegistration(). Never override register() itself.

  2. Throw ValidationException::withMessages([...]) from the hooks. Filament catches and surfaces these on the form for free.

  3. Put env() calls in a config file, not the service provider. Otherwise config:cache silently nulls them on production.

  4. Use Filament's container contracts, especially RegistrationResponse. Don't invent parallel APIs.

  5. Make role and permission packages optional dependencies. Check class_exists() at runtime; suggest in composer.json.

  6. Bridge Laravel's Registered event alongside Filament's, so existing listeners keep working.

  7. Guard migrations with Schema::hasTable() if your plugin can be installed fresh, upgraded, or reinstalled.

Filament v5's auth pages are extension points, in which we can build layers instead of replacements. You can download this free plugin via https://github.com/tallcms/filament-registration


Have questions or want to share how you've extended Filament's auth pages? Drop a comment — I'd love to see what others are building.

Comments

No comments yet. Be the first to share your thoughts!

Choose Theme