withRouting( web: __DIR__.'/../routes/web.php', api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', apiPrefix: 'api/v1', ) ->withMiddleware(function (Middleware $middleware): void { // API uses token-based auth, no CSRF needed $middleware->append(\App\Http\Middleware\SecurityHeaders::class); // Read httpOnly auth cookie and inject as Authorization header (before Sanctum) $middleware->api(prepend: [ \App\Http\Middleware\CookieBearerToken::class, ]); $middleware->alias([ 'portal.token' => \App\Http\Middleware\PortalTokenMiddleware::class, 'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, 'impersonation' => \App\Http\Middleware\HandleImpersonation::class, // RFC-WS-7 §3.6 — applied inside auth:sanctum groups so it runs // after authentication and can read $request->user(). Cannot live // on the api group because route-level auth middleware runs after // group middleware in Laravel. 'sentry.context' => \App\Http\Middleware\BindSentryContext::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { // Public Form Builder standardised error envelope (S2c D6). $exceptions->render(function (\App\Exceptions\FormBuilder\PublicFormApiException $e, Request $request) { $body = [ 'message' => $e->getMessage(), 'code' => $e->publicCode, ]; if ($e->fieldErrors !== null) { $body['errors'] = $e->fieldErrors; } return response()->json($body, $e->status, $e->headers); }); // FormRequest validation on /api/v1/public/forms/* → rewrap into // the D6 envelope so every public endpoint error looks identical // regardless of which layer surfaced it. $exceptions->render(function (ValidationException $e, Request $request) { if (! $request->is('api/v1/public/forms/*')) { return null; } return response()->json([ 'message' => $e->getMessage(), 'code' => 'VALIDATION_FAILED', 'errors' => $e->errors(), ], $e->status); }); // Database connection / query errors → 503 $exceptions->render(function (QueryException|PDOException $e, Request $request) { if ($request->expectsJson() || $request->is('api/*')) { Log::error('Database error', [ 'exception' => get_class($e), 'message' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); $response = ['message' => 'Service temporarily unavailable. Please try again later.']; if (config('app.debug')) { $response['debug'] = [ 'exception' => get_class($e), 'message' => $e->getMessage(), ]; } return response()->json($response, 503); } }); // 404 Not Found → friendly message $exceptions->render(function (NotFoundHttpException $e, Request $request) { if ($request->expectsJson() || $request->is('api/*')) { return response()->json([ 'message' => 'Resource not found.', ], 404); } }); // Authorization failures → log with user context $exceptions->render(function (AuthorizationException $e, Request $request) { if ($request->expectsJson() || $request->is('api/*')) { Log::warning('Authorization denied', [ 'user_id' => auth()->id(), 'ip' => $request->ip(), 'path' => $request->path(), 'method' => $request->method(), ]); } return null; // Let Laravel handle the 403 response normally }); // All other unhandled exceptions → 500 // (ValidationException, AuthenticationException, and HttpException are handled by Laravel) $exceptions->render(function (Throwable $e, Request $request) { if ($request->expectsJson() || $request->is('api/*')) { if ($e instanceof ValidationException || $e instanceof AuthenticationException || $e instanceof HttpException) { return null; // Let Laravel handle these normally } Log::error('Unhandled exception', [ 'exception' => get_class($e), 'message' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); $response = ['message' => 'An unexpected error occurred.']; if (config('app.debug')) { $response['debug'] = [ 'exception' => get_class($e), 'message' => $e->getMessage(), ]; } return response()->json($response, 500); } }); })->create();