---
title: Laravel
description: "Protect routes, read the signed-in user with Auth::user(), use Blade directives and receive webhooks in Laravel."
---

```bash
composer require zevauth/zevauth-php
php artisan vendor:publish --tag=zevauth-config
```

Laravel 11 or later. The integration is part of the [PHP library](/sdk/php/)
and is discovered automatically.

```dotenv
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

```php
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](/concepts/mfa/), 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

```php
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`:

```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:

```php
$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

```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](/concepts/session-bridge/)
routes, `POST /zevauth/session` and `GET /zevauth/handshake`, so turning it
on is one line in the browser:

```html
<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

```php
// routes/web.php or routes/api.php
Route::zevauthWebhooks('/webhooks/zevauth', HandleZevAuthEvent::class);
```

```php
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.

> **Note:**
> Deliveries can arrive more than once. Use the `zevauth-delivery-id`
> header or the event `id` to handle each one once.

## Backend API

```php
$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](/sdk/php/#backend-api) 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.
