# Laravel

Serve every customer domain from one Laravel app with a middleware that checks the Apexly signature and resolves the tenant.

## How requests arrive

Apexly forwards each visitor's request to your origin with the original `Host` header, `X-Forwarded-Proto: https`, and `X-Apexly-Domain-Id`, `X-Apexly-App-Id`, `X-Apexly-Timestamp` and `X-Apexly-Signature`.

## Verify the signature

`X-Apexly-Signature` is the hex HMAC-SHA256 of `<timestamp>.<host>` keyed with your signing secret (`whsec_…`). Put it in `.env` as `APEXLY_SIGNING_SECRET` and expose it as `config('services.apexly.secret')`. This function is tested against the shared vectors on every build.

```php check="origin"
<?php

/** True when X-Apexly-Signature is valid for this Host (the edge signs "{timestamp}.{host}"). */
function verify_apexly_origin(string $host, ?string $timestamp, ?string $signature, string $secret, ?int $now = null): bool
{
    if ($timestamp === null || $signature === null || !preg_match('/^\d{1,12}$/', $timestamp)) {
        return false;
    }
    if (abs(($now ?? time()) - (int) $timestamp) > 300) {
        return false;
    }
    $name = strtolower(preg_replace('/:\d+$/', '', $host));
    return hash_equals(hash_hmac('sha256', $timestamp . '.' . $name, $secret), $signature);
}
```

A middleware refuses unsigned requests for hosts you don't serve yourself and binds the tenant:

```php
<?php
// app/Http/Middleware/ResolveTenant.php
namespace App\Http\Middleware;

use App\Models\Tenant;
use Closure;
use Illuminate\Http\Request;

class ResolveTenant
{
    private const OWN_HOSTS = ['app.hireflow.com'];

    public function handle(Request $request, Closure $next)
    {
        $host = $request->getHost();
        if (!in_array($host, self::OWN_HOSTS, true)) {
            $ok = verify_apexly_origin(
                $request->getHttpHost(),
                $request->header('X-Apexly-Timestamp'),
                $request->header('X-Apexly-Signature'),
                config('services.apexly.secret'),
            );
            abort_unless($ok, 421, 'Unknown host');
            app()->instance(Tenant::class, Tenant::where('hostname', $host)->firstOrFail());
        }
        return $next($request);
    }
}
```

## Allow your customers' hostnames

Register the middleware first, and do not enable `trustHosts` (it rejects every host it does not list). Trust the forwarded protocol so `url()` and `secure()` produce `https://` links:

```php
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->prepend(\App\Http\Middleware\ResolveTenant::class);
    $middleware->trustProxies(at: '*', headers: Request::HEADER_X_FORWARDED_PROTO);
})
```

## Receive webhooks

Verify `Apexly-Signature` against `$request->getContent()` with `verify_apexly_signature` from the [Webhooks](/docs/webhooks#verify-the-signature) page, and exclude the route from CSRF protection (`$middleware->validateCsrfTokens(except: ['webhooks/apexly'])`).

```php
// routes/web.php
Route::post('/webhooks/apexly', function (Request $request) {
    $raw = $request->getContent();
    abort_unless(verify_apexly_signature($request->header('Apexly-Signature'), $raw, config('services.apexly.secret')), 400);
    $event = json_decode($raw, true);
    if ($event['type'] === 'domain.active') {
        Tenant::markLive($event['data']['domain']['metadata']['customer']);
    }
    return response()->noContent();
});
```

## Try it

Run the [Quickstart](/docs/quickstart) with a test key, then use the [sandbox origin](/docs/guides/test-mode) to see the headers your app will receive.

- [Webhooks](/docs/webhooks)
- [Test mode](/docs/guides/test-mode)
- [Django guide](/docs/guides/django)
