Laravel validation is one of those features developers learn early and then underestimate for years.

You start with required, email, and max:255. Then the app grows, rules become conditional, inputs get nested, APIs need clear errors, and suddenly validation is not just "checking fields." It's the border wall between messy user input and your clean application logic.

And that border matters.

Validation Is A Boundary, Not A Chore

A lot of codebases treat validation as a controller tax.

The request comes in, you slap some rules on it, then move on. But validation can do more than block bad input. It can shape the data your application is allowed to trust.

This is the difference between a messy front porch and a clean entryway. The outside world can be muddy. Your house doesn't have to be.

Here's a simple Form Request:

PHP app/Http/Requests/CreateInvoiceRequest.php
class CreateInvoiceRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'customer_id' => ['required', 'integer', 'exists:customers,id'],
            'due_date' => ['required', 'date', 'after_or_equal:today'],
            'line_items' => ['required', 'array', 'min:1'],
            'line_items.*.description' => ['required', 'string', 'max:120'],
            'line_items.*.amount_cents' => ['required', 'integer', 'min:1'],
        ];
    }
}

This doesn't just validate. It defines the shape of a valid invoice request.

Form Requests Keep Controllers Honest

Controllers should coordinate. They should not become validation encyclopedias.

A Form Request gives validation its own home:

PHP app/Http/Controllers/InvoiceController.php
public function store(CreateInvoiceRequest $request)
{
    $invoice = $this->createInvoice->handle(
        $request->user(),
        $request->validated()
    );

    return new InvoiceResource($invoice);
}

The controller no longer cares how invoice input is validated. It receives trusted data and delegates the business action.

That's a cleaner boundary.

Why This Matters

  1. Controllers stay smaller — Less noise around the actual use case.
  2. Rules become testable — You can test request behavior directly.
  3. Errors stay consistent — API consumers get predictable validation responses.
  4. Authorization can live nearbyauthorize() can protect the same boundary.
  5. Data shape becomes explicit — Future developers know what input is expected.

A good Form Request is like airport security. It doesn't fly the plane; it makes sure only safe passengers reach the gate.

Flat editorial map. Messy paper requests on the left approach a Form Request checkpoint where officers verify required fields, types, permissions, and conditional rules. Approved requests in teal flow into a clean Laravel service layer on the right; rejected ones bounce back with a 422 stamp. Warm sand background, navy lines, Laravel red checkpoints.
Validation as the application border: outside is untrusted, inside is calm — the Form Request is the gate.

Conditional Validation Is Where Power Shows Up

Real forms are rarely static.

Sometimes a field is required only when another field has a specific value. Sometimes one of two fields must exist. Sometimes an admin can submit fields regular users cannot.

Laravel handles these cases well.

This example requires a cancellation reason only for cancelled subscriptions:

PHP app/Http/Requests/UpdateSubscriptionRequest.php
public function rules(): array
{
    return [
        'status' => ['required', Rule::in(['active', 'paused', 'cancelled'])],
        'cancel_reason' => [
            Rule::requiredIf(fn () => $this->input('status') === 'cancelled'),
            'nullable',
            'string',
            'max:500',
        ],
    ];
}

The rule reads like the business requirement. That's what you want.

For more complex cases, you can add rules after the basic validation step:

PHP app/Http/Requests/BookAppointmentRequest.php
public function after(): array
{
    return [
        function (Validator $validator) {
            if ($this->doctorIsUnavailable()) {
                $validator->errors()->add(
                    'starts_at',
                    'The selected appointment time is not available.'
                );
            }
        },
    ];
}

This keeps the weird business edge cases close to the input boundary, not scattered across services.

Custom Rules Make Business Language Visible

A custom validation rule is useful when a rule appears in multiple places or deserves a business name.

For example, say discount codes must be active, not expired, and valid for a specific customer segment.

PHP app/Rules/ValidDiscountCode.php
class ValidDiscountCode implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $exists = DiscountCode::query()
            ->where('code', $value)
            ->where('active', true)
            ->where('expires_at', '>', now())
            ->exists();

        if (! $exists) {
            $fail('The selected discount code is not valid.');
        }
    }
}

Then the request stays readable:

PHP app/Http/Requests/ApplyDiscountRequest.php
public function rules(): array
{
    return [
        'code' => ['required', 'string', new ValidDiscountCode()],
    ];
}

You've turned database checks and business rules into a named object. That's easier to read and easier to reuse.

