Laravel Best Practices: Writing Clean and Maintainable Code
Follow industry best practices for Laravel development.
Cross-Site Request Forgery (CSRF) is a web security vulnerability that allows an attacker to trick users into executing unwanted actions on a web application in which they're authenticated.
Laravel automatically generates a CSRF "token" for each active user session. This token is used to verify that authenticated users are the ones actually making requests to the application.
<?php
// app/Http/Middleware/VerifyCsrfToken.php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
class VerifyCsrfToken extends Middleware
{
/**
* The URIs that should be excluded from CSRF verification.
*
* @var array<int, string>
*/
protected $except = [
// Add routes that don't need CSRF protection
'stripe/*',
'webhook/*',
];
}
// Simplified version of what happens:
public function handle($request, Closure $next)
{
if (
$this->isReading($request) ||
$this->runningUnitTests() ||
$this->inExceptArray($request) ||
$this->tokensMatch($request)
) {
return $next($request);
}
throw new TokenMismatchException('CSRF token mismatch.');
}
{{-- resources/views/posts/create.blade.php --}}
<form method="POST" action="/posts">
@csrf {{-- CSRF Protection --}}
<div class="form-group">
<label for="title">Title</label>
<input type="text" name="title" id="title" value="{{ old('title') }}" required>
@error('title')
<span class="error">{{ $message }}</span>
@enderror
</div>
<div class="form-group">
<label for="content">Content</label>
<textarea name="content" id="content" required>{{ old('content') }}</textarea>
@error('content')
<span class="error">{{ $message }}</span>
@enderror
</div>
<button type="submit">Create Post</button>
</form>
<form method="POST" action="/posts">
<input type="hidden" name="_token" value="X8e1zA2b3c4d5e6f7g8h9i0j...">
<!-- Rest of the form -->
</form>
{{-- Step 1: Personal Information --}}
<form method="POST" action="/registration/step1">
@csrf
<input type="hidden" name="form_step" value="1">
<div class="form-group">
<label for="name">Full Name</label>
<input type="text" name="name" id="name" value="{{ old('name') }}" required>
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" name="email" id="email" value="{{ old('email') }}" required>
</div>
<button type="submit">Next Step</button>
</form>
{{-- Step 2: Address Information --}}
<form method="POST" action="/registration/step2">
@csrf
<input type="hidden" name="form_step" value="2">
<input type="hidden" name="name" value="{{ old('name') }}">
<input type="hidden" name="email" value="{{ old('email') }}">
<div class="form-group">
<label for="address">Address</label>
<textarea name="address" id="address" required>{{ old('address') }}</textarea>
</div>
<button type="submit">Complete Registration</button>
</form>
<form method="POST" action="/upload" enctype="multipart/form-data">
@csrf
<div class="form-group">
<label for="document">Upload Document</label>
<input type="file" name="document" id="document" accept=".pdf,.doc,.docx" required>
@error('document')
<span class="error">{{ $message }}</span>
@enderror
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea name="description" id="description">{{ old('description') }}</textarea>
</div>
<button type="submit">Upload File</button>
</form>
{{-- Using Laravel Collective --}}
{!! Form::open(['url' => '/posts', 'method' => 'POST']) !!}
{!! Form::token() !!} {{-- CSRF token --}}
{!! Form::text('title', old('title'), ['class' => 'form-control']) !!}
{!! Form::textarea('content', old('content'), ['class' => 'form-control']) !!}
{!! Form::submit('Create Post', ['class' => 'btn btn-primary']) !!}
{!! Form::close() !!}
{{-- resources/views/layouts/app.blade.php --}}
<!DOCTYPE html>
<html>
<head>
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>My Application</title>
</head>
<body>
<!-- Your application content -->
</body>
</html>
// resources/js/csrf.js
// Set up CSRF token for all AJAX requests
$.ajaxSetup({
headers: {
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
}
});
// Alternative: Get token directly
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
// Example: Creating a post via AJAX
$('#post-form').on('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
$.ajax({
url: '/posts',
method: 'POST',
data: formData,
processData: false,
contentType: false,
success: function(response) {
// Handle success
alert('Post created successfully!');
window.location.href = '/posts';
},
error: function(xhr) {
// Handle errors
if (xhr.status === 419) { // CSRF token mismatch
alert('Session expired. Please refresh the page.');
location.reload();
} else {
alert('Error creating post: ' + xhr.responseJSON.message);
}
}
});
});
// resources/js/app.js
import axios from 'axios';
// Set CSRF token for all requests
const csrfToken = document.head.querySelector('meta[name="csrf-token"]');
if (csrfToken) {
axios.defaults.headers.common['X-CSRF-TOKEN'] = csrfToken.content;
} else {
console.error('CSRF token not found');
}
// Handle token expiration
axios.interceptors.response.use(
response => response,
error => {
if (error.response.status === 419) {
// CSRF token mismatch - likely session expired
localStorage.setItem('redirect_url', window.location.href);
window.location.href = '/login';
}
return Promise.reject(error);
}
);
<template>
<form @submit.prevent="submitForm">
<div class="form-group">
<label for="title">Title</label>
<input
type="text"
id="title"
v-model="form.title"
:class="{ 'error': errors.title }"
>
<span v-if="errors.title" class="error-text">{{ errors.title[0] }}</span>
</div>
<div class="form-group">
<label for="content">Content</label>
<textarea
id="content"
v-model="form.content"
:class="{ 'error': errors.content }"
></textarea>
<span v-if="errors.content" class="error-text">{{ errors.content[0] }}</span>
</div>
<button type="submit" :disabled="loading">
{{ loading ? 'Creating...' : 'Create Post' }}
</button>
</form>
</template>
<script>
export default {
data() {
return {
form: {
title: '',
content: ''
},
errors: {},
loading: false
}
},
methods: {
async submitForm() {
this.loading = true;
this.errors = {};
try {
const response = await axios.post('/api/posts', this.form);
// Success - redirect or show message
this.$router.push('/posts/' + response.data.id);
} catch (error) {
if (error.response.status === 422) {
// Validation errors
this.errors = error.response.data.errors;
} else if (error.response.status === 419) {
// CSRF token mismatch
alert('Your session has expired. Please refresh the page.');
window.location.reload();
} else {
// Other errors
alert('An error occurred. Please try again.');
}
} finally {
this.loading = false;
}
}
}
}
</script>
// resources/js/components/PostForm.jsx
import React, { useState } from 'react';
import axios from 'axios';
const PostForm = () => {
const [form, setForm] = useState({
title: '',
content: ''
});
const [errors, setErrors] = useState({});
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setErrors({});
try {
const response = await axios.post('/api/posts', form);
// Success - redirect or show message
window.location.href = `/posts/${response.data.id}`;
} catch (error) {
if (error.response.status === 422) {
setErrors(error.response.data.errors);
} else if (error.response.status === 419) {
alert('Session expired. Please refresh the page.');
window.location.reload();
} else {
alert('Error creating post');
}
} finally {
setLoading(false);
}
};
const handleChange = (e) => {
setForm({
...form,
[e.target.name]: e.target.value
});
};
return (
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="title">Title</label>
<input
type="text"
id="title"
name="title"
value={form.title}
onChange={handleChange}
className={errors.title ? 'error' : ''}
/>
{errors.title && <span className="error-text">{errors.title[0]}</span>}
</div>
<div className="form-group">
<label htmlFor="content">Content</label>
<textarea
id="content"
name="content"
value={form.content}
onChange={handleChange}
className={errors.content ? 'error' : ''}
/>
{errors.content && <span className="error-text">{errors.content[0]}</span>}
</div>
<button type="submit" disabled={loading}>
{loading ? 'Creating...' : 'Create Post'}
</button>
</form>
);
};
export default PostForm;
For stateless APIs (like mobile apps, third-party integrations), CSRF protection is typically disabled since there's no session-based authentication.
<?php
// app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
'api/*',
'mobile/*',
];
<?php
// routes/web.php
// SPA routes - protected by CSRF
Route::middleware(['web'])->group(function () {
Route::get('/{any}', function () {
return view('app'); // Your SPA entry point
})->where('any', '.*');
});
// API routes for SPA - also protected by CSRF
Route::middleware(['web', 'auth'])->prefix('api')->group(function () {
Route::get('/user', function (Request $request) {
return $request->user();
});
Route::apiResource('posts', PostController::class);
});
<?php
// app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
// Payment webhooks (they don't use sessions)
'stripe/webhook',
'paypal/webhook',
// External API endpoints
'api/v1/webhook/*',
// File upload endpoints (if needed)
'upload/temporary',
// Health check endpoints
'health',
// Specific routes that need to be public
'contact/form',
// Remember: Only exclude when absolutely necessary!
];
<?php
// app/Http/Middleware/VerifyCsrfToken.php
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
class VerifyCsrfToken extends Middleware
{
/**
* Get the CSRF token from the request.
*
* @param \Illuminate\Http\Request $request
* @return string
*/
protected function getTokenFromRequest($request)
{
// Check header first
$token = $request->header('X-CSRF-TOKEN');
// Then check input
if (!$token && $token = $request->input('_token')) {
return $token;
}
// Finally check session
if (!$token) {
$token = $request->session()->token();
}
return $token;
}
/**
* Add the CSRF token to the response cookies.
*/
protected function addCookieToResponse($request, $response)
{
$config = config('session');
$response->headers->setCookie(
new Cookie(
'XSRF-TOKEN',
$request->session()->token(),
time() + 60 * 120,
$config['path'],
$config['domain'],
$config['secure'],
false,
false,
$config['same_site'] ?? null
)
);
return $response;
}
}
<?php
// app/Http/Controllers/DownloadController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class DownloadController extends Controller
{
public function download(Request $request, $fileId)
{
// Verify CSRF token for sensitive downloads
if (!$request->hasValidSignature()) {
abort(401, 'Invalid download link');
}
$file = \App\Models\File::findOrFail($fileId);
// Additional authorization check
if (!$request->user()->can('download', $file)) {
abort(403, 'Unauthorized to download this file');
}
return Storage::download($file->path, $file->original_name);
}
public function generateDownloadLink(Request $request, $fileId)
{
$this->authorize('download', \App\Models\File::findOrFail($fileId));
// Generate signed URL that expires in 30 minutes
$url = URL::temporarySignedRoute(
'files.download',
now()->addMinutes(30),
['file' => $fileId]
);
return response()->json(['download_url' => $url]);
}
}
<?php
// tests/Feature/CsrfProtectionTest.php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;
class CsrfProtectionTest extends TestCase
{
use RefreshDatabase;
public function test_csrf_protection_blocks_requests_without_token()
{
$user = \App\Models\User::factory()->create();
$this->actingAs($user);
$response = $this->post('/posts', [
'title' => 'Test Post',
'content' => 'Test Content',
// Missing _token field
]);
$response->assertStatus(419); // CSRF token mismatch
}
public function test_csrf_protection_allows_requests_with_valid_token()
{
$user = \App\Models\User::factory()->create();
$this->actingAs($user);
$response = $this->post('/posts', [
'title' => 'Test Post',
'content' => 'Test Content',
'_token' => csrf_token(), // Include valid CSRF token
]);
$response->assertRedirect(); // Should redirect on success
$this->assertDatabaseHas('posts', ['title' => 'Test Post']);
}
public function test_excluded_routes_dont_require_csrf_token()
{
$response = $this->post('/stripe/webhook', [
'type' => 'payment_intent.succeeded',
'data' => ['object' => ['id' => 'pi_123']]
]);
$response->assertStatus(200); // Should not return 419
}
public function test_csrf_token_in_meta_tag()
{
$user = \App\Models\User::factory()->create();
$response = $this->actingAs($user)
->get('/posts/create');
$response->assertSee('csrf-token');
$response->assertSee(csrf_token());
}
}
// tests/Unit/CsrfTokenTest.php
class CsrfTokenTest extends TestCase
{
public function test_csrf_token_generation()
{
$token1 = csrf_token();
$token2 = csrf_token();
// Tokens should be the same in same request
$this->assertEquals($token1, $token2);
// Token should be 40 characters (SHA-1 hash)
$this->assertEquals(40, strlen($token1));
}
public function test_csrf_token_session_storage()
{
$session = app('session');
$token = $session->token();
$this->assertNotNull($token);
$this->assertIsString($token);
}
}
// Solution: Ensure CSRF token is included
<form method="POST">
@csrf
<!-- form fields -->
</form>
// For AJAX requests, include token in headers
$.ajaxSetup({
headers: {
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
}
});
// Solution: Refresh CSRF token after authentication
$('#login-form').on('submit', function(e) {
e.preventDefault();
$.post('/login', $(this).serialize())
.done(function(response) {
// Update CSRF token after successful login
$('meta[name="csrf-token"]').attr('content', response.new_csrf_token);
// Update axios default header if using
axios.defaults.headers.common['X-CSRF-TOKEN'] = response.new_csrf_token;
});
});
// Solution: Handle token regeneration gracefully
public function login(Request $request)
{
// ... authentication logic
// Regenerate session after login
$request->session()->regenerate();
return response()->json([
'success' => true,
'new_csrf_token' => csrf_token()
]);
}
// Never disable CSRF protection globally
// Only exclude specific, carefully considered routes
protected $except = [
// Only webhooks and truly public endpoints
'stripe/webhook',
'health-check',
];
{{-- Always use HTTPS in production --}}
@production
<meta name="csrf-token" content="{{ csrf_token() }}" secure>
@endproduction
// config/session.php
'secure' => env('SESSION_SECURE_COOKIE', true),
'http_only' => true,
'same_site' => 'lax', // or 'strict'
// Regenerate session ID periodically
$request->session()->regenerate();
// Invalidate old sessions
$request->session()->invalidate();
CSRF (Cross-Site Request Forgery) allows attackers to trick users into performing unwanted actions on websites where they're authenticated. It's dangerous because it can lead to unauthorized transactions, data changes, or account takeovers.
Laravel generates a unique CSRF token for each user session and verifies this token on all state-changing requests (POST, PUT, PATCH, DELETE). The VerifyCsrfToken middleware automatically handles this protection.
Only disable CSRF for stateless endpoints like webhooks, external APIs, or public forms that don't use session authentication. Always keep it enabled for authenticated routes.
@csrf is a Blade directive that generates a hidden input field with the token. csrf_token() returns the token string that you can use in JavaScript or custom forms.
Include the token in a meta tag and set it as a default header for AJAX requests. For SPAs, ensure your authentication flow properly handles token regeneration.
Laravel throws a TokenMismatchException which results in a 419 HTTP status code. The user typically sees a "Page Expired" error.
// For high-traffic applications, consider token caching
public function getCsrfToken()
{
return Cache::remember('csrf_token_' . session()->getId(), 3600, function () {
return Str::random(40);
});
}
// Use database or redis sessions for better performance
// config/session.php
'driver' => env('SESSION_DRIVER', 'database'),
// For load-balanced applications
'cookie' => env('SESSION_COOKIE', 'laravel_session'),
'domain' => env('SESSION_DOMAIN', '.yourdomain.com'),
You've now mastered Laravel CSRF protection and can secure your applications against cross-site request forgery attacks! In our next post, we'll explore Laravel Session Management: A Quick Guide to Storing User Data to learn how to manage user state effectively.