Laravel Best Practices: Writing Clean and Maintainable Code
Follow industry best practices for Laravel development.
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.
Breeze provides a minimal, simple implementation of all Laravel's authentication features, including:
# 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
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
└── ...
<?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();
}
}
<?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();
}
}
// 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>
// 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();
}
<?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',
];
}
}
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']);
});
}
};
Jetstream provides a more robust authentication scaffold including:
# 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
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/
<?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);
<?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']),
]);
}
}
{{-- 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>
<?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';
}
}
<?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']);
});
<?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;
}
}
<?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('/');
}
}
<?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.',
];
}
}
<?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',
]);
}
}
<?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);
}
}
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.
Update the registration form, modify the User model and migration, then customize the CreateNewUser action (Jetstream) or RegisteredUserController (Breeze) to handle the new fields.
Sanctum provides API token authentication. Jetstream includes a UI for managing API tokens, allowing users to create tokens with specific abilities for API access.
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.
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.
Publish the views using php artisan vendor:publish --tag=jetstream-views (for Jetstream) or modify the views directly in the resources/views/auth directory.
// .env
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_SECURE_COOKIE=true
# For production
FORCE_HTTPS=true
SANCTUM_STATEFUL_DOMAINS=yourdomain.com
// 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.