Form Requests and Custom Validation Rules

Form Requests and Custom Validation Rules

Reading Time: 5 minutes

Series: Every Laravel Project Should Have These Building Blocks 
Part: 6 of 35 | Level: Beginner–Intermediate | Prerequisites: Thin Controllers


What You’ll Learn

  • Why validation belongs in Form Requests, not controllers
  • How to write authorize() properly
  • How to access validated data safely with $request->validated()
  • How to share validation rules across multiple requests
  • How to build custom Rule classes for domain-specific validation
  • After-validation hooks with passedValidation()

The Problem with Inline Validation

Most Laravel tutorials start with this:

public function store(Request $request): RedirectResponse
{
    $validated = $request->validate([
        'name'  => 'required|string|max:255',
        'email' => 'required|email|unique:users',
        'price' => 'required|numeric|min:0',
    ]);

    // ...
}

This works for small apps. As your application grows, problems appear:

  1. The same rules get copy-pasted into store() and update() methods
  2. The controller method grows to 50 lines
  3. Testing validation requires making HTTP requests
  4. Authorization logic gets mixed in with validation

Form Requests solve all of these.


Anatomy of a Form Request

<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Enums\OrderStatus;
use App\Models\Order;
use App\Rules\ValidCouponCode;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class StoreOrderRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return auth()->check(); // logged-in users can place orders
    }

    /**
     * Get the validation rules.
     *
     * @return array<string, mixed>
     */
    public function rules(): array
    {
        return [
            'first_name'         => ['required', 'string', 'max:100'],
            'last_name'          => ['required', 'string', 'max:100'],
            'email'              => ['required', 'email:rfc,dns', 'max:255'],
            'address'            => ['nullable', 'string', 'max:500'],
            'city'               => ['nullable', 'string', 'max:100'],
            'postal_code'        => ['nullable', 'string', 'max:20'],
            'coupon_code'        => ['nullable', 'string', new ValidCouponCode()],
            'payment_method_id'  => ['required', 'string'],
        ];
    }

    /**
     * Custom error messages.
     *
     * @return array<string, string>
     */
    public function messages(): array
    {
        return [
            'email.email'                => 'Please provide a valid email address.',
            'payment_method_id.required' => 'A payment method is required.',
        ];
    }
}

authorize() — Not Just return true

The authorize() method is where you check whether this user is allowed to perform this action. Most developers write return true here and call it a day. That’s a missed opportunity.

// ❌ Skipping authorization
public function authorize(): bool
{
    return true; // anyone can do anything?
}

// ✅ Proper authorization
public function authorize(): bool
{
    // Only the order owner can update the order
    $order = $this->route('order'); // get the route model binding
    return $this->user()->can('update', $order);
}

// ✅ Using auth guard explicitly in multi-guard apps
public function authorize(): bool
{
    return auth('admin')->check();
}

// ✅ More complex authorization
public function authorize(): bool
{
    $order = $this->route('order');

    // Admins can update any order; users can only update their own
    if (auth('admin')->check()) {
        return true;
    }

    return $order->user_id === auth()->id();
}

When authorization fails, Laravel returns a 403 response automatically.


$request->validated() — The Only Safe Data Source

Always use $request->validated() to access form data in your controllers. Never use $request->all()$request->input(), or $request->only() directly.

// ❌ Unsafe — includes unvalidated fields
$order = Order::create($request->all());

// ❌ Still unsafe — no guarantee these fields passed validation
$order = Order::create($request->only(['name', 'email', 'address']));

// ✅ Only validated, safe data
$order = Order::create($request->validated());

// ✅ Get a specific validated field
$email = $request->validated('email');

// ✅ Get validated data with a merge
$data = array_merge($request->validated(), [
    'user_id'    => auth()->id(),
    'ordered_at' => now(),
]);

validated() returns only the fields that passed validation. If a field wasn’t in your rules() array, it won’t be in validated() — even if it was in the request.


Sharing Rules with prepareForValidation()

Sometimes you need to transform incoming data before validation runs:

class StoreVendorRequest extends FormRequest
{
    protected function prepareForValidation(): void
    {
        // Normalize phone number before validation
        $this->merge([
            'phone' => preg_replace('/\D/', '', $this->phone ?? ''),
            'email' => strtolower(trim($this->email ?? '')),
        ]);
    }

    public function rules(): array
    {
        return [
            'phone' => ['required', 'digits:10'],
            'email' => ['required', 'email'],
        ];
    }
}

After-Validation Hook: passedValidation()

Run code after validation passes but before the controller receives the request:

class StoreServiceRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'start_date' => ['required', 'date'],
            'end_date'   => ['required', 'date', 'after:start_date'],
        ];
    }

    protected function passedValidation(): void
    {
        // Cast dates to Carbon instances
        $this->merge([
            'start_date' => \Carbon\Carbon::parse($this->start_date),
            'end_date'   => \Carbon\Carbon::parse($this->end_date),
        ]);
    }
}

Reusing Rules with Base Requests

When multiple requests share common rules, create a base class:

