Laravel Breeze Starter Project
What Developers Can Learn From Laravel Breeze
Breeze is often installed as a quick way to get login and registration working, then never looked at again. That's a missed opportunity — it's also one of the clearest, smallest examples of how a real Laravel app is organized. This tutorial walks through four things worth studying inside it: the file structure, the auth model/controller/view pattern, email verification, and how to safely extend the dashboard it ships with.
1. File & folder structure
Breeze doesn't introduce a new architecture — it just fills in files that a normal Laravel app already has slots for. That's exactly why it's useful to study: it shows you what "idiomatic" Laravel actually looks like once auth is wired up.
What's worth noticing here:
-
One controller, one job. Instead of a fat
AuthController, Breeze splits registration, login, password reset, and verification into separate single-purpose controllers. This is a pattern worth copying in your own features. -
Routes are grouped by concern. All auth routes live in
routes/auth.phpand get pulled intoweb.php, keeping the main routes file readable as the app grows. -
Blade components over duplicated markup. Things like
<x-input>and<x-primary-button>show how to avoid copy-pasting the same input/button styling across every form.
2. Auth model, controllers, and views
Breeze is a compact, working example of Laravel's MVC flow end to end. Following one action — registration — through all three layers is the fastest way to internalize the pattern.
Model: app/Models/User.php
The model doesn't just hold data — it declares behavior through contracts:
class User extends Authenticatable implements MustVerifyEmail
{
use HasFactory, Notifiable;
protected $fillable = ['name', 'email', 'password'];
protected $hidden = ['password', 'remember_token'];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}
Three things to learn from this file: $fillable controls mass
assignment safety, $hidden keeps sensitive fields out of JSON
responses, and the casts() method automatically hashes
passwords on save instead of requiring you to call Hash::make()
everywhere.
Controller: RegisteredUserController
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
event(new Registered($user));
Auth::login($user);
return redirect(route('dashboard', absolute: false));
}
Notice the shape: validate → create → fire an event →
redirect. The event(new Registered($user)) line is a good
lesson in decoupling — the controller doesn't know or care that this
event triggers an email; it just announces that something happened.
View: resources/views/auth/register.blade.php
<x-guest-layout>
<form method="POST" action="{{ route('register') }}">
@csrf
<x-input-label for="name" :value="__('Name')" />
<x-text-input id="name" name="name" type="text" required autofocus />
<x-input-error :messages="$errors->get('name')" />
{{-- email, password, password_confirmation follow the same pattern --}}
</form>
</x-guest-layout>
Each field follows the same three-line pattern: label, input, error. Once you recognize it, adding a new field to any Breeze form (or your own forms) becomes mechanical instead of something to look up each time.
| Layer | File | Responsibility |
|---|---|---|
| Model | app/Models/User.php | Data rules, casts, contracts |
| Controller | Auth/RegisteredUserController.php | Validate, act, redirect |
| View | auth/register.blade.php | Present the form, show errors |
3. Email verification
Breeze includes everything for email verification, but it's off by default until you opt in — which makes it a good case study in how Laravel uses contracts and events to add optional behavior without touching the framework itself.
-
The contract switches it on. Adding
implements MustVerifyEmailto theUsermodel is the single line that tells Laravel this user type needs to verify their email before being treated as fully authenticated. -
An event triggers the notification.
event(new Registered($user))in the registration controller is already listening for exactly this. When the model implementsMustVerifyEmail, Laravel's built-in listener sends the verification email automatically — no extra code needed. -
Middleware enforces it. Adding
'verified'alongside'auth'on a route blocks access untilemail_verified_atis set:Route::get('/dashboard', function () { return view('dashboard'); })->middleware(['auth', 'verified'])->name('dashboard'); -
Signed URLs protect the verification link. The route
that actually verifies the email uses
signedmiddleware, so the link can't be tampered with or reused after the signature expires.
4. Adding pages to the Breeze dashboard
The dashboard Breeze ships with is intentionally minimal — a single
Blade view behind auth and verified middleware.
Extending it is a good first real task because it forces you to touch
routes, views, and (optionally) a controller together.
Step 1 — Add a route
// routes/web.php
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/dashboard', function () {
return view('dashboard');
})->name('dashboard');
Route::get('/projects', [ProjectController::class, 'index'])
->name('projects.index');
});
Grouping new pages under the same middleware keeps them behind login and verification automatically.
Step 2 — Create a controller
php artisan make:controller ProjectController
class ProjectController extends Controller
{
public function index()
{
return view('projects.index', [
'projects' => Auth::user()->projects,
]);
}
}
Step 3 — Build the view using Breeze's layout
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
Projects
</h2>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
@foreach ($projects as $project)
<p>{{ $project->name }}</p>
@endforeach
</div>
</div>
</div>
</x-app-layout>
Reusing <x-app-layout> means your new page automatically
gets the same navigation bar, container width, and background as the
dashboard — you're not rebuilding the shell each time.
Step 4 — Add it to navigation
Open resources/views/layouts/navigation.blade.php and add a link next to the existing Dashboard item:
<x-nav-link :href="route('projects.index')" :active="request()->routeIs('projects.index')">
Projects
</x-nav-link>
Breeze already computes :active state this way for
"Dashboard" — matching that pattern keeps the nav bar's highlighting
consistent for every page you add.
x-text-input, x-primary-button, x-input-label)
instead of writing new Tailwind classes from scratch — it keeps the
whole app visually consistent with almost no extra work.
Common mistake: forgetting the middleware group
If a new route is added directly in routes/web.php without
putting it inside the same middleware(['auth', 'verified'])
group (or repeating the middleware on the route itself), the page will
be publicly accessible even to logged-out or unverified users.
Where to go from here
Once these four areas feel familiar, Breeze stops being a black box you installed once and becomes a reference implementation you can borrow patterns from — single-purpose controllers, contract-driven optional behavior, and layout components are all things you'll want in apps that have nothing to do with authentication.