Laravel Authentication Scaffolding: A Deep Dive into Laravel Breeze & Jetstream

Published on November 19, 2025
Laravel Authentication Breeze Jetstream Security PHP

Introduction to Laravel Authentication Scaffolding

Laravel provides two main authentication starter kits: Breeze and Jetstream. These packages give you a complete authentication system out of the box, saving hours of development time.

Quick Comparison:

  • Laravel Breeze: Minimal, simple authentication scaffold
  • Laravel Jetstream: Full-featured with teams, API support, and more

Laravel Breeze: Simple Authentication

What is Laravel Breeze?

Breeze provides a minimal, simple implementation of all Laravel's authentication features, including:

  • Login, registration, password reset, email verification
  • Blade templates with Tailwind CSS
  • No JavaScript frameworks required

Breeze Installation

# Create new Laravel project
laravel new my-app
cd my-app

# Install Breeze
composer require laravel/breeze --dev

# Scaffold basic authentication
php artisan breeze:install

# Install and build frontend assets
npm install
npm run build

# Run migrations
php artisan migrate

Breeze File Structure

resources/views/
├── auth/
│   ├── login.blade.php
│   ├── register.blade.php
│   ├── forgot-password.blade.php
│   ├── reset-password.blade.php
│   └── verify-email.blade.php
├── components/
│   ├── auth-session-status.blade.php
│   └── input-error.blade.php
├── layout.blade.php
└── dashboard.blade.php

routes/
├── auth.php  # Authentication routes

app/Http/Controllers/Auth/
├── AuthenticatedSessionController.php
├── RegisteredUserController.php
└── ...

Breeze Controllers Overview

Registration Controller

<?php
// app/Http/Controllers/Auth/RegisteredUserController.php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;

class RegisteredUserController extends Controller
{
    /**
     * Handle an incoming registration request.
     *
     * @throws \Illuminate\Validation\ValidationException
     */
    public function store(Request $request): Response
    {
        $request->validate([
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'lowercase', 'email', 'max:255', '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 response()->noContent();
    }
}

Authentication Controller

<?php
// app/Http/Controllers/Auth/AuthenticatedSessionController.php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;

class AuthenticatedSessionController extends Controller
{
    /**
     * Handle an incoming authentication request.
     */
    public function store(LoginRequest $request): Response
    {
        $request->authenticate();

        $request->session()->regenerate();

        return response()->noContent();
    }

    /**
     * Destroy an authenticated session.
     */
    public function destroy(Request $request): Response
    {
        Auth::guard('web')->logout();

        $request->session()->invalidate();
        $request->session()->regenerateToken();

        return response()->noContent();
    }
}

Customizing Breeze

Adding Custom Fields to Registration

// 1. Update the registration form
// resources/views/auth/register.blade.php

<div>
    <label for="username">Username</label>
    <input 
        type="text" 
        name="username" 
        id="username" 
        value="{{ old('username') }}"
        required
    >
    @error('username')
        <span class="error">{{ $message }}</span>
    @enderror
</div>

<div>
    <label for="phone">Phone Number</label>
    <input 
        type="tel" 
        name="phone" 
        id="phone" 
        value="{{ old('phone') }}"
    >
    @error('phone')
        <span class="error">{{ $message }}</span>
    @enderror
</div>

Update Validation Rules

// app/Http/Controllers/Auth/RegisteredUserController.php

public function store(Request $request): Response
{
    $request->validate([
        'name' => ['required', 'string', 'max:255'],
        'username' => ['required', 'string', 'max:50', 'unique:users,username'],
        'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
        'phone' => ['nullable', 'string', 'max:20'],
        'password' => ['required', 'confirmed', Rules\Password::defaults()],
    ]);

    $user = User::create([
        'name' => $request->name,
        'username' => $request->username,
        'email' => $request->email,
        'phone' => $request->phone,
        'password' => Hash::make($request->password),
    ]);

    event(new Registered($user));
    Auth::login($user);

    return response()->noContent();
}

Update User Model

<?php
// app/Models/User.php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class User extends Authenticatable
{
    use HasFactory, Notifiable;

    protected $fillable = [
        'name',
        'username',
        'email',
        'phone',
        'password',
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }
}

Create Migration for New Fields

php artisan make:migration add_username_and_phone_to_users_table
<?php
// database/migrations/xxxx_xx_xx_xxxxxx_add_username_and_phone_to_users_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->string('username')->unique()->after('name');
            $table->string('phone')->nullable()->after('email');
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn(['username', 'phone']);
        });
    }
};