// app/Http/Requests/BaseOrderRequest.php
abstract class BaseOrderRequest extends FormRequest
{
    /**
     * @return array<string, mixed>
     */
    protected function customerRules(): array
    {
        return [
            'first_name' => ['required', 'string', 'max:100'],
            'last_name'  => ['required', 'string', 'max:100'],
            'email'      => ['required', 'email:rfc,dns'],
            'address'    => ['nullable', 'string', 'max:500'],
        ];
    }
}

// Store — requires all customer fields
class StoreOrderRequest extends BaseOrderRequest
{
    public function rules(): array
    {
        return array_merge($this->customerRules(), [
            'payment_method_id' => ['required', 'string'],
        ]);
    }
}

// Update — customer fields are optional
class UpdateOrderRequest extends BaseOrderRequest
{
    public function rules(): array
    {
        return array_merge(
            array_map(
                fn ($rules) => array_map(
                    fn ($r) => $r === 'required' ? 'sometimes' : $r,
                    $rules
                ),
                $this->customerRules()
            ),
            ['status' => ['sometimes', 'string', Rule::in(['pending', 'processing', 'shipped'])]]
        );
    }
}

Custom Validation Rule Classes

When a validation rule is too complex for Laravel’s built-ins, or you need the same rule in multiple requests, create a Ruleclass:

// app/Rules/ValidCouponCode.php
<?php

declare(strict_types=1);

namespace App\Rules;

use App\Models\Coupon;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class ValidCouponCode implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $coupon = Coupon::where('code', strtoupper($value))
            ->where('active', true)
            ->first();

        if (! $coupon) {
            $fail('The coupon code :attribute is invalid or expired.');
            return;
        }

        if ($coupon->isExpired()) {
            $fail('The coupon code :attribute has expired.');
            return;
        }

        if ($coupon->hasReachedUsageLimit()) {
            $fail('The coupon code :attribute has reached its usage limit.');
        }
    }
}

Use it in any Form Request:

'coupon_code' => ['nullable', 'string', new ValidCouponCode()],

Rule classes are composable. You can also pass data into them via the constructor:

// app/Rules/UniqueForUser.php
class UniqueForUser implements ValidationRule
{
    public function __construct(
        private readonly string $table,
        private readonly string $column = 'name',
        private readonly ?int $exceptId = null,
    ) {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $query = DB::table($this->table)
            ->where($this->column, $value)
            ->where('user_id', auth()->id());

        if ($this->exceptId) {
            $query->where('id', '!=', $this->exceptId);
        }

        if ($query->exists()) {
            $fail("You already have a {$this->table} with this {$this->column}.");
        }
    }
}

// Usage
'name' => ['required', 'string', new UniqueForUser('playlists', 'name', $playlist->id)],

Array Notation for Rules

Always use array notation for rules, not pipe-delimited strings:

// ❌ Pipe-delimited — hard to read, hard to add conditions
'email' => 'required|email:rfc,dns|max:255|unique:users,email',

// ✅ Array notation — readable, easy to add Rule objects conditionally
'email' => [
    'required',
    'email:rfc,dns',
    'max:255',
    Rule::unique('users', 'email')->ignore($this->user()),
],

Key Takeaways

  • Move all validation out of controllers into Form Requests — one request class per form.
  • Use $request->validated() everywhere. Never use $request->all() or $request->input().
  • authorize() is not optional. Use it to check permissions on the specific resource being acted on.
  • prepareForValidation() normalizes data before validation; passedValidation() transforms after.
  • Custom Rule classes belong in app/Rules/ — create one for any rule that appears more than once.
  • Use array notation for rules, not pipe-delimited strings.


Frequently Asked Questions

Can I reuse a Form Request for both create and update? Sometimes. Use the same Form Request if the rules are identical. Split them when the rules differ — StorePostRequest with required fields, UpdatePostRequest with sometimes|required. Trying to make one class handle both with if ($this->isMethod('PUT')) conditionals gets messy fast.

What if a validation rule needs data from the database? Use a custom Rule class (php artisan make:rule UniqueActiveUsername). The validate() method on a Rule class can query the database. This keeps the Form Request clean and the database logic in one testable place.

Should authorize() make a database query? It can. $this->user()->can('update', $this->route('post')) triggers the Post policy, which may load related models. For most cases, this is fine. If authorization requires complex queries on every request, consider caching the result or deferring the check to the controller where you have more context.


Tips and Gotchas

⚠️ Warning: Never use $request->input('field') or $request->field in a controller. Always use $request->validated(). Un-validated input means you might process data that failed your own rules — worse, it’s a mass assignment vulnerability waiting to happen.

💡 Tip: The authorize() method in a FormRequest is the right place for simple policy checks. If it returns false, Laravel throws a 403 automatically. You don’t need $this->authorize() in the controller too — pick one place.

🔥 Expert Note: Custom Rule classes (php artisan make:rule) keep rules() readable. A rule named UniqueSlugForUser is infinitely more readable than an inline closure with 8 lines of logic. Name rules after what they validate, not how.

Further Reading


← Thin Controllers | Next: Route Organization →

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

One Comment