Fortify Integration
PandaBear does not implement signing in. It renders auth screens — a login, a registration form, a password reset, an email verification notice — inside a panel's own brand and at the panel's own path, and posts every one of them to Laravel Fortify, which the package requires as a dependency. You reach for this page when a panel needs a front door of its own, or when you need to know which half of the stack owns a given behaviour.
A minimal working example
<?php
declare(strict_types=1);
namespace App\Panels\Admin;
use PandaPanel\Core\Panel;
use PandaPanel\Core\PanelProvider;
final class AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->path('admin')
->auth()
->login()
->passwordReset();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
php artisan route:list --name=panel.admin.authGET admin/login panel.admin.auth.login
GET admin/forgot-password panel.admin.auth.password.request
GET admin/reset-password/{token} panel.admin.auth.password.reset2
3
A guest opening /admin now lands on /admin/login, sees the panel's brand, and submits to Fortify's login.store.
Why the forms are Fortify's
Duplicating the login POST per panel would mean duplicating rate limiting, two-factor, passkeys, and session fixation handling — four things that must never disagree between two doors into the same application. So the panel owns the presentation and nothing else:
| Concern | Owner |
|---|---|
| The login/register/reset/verify pages | The panel (PandaPanel\Http\Controllers\PanelAuthController) |
| The login/register/reset/verify POSTs | Fortify |
| Throttling, session regeneration, remember-me | Fortify |
| TOTP two-factor, recovery codes | Fortify |
| Passkey registration and login | Fortify (laravel/passkeys) |
| What a new user is made of | Your Fortify::createUsersUsing() action |
| The emailed-code second factor | The panel (PandaPanel\Auth\EmailCodeChallenge) |
| Whether this user may enter the panel | The panel (canAccess() and PanelUser) |
The one authentication concern the package implements itself is the emailed code, and it is deliberately not part of the login POST — see Email Code Challenge.
Requiring a signed-in user
auth() is the shorthand. It appends to the panel's auth middleware, which is merged after the base stack:
public function auth(bool $verified = true): self$panel->auth(); // ['auth', 'verified'] appended
$panel->auth(verified: false); // ['auth'] appended2
The two stacks are separate because the panel's guest pages need one and not the other:
| Method | Signature | Default | Notes |
|---|---|---|---|
middleware | middleware(array $middleware): self | ['web'] | Replaces the base stack. |
authMiddleware | authMiddleware(array $middleware): self | ['auth'] | Replaces the auth stack. Pass [] only for a deliberately public panel. auth() merges into it. |
getBaseMiddleware | getBaseMiddleware(): array | — | The base stack alone. What the login page runs. |
getAuthMiddleware | getAuthMiddleware(): array | — | The auth stack alone. |
getMiddleware | getMiddleware(): array | — | Both, deduplicated, in that order. |
use App\Http\Middleware\EnsureOnCorporateNetwork;
$panel
->middleware(['web', EnsureOnCorporateNetwork::class])
->auth();
$panel->getBaseMiddleware(); // ['web', EnsureOnCorporateNetwork::class]
$panel->getAuthMiddleware(); // ['auth', 'verified']
$panel->getMiddleware(); // ['web', EnsureOnCorporateNetwork::class, 'auth', 'verified']2
3
4
5
6
7
8
9
The default auth stack already protects panel routes from guests. auth(verified: false) keeps that default in place, while auth() adds Laravel's verified middleware.
PandaPanel\Routing\PanelRouteRegistrar then builds the panel's route group from getMiddleware() and appends four framework entries in a fixed order:
…panel middleware…
ResolvePanel:{id} // binds the panel, and 403s a user who may not enter
RequireTwoFactor:{id} // no-op unless the panel called requireTwoFactor()
RequireEmailCode:{id} // no-op unless this account turned emailed codes on
ResolveTenant:{id} // only for a panel with tenancy2
3
4
5
The panel's own auth pages are registered outside that group, with getBaseMiddleware() plus ResolvePanel only. Putting a login page behind auth would send somebody who cannot sign in to the page that tells them to sign in.
The four front-door toggles
Each one registers pages, and each has a matching reader. All four default to off.
| Method | Signature | Reader | Routes registered |
|---|---|---|---|
login | login(bool $login = true): self | hasLogin(): bool | auth.login |
registration | registration(bool $registration = true): self | hasRegistration(): bool | auth.register |
passwordReset | passwordReset(bool $passwordReset = true): self | hasPasswordReset(): bool | auth.password.request, auth.password.reset |
emailVerification | emailVerification(bool $emailVerification = true): self | hasEmailVerification(): bool | auth.verification.notice |
$panel
->auth()
->login()
->registration()
->passwordReset()
->emailVerification();2
3
4
5
6
login() is the gate for all four: registerAuth() returns immediately when hasLogin() is false, so a panel that asks for registration without a login gets neither route. The three that depend on it also 404 individually when their own flag is off, because PanelAuthController calls abort_unless($this->panel()->hasRegistration(), 404) and so on.
Every route name is prefixed with the panel's own:
use PandaPanel\Core\PanelManager;
$panel = app(PanelManager::class)->get('admin');
$panel->routeName('auth.login'); // 'panel.admin.auth.login'
route($panel->routeName('auth.login')); // 'https://example.test/admin/login'2
3
4
5
6
Demanding a second factor
public function requireTwoFactor(bool $required = true): self
public function requiresTwoFactor(): bool2
$panel->auth()->requireTwoFactor();PandaPanel\Http\Middleware\RequireTwoFactor then holds a user at the panel's security page until they have one. Three things count, and any of them is enough:
$user->hasEnabledTwoFactorAuthentication() // Fortify's TOTP
PandaPanel\Auth\EmailCodeFactor::isEnabledFor($user) // the panel's emailed code
$user->passkeys()->exists() // a registered passkey2
3
A passkey counts because a panel that demanded an authenticator app from somebody already using a hardware key would be demanding a downgrade. See Two-Factor Authentication and Passkeys.
The Fortify features the package reads
The package never enables a Fortify feature. It asks whether one is on, and draws accordingly:
| Call site | Feature check | Effect |
|---|---|---|
PanelAuthController::login() | Features::enabled(Features::resetPasswords()) | The canResetPassword prop, which draws the "forgot your password" link |
PanelAuthController::login() | Features::enabled(Features::registration()) | The canRegister prop, which draws the "sign up" link |
SecuritySettings::props() | Features::canManageTwoFactorAuthentication() | The two-factor card, twoFactorEnabled, requiresConfirmation |
SecuritySettings::props() | Features::canManagePasskeys() | The passkeys card and the passkeys prop |
SecuritySettings::props() | Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm') | Whether setup asks for a confirming code |
Both props on the login page require both answers: the Fortify feature and the panel's own flag. A panel that called registration() in an application where Fortify's registration feature is off shows no sign-up link, and its /register page renders while the POST behind it does not exist.
What your application still owns
Three things, none of which a panel framework has any business writing.
A FortifyServiceProvider. The panel's screens do not replace the application's own. This is examples/app/Providers/FortifyServiceProvider.php, which the test suite runs against:
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Actions\Fortify\CreateNewUser;
use Illuminate\Support\ServiceProvider;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Fortify\Fortify;
final class FortifyServiceProvider extends ServiceProvider
{
public function boot(): void
{
Fortify::createUsersUsing(CreateNewUser::class);
Fortify::loginView(static fn (): Response => Inertia::render('auth/Login'));
Fortify::registerView(static fn (): Response => Inertia::render('auth/Register'));
Fortify::confirmPasswordView(static fn (): Response => Inertia::render('auth/ConfirmPassword'));
Fortify::twoFactorChallengeView(static fn (): Response => Inertia::render('auth/TwoFactorChallenge'));
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
confirmPasswordView is not optional for a panel that keeps its settings pages: PandaPanel\Pages\Settings\SecuritySettings runs behind Illuminate\Auth\Middleware\RequirePassword, which redirects to password.confirm.
A user model. The package asks three things of it — Notifiable for the notification centre and the emailed code, TwoFactorAuthenticatable for the security page, and optionally PandaPanel\Contracts\PanelUser for a rule about the account. See User Model Requirements.
The host frontend modules. The published Vue components import @/routes/login, @/routes/register, @/routes/password, @/routes/two-factor, @/routes/verification and @/actions/Laravel/Passkeys/Http/Controllers/PasskeyRegistrationController, all of which Wayfinder generates from your own route table. php artisan panel:install checks for each and names the ones that are missing.
Where a guest is sent, and where a user lands
Two redirects, in opposite directions, both registered by the package.
PandaPanel\Support\PanelLoginRedirect answers the question "a guest opened a panel URL — where do they go":
public static function for(Request $request): ?stringIt returns the panel's own login when the request resolves to a panel that has one, route('login') when it does not, and null when the application has no login route at all. The service provider wires it into Illuminate\Auth\Middleware\Authenticate::redirectUsing() and Illuminate\Auth\AuthenticationException::redirectUsing() inside an afterResolving(Kernel::class) hook — later than the framework's own default, which is why it is not written in bootstrap/app.php any more.
PandaPanel\Http\Middleware\RedirectPanelHome answers the other one: a signed-in user who lands on the starter kit's /dashboard is sent to the first panel they can enter.
// config/panda-panel.php
'register_guest_redirect' => true,
'home_redirect' => [
'enabled' => true,
'paths' => ['dashboard'],
],2
3
4
5
6
7
Both are documented in full under Guest Redirect and Home Redirect.
Gotchas
login()on a public panel is a login page nobody needs. The pages are registered fromhasLogin()alone, so a panel that deliberately cleared its auth stack withauthMiddleware([])can still get a/loginURL. It is harmless and confusing; pair login pages with authenticated panels.- The panel's reset-password page is not what the reset email links to. Laravel's
ResetPasswordnotification builds its URL from the application'spassword.resetroute. Pointing it at a panel is a call toIlluminate\Auth\Notifications\ResetPassword::createUrlUsing()in your own provider — see Password Reset. auth()merges, it does not replace. Calling it twice is idempotent becausemergePaths()deduplicates, butauthMiddleware(['auth'])afterauth()replaces the stack and silently dropsverified.- Fortify's
homeconfig still points wherever it pointed. The package does not edit it.RedirectPanelHomeintercepts the path instead, which is why turning the config key off gives the starter kit's screen back exactly as it was. - Route caching is safe. Every panel route points at a controller method, never a closure.
See also
- Login, Registration, Password Reset, Email Verification
- Two-Factor Authentication, Email Code Challenge, Passkeys
- Profile Settings, Security Settings, Appearance Settings
- The PanelUser contract, User model requirements, panel:user
- Panels: access rules, Panels: middleware
- Troubleshooting: login redirect loops