Laravel Jetstream: Full-Featured Authentication

What is Laravel Jetstream?

Jetstream provides a more robust authentication scaffold including:

  • Team support (create, manage, switch teams)
  • Two-factor authentication
  • API token management
  • Browser session management
  • Profile management

Jetstream Installation

# Create new Laravel project
laravel new my-app
cd my-app

# Install Jetstream
composer require laravel/jetstream

# Install with Livewire (or use Inertia)
php artisan jetstream:install livewire

# Alternative: Install with Inertia.js
php artisan jetstream:install inertia

# Install and build frontend assets
npm install
npm run build

# Run migrations
php artisan migrate

Jetstream File Structure (Livewire Version)

app/
├── Actions/
│   ├── Fortify/
│   │   ├── CreateNewUser.php
│   │   ├── UpdateUserProfileInformation.php
│   │   └── ...
│   └── Jetstream/
│       ├── DeleteUser.php
│       └── ...
├── Models/
│   └── Team.php
└── View/Components/
    └── AppLayout.php

resources/views/
├── auth/
├── components/
├── layout/
├── navigation-menu.blade.php
└── profile/

Jetstream Features Deep Dive

Team Management

<?php
// app/Models/User.php

namespace App\Models;

use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Jetstream\HasTeams;

class User extends Authenticatable
{
    use HasTeams;

    // Get current team
    public function currentTeam()
    {
        return $this->belongsTo(Team::class, 'current_team_id');
    }

    // Get all teams user owns
    public function ownedTeams()
    {
        return $this->hasMany(Team::class);
    }

    // Get all teams user belongs to
    public function teams()
    {
        return $this->belongsToMany(Team::class)
                    ->withPivot('role')
                    ->withTimestamps();
    }
}

// Usage examples
$user = Auth::user();

// Get current team
$currentTeam = $user->currentTeam;

// Get all teams
$teams = $user->teams;

// Switch team
$user->switchTeam($team);

Customizing User Registration with Jetstream

Update CreateNewUser Action

<?php
// app/Actions/Fortify/CreateNewUser.php

namespace App\Actions\Fortify;

use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\CreatesNewUsers;
use Laravel\Jetstream\Jetstream;

class CreateNewUser implements CreatesNewUsers
{
    use PasswordValidationRules;

    /**
     * Validate and create a newly registered user.
     *
     * @param  array<string, string>  $input
     */
    public function create(array $input): User
    {
        Validator::make($input, [
            'name' => ['required', 'string', 'max:255'],
            'username' => ['required', 'string', 'max:50', 'unique:users'],
            'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
            'phone' => ['nullable', 'string', 'max:20'],
            'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() 
                        ? ['accepted', 'required'] 
                        : '',
            'password' => $this->passwordRules(),
        ])->validate();

        return User::create([
            'name' => $input['name'],
            'username' => $input['username'],
            'email' => $input['email'],
            'phone' => $input['phone'],
            'password' => Hash::make($input['password']),
        ]);
    }
}

Update Registration Form

{{-- resources/views/auth/register.blade.php --}}

<div>
    <x-label for="username" value="{{ __('Username') }}" />
    <x-input 
        id="username" 
        class="block mt-1 w-full" 
        type="text" 
        name="username" 
        :value="old('username')" 
        required 
        autofocus 
        autocomplete="username" 
    />
    <x-input-error for="username" class="mt-2" />
</div>

<div class="mt-4">
    <x-label for="phone" value="{{ __('Phone Number') }}" />
    <x-input 
        id="phone" 
        class="block mt-1 w-full" 
        type="tel" 
        name="phone" 
        :value="old('phone')" 
        autocomplete="tel" 
    />
    <x-input-error for="phone" class="mt-2" />
</div>

Real-World Implementation Examples

Example 1: E-commerce Application with Roles

