Custom Error Pages (Optional)
Use one standalone Svelte page for 403, 404, 500, and 503 responses. Keep resources/views/app.blade.php; no Blade error templates to publish. This uses the Inertia 3 exception API.
Svelte Error Page
Use the existing AppHead, Button, and home route. Keep messages fixed; never pass exception details to the page.
File: resources/js/pages/ErrorPage.svelte
svelte
<script lang="ts">
import { Link } from '@inertiajs/svelte';
import AppHead from '@/components/AppHead.svelte';
import { Button } from '@/components/ui/button';
import { home } from '@/routes';
let { status }: { status: 403 | 404 | 500 | 503 } = $props();
const messages = {
403: {
title: 'Forbidden',
description: 'Sorry, you do not have permission to access this page.',
},
404: {
title: 'Page not found',
description: 'Sorry, the page you are looking for could not be found.',
},
500: {
title: 'Server error',
description: 'Something went wrong on our servers. Please try again later.',
},
503: {
title: 'Service unavailable',
description: 'We are temporarily unavailable. Please check back soon.',
},
};
const message = $derived(messages[status]);
</script>
<AppHead title={`${status}: ${message.title}`} />
<main class="dark flex min-h-svh items-center justify-center bg-background px-4 py-16 text-foreground">
<div class="flex max-w-md flex-col items-center gap-6 text-center">
<p class="bg-linear-to-r from-sky-500 to-indigo-500 bg-clip-text text-6xl font-semibold tracking-tight text-transparent">
{status}
</p>
<h1 class="text-3xl font-bold tracking-tight">{message.title}</h1>
<p class="text-muted-foreground">{message.description}</p>
<Button asChild>
{#snippet children(props)}
<Link href={home()} {...props}>Go home</Link>
{/snippet}
</Button>
</div>
</main>Keep the page and its dependencies independent of auth, name, and other shared props: unmatched URLs can fail before Inertia middleware runs.
Skip the Application Layout
Add ErrorPage to the resolver's return null branch so it never mounts navigation that needs shared props. Keep the other layouts, theme initialization, and flash toast setup.
File: resources/js/app.ts
ts
import { createInertiaApp } from '@inertiajs/svelte';
import AppLayout from '@/layouts/AppLayout.svelte';
import AuthLayout from '@/layouts/AuthLayout.svelte';
import SettingsLayout from '@/layouts/settings/Layout.svelte';
import { initializeFlashToast } from '@/lib/flash-toast';
import { initializeTheme } from '@/lib/theme.svelte';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
void createInertiaApp({
title: (title) => (title ? `${title} - ${appName}` : appName),
layout: (name) => {
switch (true) {
case name === 'Welcome':
case name === 'ErrorPage':
return null;
case name.startsWith('auth/'):
return AuthLayout;
case name.startsWith('settings/'):
return [AppLayout, SettingsLayout];
default:
return AppLayout;
}
},
progress: {
color: '#4B5563',
},
});
// This will set light / dark mode on page load...
initializeTheme();
// This will listen for flash toast data from the server...
initializeFlashToast();Handle Exception Responses
Add the imports and Inertia::handleExceptionsUsing() callback to boot(). Keep configureDefaults() and existing provider setup.
File: app/Providers/AppServiceProvider.php
php
<?php
namespace App\Providers;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
use Inertia\ExceptionResponse;
use Inertia\Inertia;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
$this->configureDefaults();
Inertia::handleExceptionsUsing(function (ExceptionResponse $response): ?ExceptionResponse {
if (app()->environment('testing')) {
return null;
}
if (app()->environment('local') && $response->statusCode() === 500) {
return null;
}
if ($response->request->is('api/*') || $response->request->expectsJson()) {
return null;
}
if (! in_array($response->statusCode(), [403, 404, 500, 503], true)) {
return null;
}
return $response->render('ErrorPage', [
'status' => $response->statusCode(),
])->rootView('app');
});
}
/**
* Configure default behaviors for production-ready applications.
*/
protected function configureDefaults(): void
{
Date::use(CarbonImmutable::class);
DB::prohibitDestructiveCommands(
app()->isProduction(),
);
Password::defaults(fn (): ?Password => app()->isProduction()
? Password::min(12)
->mixedCase()
->letters()
->numbers()
->symbols()
->uncompromised()
: null,
);
}
}Keep the original HTTP status. Handler behavior:
| Request or environment | Response |
|---|---|
| Browser or Inertia request, outside local/testing | Svelte page for 403, 404, 500, and 503 |
| Local development | Svelte page for 403, 404, and 503; Laravel's normal response for 500 |
| Testing | Laravel's normal exception response |
/api/* or a request expecting JSON | Skip the Svelte page |
| Other statuses, including 419, 422, and 429 | Leave Laravel's existing handling unchanged |
Keep rootView('app') for unmatched routes. Do not add withSharedData(); avoid resolving application props while handling an exception. Returning null keeps Laravel's response.
Keep APP_DEBUG=false in production. Local 500 debugging still depends on APP_DEBUG=true.
Error Response Tests
Cover direct/Inertia requests, unmatched URLs, environment fallbacks, JSON, status codes, and private-detail exclusion.
File: tests/Feature/ErrorPageTest.php
php
<?php
use App\Http\Middleware\HandleInertiaRequests;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Inertia\Testing\AssertableInertia as Assert;
use function Pest\Laravel\get;
beforeEach(function () {
app()->instance('env', 'production');
config(['app.debug' => false, 'inertia.ssr.enabled' => false]);
});
test('production browser errors render the standalone error page', function (int $status) {
Route::middleware('web')->get('/__test/error', fn () => abort($status, 'Private exception details.'));
$response = get('/__test/error');
$response->assertStatus($status)
->assertViewIs('app')
->assertInertia(fn (Assert $page) => $page
->component('ErrorPage')
->where('status', $status),
)
->assertDontSee('Private exception details.');
})->with([403, 404, 500, 503]);
test('production Inertia errors return an error page instead of the development modal', function (int $status) {
Route::middleware('web')->get('/__test/error', fn () => abort($status, 'Private exception details.'));
$response = get('/__test/error', [
'X-Inertia' => 'true',
'X-Requested-With' => 'XMLHttpRequest',
'X-Inertia-Version' => app(HandleInertiaRequests::class)->version(Request::create('/__test/error')) ?? '',
'Accept' => 'text/html, application/xhtml+xml',
]);
$response->assertStatus($status)
->assertHeader('X-Inertia', 'true')
->assertJsonPath('component', 'ErrorPage')
->assertJsonPath('props.status', $status)
->assertDontSee('Private exception details.');
})->with([403, 404, 500, 503]);
test('unmatched browser URLs render without shared application props', function (string $environment) {
app()->instance('env', $environment);
config(['app.debug' => $environment === 'local']);
$response = get('/__test/missing');
$response->assertNotFound()
->assertViewIs('app')
->assertInertia(fn (Assert $page) => $page
->component('ErrorPage')
->where('status', 404)
->missing('auth')
->missing('name'),
);
})->with(['production', 'local']);
test('unmatched Inertia URLs return the error page', function (string $environment) {
app()->instance('env', $environment);
config(['app.debug' => $environment === 'local']);
$response = get('/__test/missing', ['X-Inertia' => 'true']);
$response->assertNotFound()
->assertHeader('X-Inertia', 'true')
->assertJsonPath('component', 'ErrorPage')
->assertJsonPath('props.status', 404);
})->with(['production', 'local']);
test('local forbidden and unavailable errors render the custom page', function (int $status) {
app()->instance('env', 'local');
config(['app.debug' => true]);
Route::get('/__test/error', fn () => abort($status));
$response = get('/__test/error');
$response->assertStatus($status)
->assertInertia(fn (Assert $page) => $page
->component('ErrorPage')
->where('status', $status),
);
})->with([403, 503]);
test('production API and JSON errors stay JSON', function (string $path, array $headers) {
Route::get($path, fn () => abort(403, 'Access denied.'));
$response = get($path, $headers);
$response->assertForbidden()
->assertHeader('Content-Type', 'application/json')
->assertHeaderMissing('X-Inertia')
->assertJsonPath('message', 'Access denied.')
->assertJsonMissingPath('component');
})->with([
'API path without a JSON accept header' => ['/api/__test/error', ['Accept' => 'text/html']],
'web path requesting JSON' => ['/__test/error', ['Accept' => 'application/json']],
]);
test('local and testing environments retain Laravel debugging responses', function (string $environment) {
app()->instance('env', $environment);
config(['app.debug' => true]);
Route::get('/__test/error', fn () => throw new RuntimeException('Debug exception details.'));
$response = get('/__test/error', ['X-Inertia' => 'true']);
$response->assertInternalServerError()
->assertHeaderMissing('X-Inertia')
->assertSee('Debug exception details.');
})->with(['local', 'testing']);
test('other production error statuses retain Laravel responses', function (int $status) {
Route::get('/__test/error', fn () => abort($status));
$response = get('/__test/error', ['X-Inertia' => 'true']);
$response->assertStatus($status)
->assertHeaderMissing('X-Inertia')
->assertDontSee('data-page=');
})->with([419, 422, 429]);The suite sets the environment to production with debug and SSR off, then overrides those settings per case. It does not validate SSR.
Run the focused tests and frontend checks:
bash
php artisan test --compact tests/Feature/ErrorPageTest.php
npm run types:check
npm run buildBrowser Checks
- Open a missing URL directly and through an Inertia link: standalone 404, no app navigation or error modal.
- Check title, status, message, and Go home at mobile and desktop widths.
- With
APP_ENV=localandAPP_DEBUG=true, confirm the 500 debug response still works. - To preview the custom 500 locally, set
APP_ENV=productionandAPP_DEBUG=false, then clear config. Debug off alone is not enough. Restore local settings and clear config afterward.
Commit
bash
git add -A
git commit -m '08 - Custom Error Pages'