Lesson 7 — Form Requests, Validation and CSRF: build a task preview using lesson 6's layout. No database writes or accounts are involved yet. We will understand valid input, error responses and request protection before implementing CRUD.
1. Define the input contract
A preview requires a title of at most 120 characters, an optional description of at most 2,000 characters and a priority from low, normal or high. It does not accept owner_id, is_admin or administrative state. HTML required and maxlength improve usability, but server validation remains necessary because clients can bypass the form.
Validation is not authorization. Correctly shaped data does not give its sender permission to create tasks in another person's project. authorize() returns true here because this is a public, non-persistent preview. Revisit permissions before introducing database writes rather than blindly copying this decision.
2. Create the Form Request
php artisan make:request PreviewTaskRequest
Use this in app/Http/Requests/PreviewTaskRequest.php:
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class PreviewTaskRequest extends FormRequest
{
public function authorize(): bool
{
return true; // Public preview only, not permission to create persisted tasks.
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:120'],
'description' => ['nullable', 'string', 'max:2000'],
'priority' => ['required', Rule::in(['low', 'normal', 'high'])],
];
}
}nullable permits an empty description after empty-string normalization. Rule::in restricts the allowed values instead of merely requiring a string. We do not use exists or unique before the domain tables exist. Consult the validation documentation for additional rules.
3. Pass only validated input to the view
Create app/Http/Controllers/TaskPreviewController.php:
<?php
namespace App\Http\Controllers;
use App\Http\Requests\PreviewTaskRequest;
use Illuminate\Contracts\View\View;
class TaskPreviewController extends Controller
{
public function create(): View
{
return view('tasks.create');
}
public function preview(PreviewTaskRequest $request): View
{
return view('tasks.preview', ['task' => $request->validated()]);
}
}Add this import and these routes to routes/web.php, preserving earlier routes:
use App\Http\Controllers\TaskPreviewController;
Route::get('/tasks/create', [TaskPreviewController::class, 'create'])->name('tasks.create');
Route::post('/tasks/preview', [TaskPreviewController::class, 'preview'])->name('tasks.preview');The Form Request is resolved before preview executes. validated() returns rule-covered input rather than everything in all(). This action directly returns a preview, so refreshing may prompt the browser to resubmit the POST. Since nothing is persisted, we have not yet introduced redirect-after-save behavior.
4. Render errors and old input
Create resources/views/tasks/create.blade.php:
<x-layout title="Preview a task">
<h1>Preview a task</h1>
<p>This form does not save a task.</p>
<form method="POST" action="{{ route('tasks.preview') }}">
@csrf
<label for="title">Title</label>
<input id="title" name="title" value="{{ old('title') }}" required maxlength="120" aria-describedby="title-error">
<p id="title-error">@error('title') {{ $message }} @enderror</p>
<label for="description">Description</label>
<textarea id="description" name="description" maxlength="2000" aria-describedby="description-error">{{ old('description') }}</textarea>
<p id="description-error">@error('description') {{ $message }} @enderror</p>
<label for="priority">Priority</label>
<select id="priority" name="priority" aria-describedby="priority-error">
@foreach (['low', 'normal', 'high'] as $priority)
<option value="{{ $priority }}" @selected(old('priority', 'normal') === $priority)>{{ ucfirst($priority) }}</option>
@endforeach
</select>
<p id="priority-error">@error('priority') {{ $message }} @enderror</p>
<button type="submit">Preview only</button>
</form>
</x-layout>Create resources/views/tasks/preview.blade.php:
<x-layout title="Task preview">
<h1>Task preview — not saved</h1>
<h2>{{ $task['title'] }}</h2>
<p>{{ $task['description'] ?? 'No description' }}</p>
<p>Priority: {{ $task['priority'] }}</p>
<a href="{{ route('tasks.create') }}">Start another preview</a>
</x-layout>old() restores input after failed validation and @error displays field-specific messages. Do not refill passwords or secrets this way. Blade still escapes the title and description: passing validation does not make HTML-like input safe for raw output.
5. Understand Laravel 13 CSRF behavior
@csrf supplies a session-bound token field. Web routes receive request-forgery protection. Laravel 13's PreventRequestForgery supports Sec-Fetch-Site origin checks and token fallback. Therefore, not every tokenless POST necessarily returns 419: a request accepted through origin verification takes a different path. See CSRF protection.
Keep @csrf rather than exempting the route to suppress errors. For a 419 response, investigate session cookies, hostnames, HTTPS and expired sessions. CSRF protection does not replace authentication, policies or XSS prevention. Invalid input expecting JSON receives 422, while a normal invalid form submission redirects with an error bag.
6. Test validation and token fallback separately
Create tests/Feature/TaskPreviewTest.php with these core checks:
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Tests\TestCase;
class TaskPreviewTest extends TestCase
{
public function test_invalid_form_redirects_with_errors_and_old_input(): void
{
$this->from('/tasks/create')->post('/tasks/preview', ['title' => 'Keep me', 'priority' => 'urgent'])
->assertRedirect('/tasks/create')->assertSessionHasErrors('priority')
->assertSessionHasInput('title', 'Keep me');
}
public function test_invalid_json_returns_422(): void
{
$this->postJson('/tasks/preview', ['title' => str_repeat('x', 121), 'priority' => 'high'])
->assertUnprocessable()->assertJsonValidationErrors('title');
}
public function test_csrf_fallback_rejects_missing_token_and_accepts_matching_token(): void
{
$this->app->bind(PreventRequestForgery::class, EnforcedRequestForgery::class);
$this->post('/tasks/preview', ['title' => 'Review', 'priority' => 'normal'])->assertStatus(419);
$this->withSession(['_token' => 'test-session-token'])
->post('/tasks/preview', ['_token' => 'test-session-token', 'title' => 'Review', 'priority' => 'normal'])
->assertOk();
}
}
class EnforcedRequestForgery extends PreventRequestForgery
{
protected function runningUnitTests()
{
return false;
}
}Laravel normally bypasses CSRF during tests. EnforcedRequestForgery is a test-only subclass disabling that bypass; do not install it as application middleware. These requests omit Sec-Fetch-Site, so they exercise token fallback. The token is fake test data, not an application secret.
npm run build
php artisan test --filter=TaskPreviewTest
php artisan test
The full example also checks that the form renders _token and that preview data excludes unrelated fields while escaping the title. The entire suite passed 14 tests with 39 assertions; this is not the assertion count of only the shortened test listing above.
7. Exercise and completion
Open /tasks/create, omit the title, submit priority=urgent with an HTTP client and try markup in the title. Distinguish an invalid form's 302, invalid JSON's 422 and token fallback's 419. Confirm old input survives validation errors but no task is written to the database.
Extend tests with 120- and 121-character titles, empty descriptions and fields outside the contract. Do not turn this public preview into a write endpoint without authentication, ownership and policies. Navigation: Lesson 6 · Roadmap. Next comes migration design and safe rollback.




No comments yet. Be the first to share your thoughts.