Custom User Model with Roles
<?php
// app/Models/User.php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Jetstream\HasTeams;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens;
    use HasFactory;
    use HasProfilePhoto;
    use HasTeams;
    use Notifiable;
    use TwoFactorAuthenticatable;

    protected $fillable = [
        'name',
        'username',
        'email',
        'phone',
        'role',
        'password',
    ];

    protected $hidden = [
        'password',
        'remember_token',
        'two_factor_recovery_codes',
        'two_factor_secret',
    ];

    protected $casts = [
        'email_verified_at' => 'datetime',
    ];

    protected $appends = [
        'profile_photo_url',
    ];

    // Role-based methods
    public function isAdmin(): bool
    {
        return $this->role === 'admin';
    }

    public function isVendor(): bool
    {
        return $this->role === 'vendor';
    }

    public function isCustomer(): bool
    {
        return $this->role === 'customer' || $this->role === null;
    }

    public function canManageProducts(): bool
    {
        return $this->isAdmin() || $this->isVendor();
    }

    public function canManageUsers(): bool
    {
        return $this->isAdmin();
    }

    // Team-based permissions
    public function canEditTeam(Team $team): bool
    {
        return $this->ownsTeam($team) || 
               $this->teamRole($team) === 'admin';
    }
}
Role-Based Middleware
<?php
// app/Http/Middleware/CheckRole.php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class CheckRole
{
    public function handle(Request $request, Closure $next, string $role): Response
    {
        if (!$request->user()) {
            return redirect()->route('login');
        }

        if (!$this->hasRole($request->user(), $role)) {
            abort(403, 'Unauthorized action.');
        }

        return $next($request);
    }

    protected function hasRole($user, string $role): bool
    {
        return match($role) {
            'admin' => $user->isAdmin(),
            'vendor' => $user->isVendor(),
            'customer' => $user->isCustomer(),
            default => false,
        };
    }
}

// Register in app/Http/Kernel.php
protected $routeMiddleware = [
    // ...
    'role' => \App\Http\Middleware\CheckRole::class,
];

// Usage in routes
Route::middleware(['auth', 'role:admin'])->group(function () {
    Route::get('/admin', [AdminController::class, 'dashboard']);
});

Route::middleware(['auth', 'role:vendor'])->group(function () {
    Route::get('/vendor', [VendorController::class, 'dashboard']);
});

Example 2: SaaS Application with Team Billing

Team Model with Subscription
<?php
// app/Models/Team.php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Laravel\Jetstream\Team as JetstreamTeam;

class Team extends JetstreamTeam
{
    use HasFactory;

    protected $fillable = [
        'name',
        'personal_team',
        'stripe_id',
        'pm_type',
        'pm_last_four',
        'trial_ends_at',
        'plan',
    ];

    protected $casts = [
        'trial_ends_at' => 'datetime',
    ];

    // Team subscription methods
    public function onTrial(): bool
    {
        return $this->trial_ends_at && $this->trial_ends_at->isFuture();
    }

    public function subscribed(): bool
    {
        return $this->stripe_id !== null;
    }

    public function onPlan($plan): bool
    {
        return $this->plan === $plan;
    }

    public function canAddMembers(): bool
    {
        $maxMembers = match($this->plan) {
            'premium' => 10,
            'basic' => 5,
            default => 3,
        };

        return $this->users()->count() < $maxMembers;
    }

    public function canCreateProjects(): bool
    {
        $maxProjects = match($this->plan) {
            'premium' => null, // Unlimited
            'basic' => 10,
            default => 3,
        };

        return $maxProjects === null || 
               $this->projects()->count() < $maxProjects;
    }
}

Advanced Customizations

Custom Profile Management

Extended Profile Controller
<?php
// app/Http/Controllers/ProfileController.php

namespace App\Http\Controllers;

use App\Http\Requests\ProfileUpdateRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Illuminate\View\View;

class ProfileController extends Controller
{
    /**
     * Display the user's profile form.
     */
    public function edit(Request $request): View
    {
        return view('profile.edit', [
            'user' => $request->user(),
        ]);
    }

    /**
     * Update the user's profile information.
     */
    public function update(ProfileUpdateRequest $request): RedirectResponse
    {
        $request->user()->fill($request->validated());

        if ($request->user()->isDirty('email')) {
            $request->user()->email_verified_at = null;
        }

        $request->user()->save();

        return Redirect::route('profile.edit')->with('status', 'profile-updated');
    }

