withRouting( web: __DIR__.'/../routes/web.php', // Versioned at the prefix, not with a header or a query parameter: // /api/v1 is a frozen contract, and a future /api/v2 gets its own // route file rather than branching inside these controllers. api: __DIR__.'/../routes/api.php', apiPrefix: 'api/v1', commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware) { // Trusted proxies are configured in config/trustedproxy.php, NOT // here. This closure runs when the HTTP kernel is resolved, which is // before the dotenv bootstrapper has read .env, so env() returns null // here for anything that is not already a real environment variable — // silently, and only on web requests (artisan bootstraps in the other // order, so a CLI check reports the setting as working). The framework's // TrustProxies middleware is in the global stack either way and falls // back to that config key on its own. $middleware->web(append: [ // Binds every session to the password hash it was created under, // so changing a password (or a reset) actually terminates the // account's other sessions instead of leaving a stolen one live. // Required for Auth::logoutOtherDevices() to have any effect. AuthenticateSession::class, EnsureSetupIsComplete::class, EnsureAccountIsActive::class, EnforceTwoFactor::class, SetLocale::class, HandleInertiaRequests::class, // Deliberately NOT here: AddLinkHeadersForPreloadedAssets. It // copies every Vite preload into a `Link:` response header, // and the head of the document already carries the identical // tags — twenty of them on the login page, more on a heavier // one. The copy is what a browser never reads and a proxy has // to buffer: it pushed /files past 6 KB of headers, where the // 4 KB proxy_buffer_size that nginx, and therefore Nginx Proxy // Manager, defaults to answers 502. Some pages fit and some do // not, so it reads as an intermittent fault rather than a // header that is always too big (#1664). Nothing is lost but // 103 Early Hints, which this application does not send. ]); // The API group gets none of the web stack above — no session, no // CSRF, no Inertia. Locale is the one thing worth carrying over, // since validation messages are written for a human to read; the // web SetLocale can't be reused because it reads the session. $middleware->api(append: [ SetApiLocale::class, // Applied to the group rather than per route, so an endpoint // added later is measured without anyone opting in. RecordApiRequest::class, ]); $middleware->throttleApi(); $middleware->validateCsrfTokens(except: [ 'uploads/*/parts/*', ]); // Swapped for the subclass only to name the CSRF cookie after this // installation rather than after the framework — see that class for // what sharing `XSRF-TOKEN` with a neighbouring app does. // // `web(replace:)` rather than the bare `replace()`: the latter only // reaches the global stack, and CSRF lives in the web group, so it // silently does nothing here. $middleware->web(replace: [ Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class => ValidateCsrfToken::class, ]); $middleware->alias([ 'capability' => EnsureCapability::class, 'staff' => EnsureStaff::class, 'staff-token' => EnsureStaffToken::class, 'token-can' => EnsureTokenCan::class, 'api-active' => EnsureApiAccountIsActive::class, ]); }) ->withExceptions(function (Exceptions $exceptions) { // Every credential this application stores encrypted, named again // here so a failed validation does not write it back out in clear. // // A ValidationException flashes the request's input into the // session so the form can be repopulated, minus this list. The // framework's own three cover the login and password forms; none // of the settings screens' credentials were on it, and // config/session.php stores sessions in the database by default // with `encrypt => false`. So a mistyped storage form put the // secret access key in clear into the same database whose dump the // `encrypted` cast exists to survive — and a service account key // file, which is most likely to fail validation exactly when it // was pasted incompletely, put a private key there. // // Merged with the framework's defaults rather than replacing them. // The cost is that these fields come back blank after a failed // save, which is what every one of these screens already does on a // successful one: they are write-only, and a blank means "keep // what is stored". $exceptions->dontFlash([ 'secret', // ExternalStorageSettingsController (S3) 'key_file', // ExternalStorageSettingsController (GCS) 'bind_password', // LdapSettingsController 'client_secret', // SocialLoginSettingsController, EmailSettingsController 'secret_key', // CaptchaSettingsController ]); // RFC 7807 for /api/* only. Everything else — web pages, Inertia // requests, the public share links — keeps Laravel's own handling // untouched, which is why this is scoped by path rather than by // whether the request happens to accept JSON (Inertia requests do). $exceptions->render(function (Throwable $e, Request $request) { $problems = app(ProblemDetails::class); return $problems->shouldHandle($request) ? $problems->render($request, $e) : null; }); // A redirect born in exception handling — the guest redirect after // an expired login, above all — never travels back through the // middleware stack, so Inertia's usual 302→303 upgrade cannot reach // it. WriteSafeRedirect explains why that matters and holds the // rule; the same three middleware that answer before Inertia's is // reached apply it too. $exceptions->respond( fn (SymfonyResponse $response, Throwable $e, Request $request): SymfonyResponse => WriteSafeRedirect::apply($request, $response) ); })->create();