Web Development 5-8 minutes

Validation in Laravel 13: Form Requests, Custom Rules, and authorize()

Diego Cortés
Diego Cortés
Full Stack Developer & SEO Specialist
Share:
Validation in Laravel 13: Form Requests, Custom Rules, and authorize()

Validation rules duplicated between store and update, security mixed into business logic, and error messages scattered across the controller: that is the mess Laravel 13 Form Requests eliminate. In this guide we build a complete article form with custom rules, authorization, and translated messages, step by step.

Why Validate Outside the Controller

The Problem with Inline Rules in store and update

Validating inside the controller seems harmless at first: a rules array in store, a nearly identical one in update. By the third form you have duplication, the authorize method (who is allowed to do this) ends up as a generic return true, and error messages are customized with ad-hoc validators. The result is code that's hard to maintain and, worse, silent security gaps.

What a Form Request Adds to Your Architecture

A Form Request is a class that encapsulates the entire validation of a request: rules, authorization, messages, and pre and post hooks. Laravel injects it into the controller and handles everything: on failure it redirects with errors in the session; on success the controller receives only validated data. The controller becomes a place to orchestrate, not to validate.

Your First Form Request with Artisan

php artisan make:request and the Class Structure

Artisan generates the class in a second:

php artisan make:request StorePostRequest

The resulting file in app/Http/Requests ships with the two core methods of the system: authorize(), which determines whether the authenticated user can perform the action, and rules(), which returns the validation rules applied to the data. The remaining hooks we'll cover sit on top of those.

rules() and validated(): Clean Data in the Controller

Rules are written as an associative array of field => rules using Laravel's built-in rules (required, email, min, unique, image, array). The controller never touches the validator: it receives the Form Request as a dependency and consumes $request->validated(), which returns only the fields that passed validation, with no extras.

public function store(StorePostRequest $request)
{
    $post = Post::create($request->validated());
    return redirect()->route('posts.show', $post);
}

Authorization Inside the Form Request: authorize()

The authorize() method returns true or false and is evaluated before validation. If it returns false, Laravel responds with a 403 without validating. It's the natural home for permission checks, and it pairs perfectly with policies:

public function authorize(): bool
{
    return $this->user()->can('create', Post::class);
}

Combining Policies with authorize()

Instead of repeating permission logic in every controller, the policy holds the business rule (who can create or edit a post) and authorize() delegates to it. The security of an action lives in one place, and the Form Request just calls it.

Custom Rules with Rule Classes

make:rule and the passes() Method

When a built-in rule doesn't cover your case, create a Rule class with Artisan:

php artisan make:rule ValidSlug

The class defines passes(), which receives the value and returns true or false, and message(), with the error text. You can also register rules as closures inside rules() when the logic is trivial.

public function passes($attribute, $value): bool
{
    return preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $value) === 1;
}

public function message(): string
{
    return 'The slug may only contain lowercase letters, numbers, and dashes.';
}

Validating a Unique Slug in store and update

The classic: the slug must be unique, but when editing you need to ignore the current record. The unique rule accepts an ignore with the model id:

use Illuminate\Validation\Rule;

'slug' => ['required', new ValidSlug, Rule::unique('posts')->ignore($this->post?->id)]

With the nullsafe operator, the same rule works for creating (no post, nothing to ignore) and editing (the current id is skipped).

Conditional Validation and Prepared Data

required_if, required_unless, and Closures

Dynamic forms need rules that depend on other fields. Conditional rules solve most cases: required_if:published,true makes a field mandatory only when another field meets a condition; required_unless and required_with cover the rest. For more complex logic, a closure receives the validator and adds rules on demand.

'published_at' => 'required_if:status,published|date',
'tags' => 'nullable|array|max:5'

prepareForValidation(): Normalize Before Validating

prepareForValidation() runs right before validation begins and lets you manipulate the input: generate the slug from the title, trim whitespace, normalize casing. It's the standard place for normalization, and also for adapting the request to different keys between create and update:

protected function prepareForValidation(): void
{
    $this->merge([
        'slug' => str($this->title)->slug(),
    ]);
}

After-Validation Hooks: passedValidation() and withValidator()

There is life after validation too. passedValidation() runs only when validation passes: perfect for final normalization or side effects. withValidator() receives the validator before it resolves and lets you add callbacks, such as after() for checks that depend on the database:

public function withValidator(Validator $validator): void
{
    $validator->after(function ($validator) {
        if (Post::where('slug', $this->slug)->exists()) {
            $validator->errors()->add('slug', 'A post with that slug already exists.');
        }
    });
}

Error Messages and Translations

messages() and the lang/validation.php File

For custom messages, the messages() method returns a field.rule => text array. The global alternative is the lang/validation.php file, which centralizes the message of every built-in rule and supports placeholders like :attribute or :min. The typical combo: generic messages in the language file and one-off exceptions in messages().

public function messages(): array
{
    return [
        'title.required' => 'The title is required.',
        'slug.unique' => 'That slug is already taken.',
    ];
}

Showing Errors in Blade with @error

The @error directive renders the message only when that field has an error, and old() refills the form after a failure:

<input name="title" value="{{ old('title') }}">
@error('title')
    <p class="text-red-600">{{ $message }}</p>
@enderror

Sharing Rules Across Form Requests

Store and update usually share most of their rules. To avoid duplication, extract the base into a parent class or a trait (e.g., PostRules with a common baseRules() method) and let each Form Request add only its differences: update adds the id ignore; store adds the required image. The result is small classes that change for a single reason.

trait PostRules
{
    protected function baseRules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'body' => ['required', 'string'],
            'tags' => ['nullable', 'array', 'max:5'],
        ];
    }
}

Conclusion

Form Requests turn validation into an explicit layer of your application: rules, authorization, messages, and hooks in one class per form, reusable between store and update. The controller drops to a single line, and security no longer depends on a forgotten return true. If you work with Laravel 13, this is the pattern that separates a maintainable form from one you're afraid to touch. Keep exploring the blog for more Laravel 13 guides: validation, authorization, and robust forms are only the beginning.

Categories