    /**
     * Update the user's profile photo.
     */
    public function updatePhoto(Request $request): RedirectResponse
    {
        $request->validate([
            'photo' => ['required', 'image', 'mimes:jpeg,png,jpg,gif', 'max:2048'],
        ]);

        $user = $request->user();
        
        // Delete old photo if exists
        if ($user->profile_photo_path) {
            Storage::disk('public')->delete($user->profile_photo_path);
        }

        // Store new photo
        $path = $request->file('photo')->store('profile-photos', 'public');
        
        $user->forceFill([
            'profile_photo_path' => $path,
        ])->save();

        return back()->with('status', 'photo-updated');
    }

    /**
     * Delete the user's account.
     */
    public function destroy(Request $request): RedirectResponse
    {
        $request->validateWithBag('userDeletion', [
            'password' => ['required', 'current_password'],
        ]);

        $user = $request->user();

        Auth::logout();

        $user->delete();

        $request->session()->invalidate();
        $request->session()->regenerateToken();

        return Redirect::to('/');
    }
}
Custom Profile Form Request
<?php
// app/Http/Requests/ProfileUpdateRequest.php

namespace App\Http\Requests;

use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class ProfileUpdateRequest extends FormRequest
{
    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\Rule|array|string>
     */
    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'username' => [
                'required',
                'string',
                'max:50',
                Rule::unique(User::class)->ignore($this->user()->id),
            ],
            'email' => [
                'required',
                'string',
                'lowercase',
                'email',
                'max:255',
                Rule::unique(User::class)->ignore($this->user()->id),
            ],
            'phone' => ['nullable', 'string', 'max:20'],
            'bio' => ['nullable', 'string', 'max:500'],
            'website' => ['nullable', 'url', 'max:255'],
            'location' => ['nullable', 'string', 'max:100'],
        ];
    }

    public function messages(): array
    {
        return [
            'username.unique' => 'This username is already taken.',
            'email.unique' => 'This email address is already registered.',
            'website.url' => 'Please enter a valid website URL.',
        ];
    }
}

API Authentication with Sanctum

API Token Management

<?php
// app/Http/Controllers/APITokenController.php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Hash;
use Laravel\Sanctum\PersonalAccessToken;

class APITokenController extends Controller
{
    /**
     * Get all API tokens for the user.
     */
    public function index(Request $request)
    {
        return $request->user()->tokens->map(function ($token) {
            return [
                'id' => $token->id,
                'name' => $token->name,
                'last_used' => $token->last_used_at,
                'created_at' => $token->created_at,
            ];
        });
    }

    /**
     * Create a new API token.
     */
    public function store(Request $request)
    {
        $request->validate([
            'name' => ['required', 'string', 'max:255'],
            'abilities' => ['sometimes', 'array'],
            'abilities.*' => ['string', 'in:read,write,delete'],
        ]);

        $abilities = $request->abilities ?? ['*'];

        $token = $request->user()->createToken(
            $request->name,
            $abilities,
            now()->addDays(30) // Token expires in 30 days
        );

        return response()->json([
            'token' => $token->plainTextToken,
            'name' => $token->accessToken->name,
            'abilities' => $token->accessToken->abilities,
            'expires_at' => $token->accessToken->expires_at,
        ], 201);
    }

    /**
     * Update an API token.
     */
    public function update(Request $request, $tokenId)
    {
        $request->validate([
            'name' => ['sometimes', 'string', 'max:255'],
            'abilities' => ['sometimes', 'array'],
            'abilities.*' => ['string', 'in:read,write,delete'],
        ]);

        $token = PersonalAccessToken::findOrFail($tokenId);

        if ($token->tokenable_id !== $request->user()->id) {
            abort(403, 'Unauthorized');
        }

        if ($request->has('name')) {
            $token->name = $request->name;
        }

        if ($request->has('abilities')) {
            $token->abilities = $request->abilities;
        }

        $token->save();

        return response()->json([
            'message' => 'Token updated successfully',
            'token' => [
                'id' => $token->id,
                'name' => $token->name,
                'abilities' => $token->abilities,
            ],
        ]);
    }