A custom rule is like giving a messy policy a name tag. Once it has a name, people can talk about it clearly.

Validation Can Create DTO-Like Boundaries

Laravel validation returns arrays, but you don't have to pass raw arrays everywhere.

For larger apps, you can convert validated input into a small data object:

PHP app/Data/CreateUserData.php
class CreateUserData
{
    public function __construct(
        public string $name,
        public string $email,
        public ?string $timezone,
    ) {}

    public static function fromRequest(CreateUserRequest $request): self
    {
        $data = $request->validated();

        return new self(
            name: $data['name'],
            email: $data['email'],
            timezone: $data['timezone'] ?? null,
        );
    }
}

Then your action receives a clear object:

PHP app/Actions/CreateUser.php
public function handle(CreateUserData $data): User
{
    return User::create([
        'name' => $data->name,
        'email' => $data->email,
        'timezone' => $data->timezone,
    ]);
}

You don't need DTOs for every endpoint. But when arrays start traveling through five layers, a small object can make the code safer.

Sanitization Is Not Validation

Validation answers the question "is this input acceptable?" Sanitization answers a different question: "what should I store?"

They overlap, but they're not the same. A email rule rejects malformed addresses; it does not lowercase the value or strip whitespace. A string rule confirms the type; it does not trim or normalize line endings. If you skip sanitization, you end up with " Nazar@Example.COM " and "nazar@example.com" as two different rows in the same table.

The cleanest place to sanitize is in the Form Request itself:

PHP app/Http/Requests/CreateUserRequest.php
public function prepareForValidation(): void
{
    $this->merge([
        'email' => is_string($this->email)
            ? strtolower(trim($this->email))
            : $this->email,
        'name' => is_string($this->name)
            ? trim(preg_replace('/\s+/', ' ', $this->name))
            : $this->name,
    ]);
}

public function rules(): array
{
    return [
        'email' => ['required', 'email', 'max:255', 'unique:users,email'],
        'name' => ['required', 'string', 'max:120'],
    ];
}

prepareForValidation() runs before the rules, so the cleaned values are what gets validated and what validated() returns. The controller never sees the raw mess.

A useful mental model: validation is the checkpoint, sanitization is the friendly officer who straightens your collar before you walk through it. Both make the system inside calmer.

A Consistent Validation Error Envelope

When a Laravel API rejects input, the default 422 response includes a message and an errors map. That works, but in API-first apps it's worth wrapping it in your standard error envelope so every error type — validation, not-found, forbidden, server — has the same shape.

JSON error 422
{
  "error": {
    "code": "validation_failed",
    "message": "The data you sent failed validation.",
    "fields": {
      "email": ["The email field is required."],
      "name": ["The name must not be greater than 120 characters."]
    },
    "request_id": "01HZK9X7RT9JX5..."
  }
}

You can produce this consistently by overriding the failedValidation hook on the request, or by registering a renderer in bootstrap/app.php:

PHP bootstrap/app.php
->withExceptions(function (Exceptions $exceptions) {
    $exceptions->render(function (ValidationException $e, Request $request) {
        if (! $request->expectsJson()) {
            return null;
        }

        return response()->json([
            'error' => [
                'code' => 'validation_failed',
                'message' => 'The data you sent failed validation.',
                'fields' => $e->errors(),
                'request_id' => $request->headers->get('X-Request-Id'),
            ],
        ], 422);
    });
})

Frontends parse this. Mobile apps depend on this. Once shipped, the shape is a contract — change it and clients break. Pick something simple early and stay there.

Common Validation Problems

  1. Putting everything in controllers — The endpoint becomes noisy and hard to test.
  2. Duplicating rules — Repeated validation drifts over time.
  3. Trusting optional fields too much — Missing fields and null fields are not always the same.
  4. Skipping authorization — Valid input from the wrong user is still dangerous.
  5. Leaking database rules into UX — Error messages should help humans, not expose internals.

Validation is not just a lock. It's also a translator between messy requests and clean application decisions.

Final Tips

I used to think validation was the boring part before the real work. Then I saw how much bad application logic came from unclear input boundaries. Once validation got cleaner, services got smaller and bugs became easier to locate.

Going forward, treat validation as part of your design. A good request class tells the next developer what the endpoint accepts, rejects, and protects.

Make the boundary strong, and the inside of your app gets calmer. Good luck building cleaner Laravel flows 👊