clarkeash/doorman
Doorman adds secure passwordless magic-link authentication to Laravel. Generate signed login links by email, set expiry and usage limits, invalidate links, and integrate with Laravel's auth guard for simple, low-friction sign-in.
Doorman is a launch gate, not an authorization system. It answers one question — "has this visitor presented a valid invite code?" — and lets you lock an entire Laravel app or a route group behind that answer. Use it for private betas, staging sites, pre-launch landing pages, or a customer-only area where you distribute codes by hand.
Install and scaffold
composer require clarkeash/doorman
php artisan vendor:publish --provider="ClarkeAsh\Doorman\DoormanServiceProvider"
php artisan migrate
The publish step gives you config/doorman.php (redirect target, IP allowlist, code formatting, redeem route) and a doorman_codes migration. Read the config file first — it is the whole surface area of the package, and the key names there tell you what the middleware will actually do.
First use case: gate the whole site
Open routes/web.php and wrap everything except the redemption endpoint:
Route::middleware('doorman')->group(function () {
Route::get('/', HomeController::class);
Route::get('/dashboard', DashboardController::class);
});
// must stay outside the group or you get a redirect loop
Route::view('/redeem', 'doorman.redeem')->name('doorman.redeem');
Route::post('/redeem', RedeemController::class);
Then mint a code and try it in an incognito window:
php artisan doorman:generate
The middleware stores the visitor's unlocked state in the session, so the gate survives navigation but not a cleared cookie jar. That is the behaviour you want for a beta gate; it is the wrong behaviour for an API, which is the first gotcha you will hit (see below).
If you are on Laravel 11+ and the doorman alias is not resolving, check the package's service provider registration and add an explicit alias in bootstrap/app.php — this is the single most common install failure.
For a private staging environment, per-route grouping is noisy. Append the middleware globally in bootstrap/app.php and exclude only the redemption routes:
->withMiddleware(function (Middleware $middleware) {
$middleware->append(\ClarkeAsh\Doorman\Http\Middleware\DoormanMiddleware::class);
})
Combine with the IP allowlist in config/doorman.php so your office and CI runner never see the gate at all. This is the pattern most teams actually want for staging: humans get the gate, machines get the allowlist.
When only part of the app is private (public marketing site + gated customer portal), keep the gate on a group and let the public routes breathe:
Route::middleware('doorman')->prefix('portal')->group(base_path('routes/portal.php'));
Build your own redemption controller rather than relying on a generic form — you get validation, rate limiting, and a decent error message:
public function store(Request $request)
{
$request->validate(['code' => ['required', 'string', 'max:64']]);
if (! Doorman::redeem($request->code)) {
throw ValidationException::withMessages([
'code' => 'That code is invalid, expired, or fully used.',
]);
}
return redirect()->intended('/');
}
Rate-limit this route (->middleware('throttle:5,1')). An invite-code endpoint without throttling is a brute-force target, and codes are short enough to be guessable if you configure them that way.
Two patterns dominate:
php artisan doorman:generate in a loop or with a count argument, dumped to a file, pasted into a spreadsheet, emailed individually. Fine for tens of codes.Mail::to($user)->queue(new InviteCodeMail(Doorman::generate()));
Watch the return type — some versions return the code string, others a model. Check the signature before you assume.
Order matters: gate first, then authenticate. A visitor who has not redeemed a code should never reach a login screen, because that leaks the shape of your app and gives bots a target.
Route::middleware(['doorman', 'auth'])->group(...);
A common refinement for customer portals: let registration be public, then auto-redeem a code carried in the signup URL as a query parameter, so the user never types it.
The package's IP allowlist is underused. Put uptime monitors, load balancers, and your own CI on the allowlist and reserve codes for real people. This dramatically reduces the number of codes in circulation and the support burden when one leaks.
Tests will fail the moment the gate is active. Either disable it:
$this->withoutMiddleware(\ClarkeAsh\Doorman\Http\Middleware\DoormanMiddleware::class);
or assert the gate itself, which is more valuable — one test for "blocked without a code", one for "unlocked after redemption", one for "expired code rejected". Keep the gate tests in their own file so the rest of the suite can run with middleware disabled.
It is session-based, so it does not protect APIs. The unlocked state lives in the session. Stateless routes — API groups, webhook endpoints, anything with api middleware — will re-prompt on every request, or worse, silently fail to prompt and let traffic through depending on how you wired the group. For APIs, use the IP allowlist, a signed token, or a different tool entirely.
Redirect loops are the #1 runtime bug. The redemption route, the post-redeem redirect target, and any asset or health-check route must all sit outside the gated group. If /redeem is inside the group, an unauthenticated visitor gets bounced to /redeem, which bounces them again. Test in a fresh incognito window immediately after wiring it up.
Proxy IPs break the allowlist. If the app sits behind a load balancer, $request->ip() returns the balancer. Configure TrustProxies correctly or your allowlist will either match everyone or no one.
Limited-use codes are not atomic. If a code has a maximum number of redemptions, two concurrent requests can both read "1 use remaining" and both succeed. For a low-traffic beta this is irrelevant; if codes are valuable (one-per-customer discounts, seat licenses), wrap the redemption in a transaction with lockForUpdate() or accept the overshoot deliberately.
Codes are stored in plaintext. Treat the doorman_codes table as a secret store: no database dumps in CI artifacts, no read replicas exposed to analytics tools. Rotate aggressively and delete redeemed codes once they have served their purpose rather than keeping a permanent ledger of every code you ever issued.
Clean up expired and exhausted codes. Left alone, the table grows monotonically and the redemption query scans more rows over time. A scheduled job that prunes used and expired rows — nightly is plenty — keeps it small and keeps old codes from lingering as a liability.
config:cache will bite you. Changing the redirect target or allowlist in config/doorman.php and seeing no effect almost always means a cached config. php artisan config:clear after every config edit during development.
Re-publish on upgrade. The package is still shipping releases (latest in late 2025). After composer update, diff the published config and migration against the package's copies — new config keys silently default, and new columns mean a php artisan migrate you might forget.
Debugging checklist. php artisan route:list -v to confirm which routes carry the middleware; the doorman_codes table to confirm the code exists and has uses left; the session payload to confirm the unlock flag is actually being written; and the middleware source itself when behaviour contradicts the README — it is a small file and reading it is faster than guessing.
Extension points worth knowing. The middleware is a single class, so subclassing or replacing it via the container is straightforward if you need multi-tenancy (different code pools per brand) or audit logging on every redemption. The config file is the intended knob; the middleware is the escape hatch.
Do not confuse it with Laravel's authorization layer. Doorman has no concept of users, roles, or policies. It is a pre-authentication gate. Mixing it into Gate::define calls or policy logic will produce confusing, untestable code — keep it at the middleware layer where it belongs.
How can I help you explore Laravel packages today?