Page Stubs
Add Home, About, Contact, Privacy, and Terms using the starter components. Contact remains a no-send stub until step 06.
Select the Public Page Layout
Switch AppLayout from the sidebar to the header layout. This also changes Dashboard and the outer Settings layout, not just public pages. Auth pages keep AuthLayout.
File: resources/js/layouts/AppLayout.svelte
svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import AppLayout from '@/layouts/app/AppHeaderLayout.svelte';
import type { BreadcrumbItem } from '@/types';
let {
breadcrumbs = [],
children,
}: {
breadcrumbs?: BreadcrumbItem[];
children?: Snippet;
} = $props();
</script>
<AppLayout {breadcrumbs}>
{@render children?.()}
</AppLayout>Header Layout and Footer
Add the privacy and terms links to the footer and keep the toaster for contact form feedback.
File: resources/js/layouts/app/AppHeaderLayout.svelte
svelte
<script lang="ts">
import { Link, page } from '@inertiajs/svelte';
import type { Snippet } from 'svelte';
import AppContent from '@/components/AppContent.svelte';
import AppHeader from '@/components/AppHeader.svelte';
import AppShell from '@/components/AppShell.svelte';
import { Toaster } from '@/components/ui/sonner';
import { privacy, terms } from '@/routes';
import type { BreadcrumbItem } from '@/types';
let {
breadcrumbs = [],
children,
}: {
breadcrumbs?: BreadcrumbItem[];
children?: Snippet;
} = $props();
</script>
<AppShell variant="header">
<AppHeader {breadcrumbs} />
<AppContent variant="header">
{@render children?.()}
</AppContent>
<footer class="border-t border-sidebar-border/80">
<div class="mx-auto flex max-w-7xl flex-col gap-4 px-4 py-6 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<p>© {new Date().getFullYear()} {page.props.name}. All rights reserved.</p>
<nav aria-label="Legal" class="flex gap-6">
<Link href={privacy()} class="hover:text-foreground hover:underline">Privacy</Link>
<Link href={terms()} class="hover:text-foreground hover:underline">Terms</Link>
</nav>
</div>
</footer>
<Toaster />
</AppShell>Header Navigation
Use Home, About, and Contact in the main navigation; login/register for guests. The snapshot imports register() unconditionally: remove that import and link if registration is disabled.
Add aria-label="Open navigation menu" to the icon-only mobile menu button when applying the snapshot.
File: resources/js/components/AppHeader.svelte
svelte
<script lang="ts">
import { Link, page } from '@inertiajs/svelte';
import House from '@lucide/svelte/icons/house';
import Info from '@lucide/svelte/icons/info';
import Mail from '@lucide/svelte/icons/mail';
import Menu from '@lucide/svelte/icons/menu';
import AppLogo from '@/components/AppLogo.svelte';
import AppLogoIcon from '@/components/AppLogoIcon.svelte';
import Breadcrumbs from '@/components/Breadcrumbs.svelte';
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuList,
navigationMenuTriggerStyle,
} from '@/components/ui/navigation-menu';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
import UserMenuContent from '@/components/UserMenuContent.svelte';
import { currentUrlState } from '@/lib/currentUrl.svelte';
import { getInitials } from '@/lib/initials';
import { toUrl } from '@/lib/utils';
import { about, contact, home, login, register } from '@/routes';
import type { BreadcrumbItem, NavItem } from '@/types';
let {
breadcrumbs = [],
}: {
breadcrumbs?: BreadcrumbItem[];
} = $props();
const auth = $derived(page.props.auth);
const url = currentUrlState();
const activeItemStyles =
'text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100';
const mainNavItems: NavItem[] = [
{
title: 'Home',
href: home(),
icon: House,
},
{
title: 'About',
href: about(),
icon: Info,
},
{
title: 'Contact',
href: contact(),
icon: Mail,
},
];
</script>
<div>
<div class="border-b border-sidebar-border/80">
<div class="mx-auto flex h-16 items-center px-4 md:max-w-7xl">
<!-- Mobile Menu -->
<div class="lg:hidden">
<Sheet>
<SheetTrigger asChild>
{#snippet children(props)}
<Button
variant="ghost"
size="icon"
class="mr-2 h-9 w-9"
onclick={props.onclick}
aria-expanded={props['aria-expanded']}
>
<Menu class="h-5 w-5" />
</Button>
{/snippet}
</SheetTrigger>
<SheetContent side="left" class="w-[300px] p-6">
<SheetTitle class="sr-only">Navigation menu</SheetTitle>
<SheetHeader class="flex justify-start text-left">
<AppLogoIcon
class="size-6 fill-current text-black dark:text-white"
/>
</SheetHeader>
<div
class="flex h-full flex-1 flex-col justify-between space-y-4 pt-6 pb-10"
>
<nav class="-mx-3 space-y-1">
{#each mainNavItems as item (toUrl(item.href))}
<Link
href={toUrl(item.href)}
class="flex items-center gap-x-3 rounded-lg px-3 py-2 text-sm font-medium hover:bg-accent {url.whenCurrentUrl(
item.href,
url.currentUrl,
activeItemStyles,
'',
) ?? ''}"
>
{#if item.icon}
<item.icon class="h-5 w-5" />
{/if}
{item.title}
</Link>
{/each}
</nav>
</div>
</SheetContent>
</Sheet>
</div>
<Link href={toUrl(home())} class="flex items-center gap-x-2">
<AppLogo />
</Link>
<!-- Desktop Menu -->
<div class="hidden h-full lg:flex lg:flex-1">
<NavigationMenu class="ml-10 flex h-full items-stretch">
<NavigationMenuList
class="flex h-full items-stretch space-x-2"
>
{#each mainNavItems as item (toUrl(item.href))}
<NavigationMenuItem
class="relative flex h-full items-center"
>
<Link
class="{navigationMenuTriggerStyle()} {url.whenCurrentUrl(
item.href,
url.currentUrl,
activeItemStyles,
'',
) ?? ''} h-9 cursor-pointer px-4"
href={toUrl(item.href)}
>
{#if item.icon}
<item.icon class="mr-2 h-4 w-4" />
{/if}
{item.title}
</Link>
{#if url.isCurrentUrl(item.href, url.currentUrl)}
<div
class="absolute bottom-0 left-0 h-0.5 w-full translate-y-px bg-black dark:bg-white"
></div>
{/if}
</NavigationMenuItem>
{/each}
</NavigationMenuList>
</NavigationMenu>
</div>
<div class="ml-auto flex items-center space-x-2">
{#if auth.user}
<DropdownMenu>
<DropdownMenuTrigger asChild>
{#snippet children(props)}
<Button
variant="ghost"
size="icon"
class="relative size-10 w-auto rounded-full p-1 focus-within:ring-2 focus-within:ring-primary"
onclick={props.onclick}
aria-expanded={props['aria-expanded']}
data-state={props['data-state']}
aria-label="Open profile menu"
>
<Avatar
class="size-8 overflow-hidden rounded-full"
>
{#if auth.user?.avatar}
<AvatarImage
src={auth.user.avatar}
alt={(auth.user?.first_name ?? '') + ' ' + (auth.user?.last_name ?? '')}
/>
{/if}
<AvatarFallback
class="rounded-lg bg-neutral-200 font-semibold text-black dark:bg-neutral-700 dark:text-white"
>
{getInitials((auth.user?.first_name ?? '') + ' ' + (auth.user?.last_name ?? ''))}
</AvatarFallback>
</Avatar>
</Button>
{/snippet}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-56">
<UserMenuContent user={auth.user} />
</DropdownMenuContent>
</DropdownMenu>
{:else}
<Link href={login()} class={navigationMenuTriggerStyle()}>
Log in
</Link>
<Link
href={register()}
class="{navigationMenuTriggerStyle()} border border-border"
>
Register
</Link>
{/if}
</div>
</div>
</div>
{#if breadcrumbs.length > 1}
<div class="flex w-full border-b border-sidebar-border/70">
<div
class="mx-auto flex h-12 w-full items-center justify-start px-4 text-neutral-500 md:max-w-7xl"
>
<Breadcrumbs {breadcrumbs} />
</div>
</div>
{/if}
</div>User Menu
Keep Dashboard and Settings available in the authenticated user menu.
File: resources/js/components/UserMenuContent.svelte
svelte
<script lang="ts">
import { Link, router } from '@inertiajs/svelte';
import LayoutGrid from '@lucide/svelte/icons/layout-grid';
import LogOut from '@lucide/svelte/icons/log-out';
import Settings from '@lucide/svelte/icons/settings';
import {
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import UserInfo from '@/components/UserInfo.svelte';
import { toUrl } from '@/lib/utils';
import { dashboard, logout } from '@/routes';
import { edit } from '@/routes/profile';
import type { User } from '@/types';
let {
user,
}: {
user: User;
} = $props();
function handleLogout(propsOnClick?: () => void) {
return () => {
propsOnClick?.();
router.flushAll();
};
}
</script>
<DropdownMenuLabel class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<UserInfo {user} showEmail={true} />
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
{#snippet children(props)}
<Link
class={props.class}
href={toUrl(dashboard())}
prefetch
onclick={props.onClick}
>
<LayoutGrid class="mr-2 h-4 w-4" />
Dashboard
</Link>
{/snippet}
</DropdownMenuItem>
<DropdownMenuItem asChild>
{#snippet children(props)}
<Link
class={props.class}
href={toUrl(edit())}
prefetch
onclick={props.onClick}
>
<Settings class="mr-2 h-4 w-4" />
Settings
</Link>
{/snippet}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
{#snippet children(props)}
<Link
class={props.class}
href={logout()}
as="button"
onclick={handleLogout(props.onClick)}
data-test="logout-button"
>
<LogOut class="mr-2 h-4 w-4" />
Log out
</Link>
{/snippet}
</DropdownMenuItem>Pages
bash
touch resources/js/pages/{Home,About,Contact,Privacy,Terms}.svelteFile: resources/js/pages/Home.svelte
svelte
<script module lang="ts">
import { home } from '@/routes';
export const layout = {
breadcrumbs: [
{
title: 'Home',
href: home(),
},
],
};
</script>
<script lang="ts">
import AppHead from '@/components/AppHead.svelte';
</script>
<AppHead title="Home" />
<div class="mx-auto max-w-7xl flex-1 px-4 py-20 sm:px-6 lg:px-8">
<h1 class="bg-linear-to-r from-sky-500 to-indigo-500 bg-clip-text text-4xl font-bold tracking-tight text-transparent">
Home
</h1>
<p class="mt-4 max-w-2xl text-lg leading-relaxed text-muted-foreground">
{import.meta.env.VITE_APP_NAME || 'Laravel'} welcomes you.
</p>
</div>File: resources/js/pages/About.svelte
svelte
<script module lang="ts">
import { about } from '@/routes';
export const layout = {
breadcrumbs: [
{
title: 'About',
href: about(),
},
],
};
</script>
<script lang="ts">
import AppHead from '@/components/AppHead.svelte';
</script>
<AppHead title="About" />
<div class="mx-auto max-w-7xl flex-1 px-4 py-16 sm:px-6 lg:px-8">
<h1 class="mb-6 text-3xl font-bold tracking-tight">About</h1>
<p class="max-w-2xl text-lg leading-relaxed text-muted-foreground">
{import.meta.env.VITE_APP_NAME || 'Laravel'} is a modern Laravel starter kit built to help you ship faster.
</p>
</div>File: resources/js/pages/Contact.svelte
svelte
<script module lang="ts">
import { contact } from '@/routes';
export const layout = {
breadcrumbs: [
{
title: 'Contact',
href: contact(),
},
],
};
</script>
<script lang="ts">
import { Form } from '@inertiajs/svelte';
import AppHead from '@/components/AppHead.svelte';
import InputError from '@/components/InputError.svelte';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import { store } from '@/routes/contact';
</script>
<AppHead title="Contact" />
<div class="mx-auto max-w-7xl flex-1 px-4 py-16 sm:px-6 lg:px-8">
<h1 class="mb-6 text-3xl font-bold tracking-tight">Contact</h1>
<p class="max-w-2xl text-lg leading-relaxed text-muted-foreground">
Have a question or want to get in touch? Reach out to us and we'll get back to you as soon as possible.
</p>
<Form {...store.form()} resetOnSuccess class="mt-6 max-w-xl space-y-4">
{#snippet children({ errors, processing })}
<div class="grid gap-2">
<Label for="name">Name</Label>
<Input id="name" name="name" type="text" required />
<InputError message={errors.name} />
</div>
<div class="grid gap-2">
<Label for="email">Email</Label>
<Input id="email" name="email" type="email" required />
<InputError message={errors.email} />
</div>
<div class="grid gap-2">
<Label for="message">Message</Label>
<textarea
id="message"
name="message"
rows="6"
required
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-20 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
></textarea>
<InputError message={errors.message} />
</div>
<Button type="submit" disabled={processing}>
{#if processing}<Spinner />{/if}
Send message
</Button>
{/snippet}
</Form>
</div>Keep validation errors, the disabled submit state, and flashed-toast feedback.
File: resources/js/pages/Privacy.svelte
svelte
<script module lang="ts">
import { privacy } from '@/routes';
export const layout = {
breadcrumbs: [
{
title: 'Privacy Policy',
href: privacy(),
},
],
};
</script>
<script lang="ts">
import AppHead from '@/components/AppHead.svelte';
</script>
<AppHead title="Privacy Policy" />
<div class="mx-auto max-w-7xl flex-1 px-4 py-16 sm:px-6 lg:px-8">
<h1 class="mb-6 text-3xl font-bold tracking-tight">Privacy Policy</h1>
<p class="max-w-2xl text-lg leading-relaxed text-muted-foreground">
Your privacy is important to us. This policy outlines how we collect, use, and protect your personal information.
</p>
</div>File: resources/js/pages/Terms.svelte
svelte
<script module lang="ts">
import { terms } from '@/routes';
export const layout = {
breadcrumbs: [
{
title: 'Terms of Service',
href: terms(),
},
],
};
</script>
<script lang="ts">
import AppHead from '@/components/AppHead.svelte';
</script>
<AppHead title="Terms of Service" />
<div class="mx-auto max-w-7xl flex-1 px-4 py-16 sm:px-6 lg:px-8">
<h1 class="mb-6 text-3xl font-bold tracking-tight">Terms of Service</h1>
<p class="max-w-2xl text-lg leading-relaxed text-muted-foreground">
By using {import.meta.env.VITE_APP_NAME || 'Laravel'}, you agree to abide by the following terms and conditions.
</p>
</div>Privacy and Terms are placeholders, not finished policies. Replace their copy before launch.
Contact Form Controller
bash
php artisan make:controller ContactFormController --no-interactionFile: app/Http/Controllers/ContactFormController.php
php
<?php
namespace App\Http\Controllers;
use App\Mail\ContactMessage;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Inertia\Inertia;
class ContactFormController extends Controller
{
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255'],
'message' => ['required', 'string', 'max:5000'],
]);
$recipient = config('mail.contact.to') ?: config('mail.from.address');
// Mail::to($recipient)->send(new ContactMessage(
// senderName: $validated['name'],
// senderEmail: $validated['email'],
// body: $validated['message'],
// ));
Inertia::flash('toast', [
'type' => 'success',
'message' => __('Thanks! Your message has been sent.'),
]);
return back();
}
}Contact is not live yet
The stub validates and flashes “sent” but neither sends nor stores the message. Complete step 06 before accepting real submissions.
The POST route below allows five submissions per minute per authenticated user or guest IP.
Routes
Replace the home route and add the public routes. Keep dashboard and require __DIR__.'/settings.php'.
File: routes/web.php
php
<?php
use App\Http\Controllers\ContactFormController;
use Illuminate\Support\Facades\Route;
use Laravel\Fortify\Features;
Route::inertia('/', 'Home', [
'canRegister' => Features::enabled(Features::registration()),
])->name('home');
Route::inertia('about', 'About')->name('about');
Route::inertia('contact', 'Contact')->name('contact');
Route::inertia('privacy', 'Privacy')->name('privacy');
Route::inertia('terms', 'Terms')->name('terms');
Route::post('contact', [ContactFormController::class, 'store'])
->middleware('throttle:5,1')
->name('contact.store');
Route::middleware(['auth', 'verified'])->group(function () {
Route::inertia('dashboard', 'Dashboard')->name('dashboard');
});
require __DIR__.'/settings.php';Generate Wayfinder functions before running frontend checks:
bash
php artisan wayfinder:generateRemove the Welcome Page
Once / renders Home, remove the unused starter page:
bash
rm resources/js/pages/Welcome.svelteKeep Dashboard.svelte and the Blade application shell and mail templates.
Authentication Redirect
Set Fortify's home to /; keep the other auth settings. This changes the fallback redirect, not an existing intended destination.
File: config/fortify.php
php
<?php
use Laravel\Fortify\Features;
return [
/*
|--------------------------------------------------------------------------
| Fortify Guard
|--------------------------------------------------------------------------
|
| Here you may specify which authentication guard Fortify will use while
| authenticating users. This value should correspond with one of your
| guards that is already present in your "auth" configuration file.
|
*/
'guard' => 'web',
/*
|--------------------------------------------------------------------------
| Fortify Password Broker
|--------------------------------------------------------------------------
|
| Here you may specify which password broker Fortify can use when a user
| is resetting their password. This configured value should match one
| of your password brokers setup in your "auth" configuration file.
|
*/
'passwords' => 'users',
/*
|--------------------------------------------------------------------------
| Username / Email
|--------------------------------------------------------------------------
|
| This value defines which model attribute should be considered as your
| application's "username" field. Typically, this might be the email
| address of the users but you are free to change this value here.
|
| Out of the box, Fortify expects forgot password and reset password
| requests to have a field named 'email'. If the application uses
| another name for the field you may define it below as needed.
|
*/
'username' => 'email',
'email' => 'email',
/*
|--------------------------------------------------------------------------
| Lowercase Usernames
|--------------------------------------------------------------------------
|
| This value defines whether usernames should be lowercased before saving
| them in the database, as some database system string fields are case
| sensitive. You may disable this for your application if necessary.
|
*/
'lowercase_usernames' => true,
/*
|--------------------------------------------------------------------------
| Home Path
|--------------------------------------------------------------------------
|
| Here you may configure the path where users will get redirected during
| authentication or password reset when the operations are successful
| and the user is authenticated. You are free to change this value.
|
*/
'home' => '/',
/*
|--------------------------------------------------------------------------
| Fortify Routes Prefix / Subdomain
|--------------------------------------------------------------------------
|
| Here you may specify which prefix Fortify will assign to all the routes
| that it registers with the application. If necessary, you may change
| subdomain under which all of the Fortify routes will be available.
|
*/
'prefix' => '',
'domain' => null,
/*
|--------------------------------------------------------------------------
| Fortify Routes Middleware
|--------------------------------------------------------------------------
|
| Here you may specify which middleware Fortify will assign to the routes
| that it registers with the application. If necessary, you may change
| these middleware but typically this provided default is preferred.
|
*/
'middleware' => ['web'],
/*
|--------------------------------------------------------------------------
| Rate Limiting
|--------------------------------------------------------------------------
|
| By default, Fortify will throttle logins to five requests per minute for
| every email and IP address combination. However, if you would like to
| specify a custom rate limiter to call then you may specify it here.
|
*/
'limiters' => [
'login' => 'login',
'two-factor' => 'two-factor',
'passkeys' => 'passkeys',
],
/*
|--------------------------------------------------------------------------
| Register View Routes
|--------------------------------------------------------------------------
|
| Here you may specify if the routes returning views should be disabled as
| you may not need them when building your own application. This may be
| especially true if you're writing a custom single-page application.
|
*/
'views' => true,
/*
|--------------------------------------------------------------------------
| Passkeys
|--------------------------------------------------------------------------
|
| These settings configure Fortify's passkey (WebAuthn) support.
|
*/
'passkeys' => [
'relying_party_id' => parse_url(config('app.url'), PHP_URL_HOST),
'allowed_origins' => [config('app.url')],
'user_handle_secret' => env('PASSKEYS_USER_HANDLE_SECRET', config('app.key')),
'timeout' => 60000,
],
/*
|--------------------------------------------------------------------------
| Features
|--------------------------------------------------------------------------
|
| Some of the Fortify features are optional. You may disable the features
| by removing them from this array. You're free to only remove some of
| these features, or you can even remove all of these if you need to.
|
*/
'features' => [
Features::registration(),
Features::resetPasswords(),
Features::emailVerification(),
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
// 'window' => 0
]),
Features::passkeys([
'confirmPassword' => true,
]),
],
];Update Tests
Change fallback redirect assertions to /. Keep feature guards, RoleSeeder, and the role assertion added in step 04.
Authentication
File: tests/Feature/Auth/AuthenticationTest.php
php
<?php
use App\Models\User;
use Illuminate\Support\Facades\RateLimiter;
use Laravel\Fortify\Features;
test('login screen can be rendered', function () {
$response = $this->get(route('login'));
$response->assertOk();
});
test('users can authenticate using the login screen', function () {
$user = User::factory()->create();
$response = $this->post(route('login.store'), [
'email' => $user->email,
'password' => 'password',
]);
$this->assertAuthenticated();
$response->assertRedirect(route('home'));
});
test('users with two factor enabled are redirected to two factor challenge', function () {
$this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
]);
$user = User::factory()->withTwoFactor()->create();
$response = $this->post(route('login'), [
'email' => $user->email,
'password' => 'password',
]);
$response->assertRedirect(route('two-factor.login'));
$response->assertSessionHas('login.id', $user->id);
$this->assertGuest();
});
test('users can not authenticate with invalid password', function () {
$user = User::factory()->create();
$this->post(route('login.store'), [
'email' => $user->email,
'password' => 'wrong-password',
]);
$this->assertGuest();
});
test('users can logout', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->post(route('logout'));
$response->assertRedirect(route('home'));
$this->assertGuest();
});
test('users are rate limited', function () {
$user = User::factory()->create();
RateLimiter::increment(md5('login'.implode('|', [$user->email, '127.0.0.1'])), amount: 5);
$response = $this->post(route('login.store'), [
'email' => $user->email,
'password' => 'wrong-password',
]);
$response->assertTooManyRequests();
});Email Verification
File: tests/Feature/Auth/EmailVerificationTest.php
php
<?php
use App\Models\User;
use Illuminate\Auth\Events\Verified;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\URL;
use Laravel\Fortify\Features;
beforeEach(function () {
$this->skipUnlessFortifyHas(Features::emailVerification());
});
test('email verification screen can be rendered', function () {
$user = User::factory()->unverified()->create();
$response = $this->actingAs($user)->get(route('verification.notice'));
$response->assertOk();
});
test('email can be verified', function () {
$user = User::factory()->unverified()->create();
Event::fake();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)],
);
$response = $this->actingAs($user)->get($verificationUrl);
Event::assertDispatched(Verified::class);
expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
$response->assertRedirect(route('home').'?verified=1');
});
test('email is not verified with invalid hash', function () {
$user = User::factory()->unverified()->create();
Event::fake();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1('wrong-email')],
);
$this->actingAs($user)->get($verificationUrl);
Event::assertNotDispatched(Verified::class);
expect($user->fresh()->hasVerifiedEmail())->toBeFalse();
});
test('email is not verified with invalid user id', function () {
$user = User::factory()->unverified()->create();
Event::fake();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => 123, 'hash' => sha1($user->email)],
);
$this->actingAs($user)->get($verificationUrl);
Event::assertNotDispatched(Verified::class);
expect($user->fresh()->hasVerifiedEmail())->toBeFalse();
});
test('verified user is redirected home from verification prompt', function () {
$user = User::factory()->create();
Event::fake();
$response = $this->actingAs($user)->get(route('verification.notice'));
Event::assertNotDispatched(Verified::class);
$response->assertRedirect(route('home'));
});
test('already verified user visiting verification link is redirected without firing event again', function () {
$user = User::factory()->create();
Event::fake();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)],
);
$this->actingAs($user)->get($verificationUrl)
->assertRedirect(route('home').'?verified=1');
Event::assertNotDispatched(Verified::class);
expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
});Registration
File: tests/Feature/Auth/RegistrationTest.php
php
<?php
use Database\Seeders\RoleSeeder;
use Laravel\Fortify\Features;
beforeEach(function () {
$this->skipUnlessFortifyHas(Features::registration());
});
test('registration screen can be rendered', function () {
$response = $this->get(route('register'));
$response->assertOk();
});
test('new users can register', function () {
$this->seed(RoleSeeder::class);
$response = $this->post(route('register.store'), [
'first_name' => 'Test',
'last_name' => 'User',
'email' => 'test@example.com',
'password' => 'password',
'password_confirmation' => 'password',
]);
$response->assertSessionHasNoErrors();
$response->assertRedirect(route('home'));
$this->assertAuthenticated();
});Verification Notifications
File: tests/Feature/Auth/VerificationNotificationTest.php
php
<?php
use App\Models\User;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Support\Facades\Notification;
use Laravel\Fortify\Features;
beforeEach(function () {
$this->skipUnlessFortifyHas(Features::emailVerification());
});
test('sends verification notification', function () {
Notification::fake();
$user = User::factory()->unverified()->create();
$this->actingAs($user)
->post(route('verification.send'))
->assertRedirect(route('home'));
Notification::assertSentTo($user, VerifyEmail::class);
});
test('does not send verification notification if email is verified', function () {
Notification::fake();
$user = User::factory()->create();
$this->actingAs($user)
->post(route('verification.send'))
->assertRedirect(route('home'));
Notification::assertNothingSent();
});Verify Pages
bash
composer lint
composer ci:check
npm run build- Check all five public routes as a guest, then Dashboard and Settings while signed in.
- Check desktop/mobile navigation, keyboard menu controls, and guest/auth links.
- Submit invalid contact data, then valid data; check field errors and the toast. No mail yet.
- Check the login/register fallback is
/; an intended protected URL should still take precedence.
Commit
bash
git add -A
git commit -m '05 - Page Stubs'