Your backend
Laravel
Protect routes, read the signed-in user with Auth::user(), use Blade directives and receive webhooks in Laravel.
composer require zevauth/zevauth-php
php artisan vendor:publish --tag=zevauth-config
Laravel 11 or later. The integration is part of the PHP library and is discovered automatically.
ZEVAUTH_ENVIRONMENT_ID=env_live_…
ZEVAUTH_PUBLISHABLE_KEY=pk_live_…
ZEVAUTH_SECRET_KEY=sk_live_…
ZEVAUTH_WEBHOOK_SECRET=whsec_…
Signing keys are cached in your default cache store, so they’re fetched
about every ten minutes, not on every request. Set ZEVAUTH_CACHE_STORE to
use another store.
Protecting routes
Route::middleware('zevauth')->group(function () {
Route::get('/dashboard', DashboardController::class);
});
Route::middleware('zevauth:mfa')->post('/payouts', PayoutController::class);
Route::middleware('zevauth:allow-pending')->get('/account/setup', SetupController::class);
| Middleware | Lets through |
|---|---|
zevauth | A signed-in session with no unfinished tasks |
zevauth:mfa | The same, and the person has verified a second factor |
zevauth:allow-pending | A signed-in session even with tasks to finish, for the pages that finish them |
Combine options with a comma: zevauth:mfa,allow-pending. Refusals are JSON
in the same shape as the ZevAuth API: 401 unauthorized, 403
session_task_pending, 403 mfa_required.
Who is signed in
use ZevAuth\Laravel\Facades\ZevAuth;
$auth = ZevAuth::auth();
$auth->userId;
$auth->has(permission: 'org:members:manage');
ZevAuth::requireAuth(requireMfa: true); // throws AuthRefusedException
With Auth::user()
Point a guard at ZevAuth in config/auth.php:
'guards' => [
'web' => ['driver' => 'zevauth'],
],
Auth::user(), $request->user(), @auth and Laravel’s own auth
middleware then work. The user is built from the verified token, with no
database query:
$user = $request->user(); // ZevAuth\Laravel\ZevAuthUser
$user->id; // user_…
$user->email;
$user->hasOrg(role: 'org:admin');
Keep your own records keyed by $user->id if you need them.
Blade
@zevauthSignedIn
<p>Welcome back.</p>
@endzevauthSignedIn
@zevauthSignedOut
<div id="sign-in"></div>
@endzevauthSignedOut
Server-rendered pages
Blade pages are requested by the browser itself, with no Authorization
header. The integration registers the session bridge
routes, POST /zevauth/session and GET /zevauth/handshake, so turning it
on is one line in the browser:
<script src="https://cdn.jsdelivr.net/npm/@zevauth/ui@0/dist/zevauth-ui.global.js"></script>
<script>
ZevAuthUI.init({
publishableKey: '{{ config('zevauth.publishable_key') }}',
sessionBridge: { url: '/zevauth/session' },
});
ZevAuthUI.mountSignIn('#sign-in');
</script>
From then on, the zevauth middleware, the guard and the Blade directives
all see the session on page requests. When the cookie lapses, the
middleware sends the browser through the handshake and straight back. The
person never sees it.
The bridge cookies are kept out of Laravel’s cookie encryption
automatically. Options are under bridge in config/zevauth.php:
allowed_origins, cookie_domain to share the session with subdomains,
and secure (turn it off only for plain-http local development).
Webhooks
// routes/web.php or routes/api.php
Route::zevauthWebhooks('/webhooks/zevauth', HandleZevAuthEvent::class);
final class HandleZevAuthEvent
{
public function __invoke(Request $request)
{
$event = $request->attributes->get('zevauth.event'); // already verified
if ($event['type'] === 'user.deleted') {
User::where('zevauth_id', $event['data']['id'])->delete();
}
return response()->noContent();
}
}
The route checks the signature against the raw body and refuses anything ZevAuth didn’t sign with a 400. It’s excluded from CSRF protection, since deliveries come from ZevAuth’s servers.
Backend API
$client = ZevAuth::client(); // built from ZEVAUTH_SECRET_KEY
$client->users->update($user->id, ['publicMetadata' => ['plan' => 'pro']]);
$client->organizations->addMember('org_…', userId: $user->id);
Everything on the PHP page applies: errors carry
errorCode, status and requestId, and safe retries are automatic.
Octane
The guard and middleware read the current request on every call, so a long-lived Octane worker never carries one person’s session into the next request.
Updated at, Sunday, November 1, 2026