    /**
     * Delete an API token.
     */
    public function destroy(Request $request, $tokenId)
    {
        $token = PersonalAccessToken::findOrFail($tokenId);

        if ($token->tokenable_id !== $request->user()->id) {
            abort(403, 'Unauthorized');
        }

        $token->delete();

        return response()->json([
            'message' => 'Token deleted successfully',
        ]);
    }
}

Testing Authentication

Authentication Test Examples

<?php
// tests/Feature/AuthenticationTest.php

namespace Tests\Feature;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class AuthenticationTest extends TestCase
{
    use RefreshDatabase;

    public function test_users_can_authenticate_using_the_login_screen()
    {
        $user = User::factory()->create([
            'password' => bcrypt('password123'),
        ]);

        $response = $this->post('/login', [
            'email' => $user->email,
            'password' => 'password123',
        ]);

        $this->assertAuthenticated();
        $response->assertRedirect('/dashboard');
    }

    public function test_users_can_not_authenticate_with_invalid_password()
    {
        $user = User::factory()->create();

        $this->post('/login', [
            'email' => $user->email,
            'password' => 'wrong-password',
        ]);

        $this->assertGuest();
    }

    public function test_users_can_register_with_custom_fields()
    {
        $response = $this->post('/register', [
            'name' => 'Test User',
            'username' => 'testuser',
            'email' => 'test@example.com',
            'phone' => '+1234567890',
            'password' => 'Password123!',
            'password_confirmation' => 'Password123!',
            'terms' => true,
        ]);

        $this->assertAuthenticated();
        $this->assertDatabaseHas('users', [
            'username' => 'testuser',
            'email' => 'test@example.com',
            'phone' => '+1234567890',
        ]);
    }

    public function test_team_creation_upon_registration()
    {
        $user = User::factory()->create();
        
        $this->actingAs($user);

        $response = $this->post('/teams', [
            'name' => 'Test Team',
        ]);

        $this->assertDatabaseHas('teams', [
            'name' => 'Test Team',
            'user_id' => $user->id,
        ]);

        $this->assertEquals('Test Team', $user->fresh()->currentTeam->name);
    }
}

Common Interview Questions & Answers

1. What's the difference between Breeze and Jetstream?

Breeze provides minimal authentication with login, registration, and password reset. Jetstream adds team support, two-factor authentication, API support, and profile management with either Livewire or Inertia.js.

2. How do you add custom fields to user registration?

Update the registration form, modify the User model and migration, then customize the CreateNewUser action (Jetstream) or RegisteredUserController (Breeze) to handle the new fields.

3. What is Laravel Sanctum and how does it work with Jetstream?

Sanctum provides API token authentication. Jetstream includes a UI for managing API tokens, allowing users to create tokens with specific abilities for API access.

4. How do you implement role-based access control?

Add a role field to users, create middleware to check roles, and use gates/policies for authorization. You can extend this with team-based roles in Jetstream.

5. What's the purpose of Fortify in Jetstream?

Fortify is the backend authentication package that handles the actual authentication logic, while Jetstream provides the UI and additional features like teams and profile management.

6. How do you customize the authentication views?

Publish the views using php artisan vendor:publish --tag=jetstream-views (for Jetstream) or modify the views directly in the resources/views/auth directory.

Best Practices

  • Use environment-specific configurations for authentication
  • Implement proper validation for all authentication forms
  • Use secure password rules and encourage strong passwords
  • Implement rate limiting on authentication endpoints
  • Use HTTPS in production for all authentication routes
  • Regularly update dependencies for security patches
  • Test authentication flows thoroughly
  • Implement proper session management and security headers

Deployment Considerations

Environment Configuration

// .env
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_SECURE_COOKIE=true

# For production
FORCE_HTTPS=true
SANCTUM_STATEFUL_DOMAINS=yourdomain.com

Security Headers

// app/Http/Middleware/SecurityHeaders.php
public function handle($request, Closure $next)
{
    $response = $next($request);
    
    $response->headers->set('X-Frame-Options', 'DENY');
    $response->headers->set('X-Content-Type-Options', 'nosniff');
    $response->headers->set('X-XSS-Protection', '1; mode=block');
    $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
    
    return $response;
}

You've now mastered Laravel authentication scaffolding with Breeze and Jetstream! In our next post, we'll explore Laravel Authorization with Gates and Policies: Controlling User Access to learn fine-grained access control.