0

After validation hooks — withValidator()

Intermediate5 min read·lv-11-008

Concept

Sometimes you need validation logic that cannot be expressed as a rule at all — logic that must see the full validated dataset, check cross-field relationships, or call an external service only after all individual rules have passed. Laravel provides two hooks for this: withValidator() in FormRequest, and the after() callback on a Validator instance.

withValidator(Validator $validator) is a method you define on a FormRequest class. The framework calls it after the Validator instance has been constructed from your rules() array but before the validation pass runs. This gives you a reference to the live Validator object, and you can call $validator->after(callable) to register a post-validation hook.

The callback registered via $validator->after() runs after all rule-based validation completes. At this point, $validator->errors() contains all rule-based failures, and you can inspect them before deciding whether to add more errors. The callback receives the Validator instance as its argument. Calling $validator->errors()->add('field', 'message') appends additional errors to the MessageBag.

This two-phase design is important: rule-based validation happens first, and the after hook runs second. This means you can guard expensive cross-field checks behind a condition that only runs if earlier rules passed — avoiding a database call or API hit when the input is already obviously invalid.

A common use case is business-rule validation that spans multiple fields. For example: "if plan is free, team_size must not exceed 5" cannot be expressed cleanly as a single rule string because it requires reading two fields together. An after hook makes this straightforward.

You can register multiple after callbacks — they run in the order they were registered. Each callback has full access to the Validator instance, so you can check $validator->errors()->has('specific_field') before conditionally adding related errors.

Code Example

php
<?php

// In a FormRequest: withValidator() is called by the framework before validation runs
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;

class CreateSubscriptionRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'plan'      => ['required', 'in:free,starter,pro,enterprise'],
            'team_size' => ['required', 'integer', 'min:1', 'max:500'],
            'start_date' => ['required', 'date', 'after_or_equal:today'],
            'end_date'  => ['nullable', 'date'],
        ];
    }

    /**
     * Configure additional validation after all rules have been checked.
     * $validator is the Illuminate\Validation\Validator instance.
     */
    public function withValidator(Validator $validator): void
    {
        // First after hook: cross-field business rule
        $validator->after(function (Validator $v): void {
            $plan     = $this->input('plan');
            $teamSize = (int) $this->input('team_size');

            if ($plan === 'free' && $teamSize > 5) {
                $v->errors()->add(
                    'team_size',
                    'The free plan supports a maximum of 5 team members. Upgrade to Starter or above.'
                );
            }

            if ($plan === 'starter' && $teamSize > 25) {
                $v->errors()->add(
                    'team_size',
                    'The Starter plan supports a maximum of 25 team members.'
                );
            }
        });

        // Second after hook: date range cross-validation
        // Only run if the base date rules passed (avoid accessing invalid data)
        $validator->after(function (Validator $v): void {
            if ($v->errors()->hasAny(['start_date', 'end_date'])) {
                return; // don't pile on if dates are already invalid
            }

            $start = $this->date('start_date');
            $end   = $this->date('end_date');

            if ($end && $start && $end->lte($start)) {
                $v->errors()->add('end_date', 'The end date must be after the start date.');
            }

            // Ensure subscription window is at most 1 year
            if ($end && $start && $end->diffInMonths($start) > 12) {
                $v->errors()->add('end_date', 'The subscription period cannot exceed 12 months.');
            }
        });
    }
}

// --- Using after() directly on Validator::make() (outside FormRequest) ---
use Illuminate\Support\Facades\Validator;

$validator = Validator::make($data, $rules);

$validator->after(function (Validator $v) use ($data): void {
    if (empty($data['billing_address']) && $data['payment_method'] !== 'wallet') {
        $v->errors()->add('billing_address', 'A billing address is required for this payment method.');
    }
});

$validator->validate(); // throws ValidationException if any errors exist

Interview Q&A

Q: Why is it important to check $validator->errors()->has() inside an after hook before adding related errors?

after hooks run after all rule-based validation, which means the MessageBag already contains all individual field errors. If a field like start_date already failed its own date rule, reading $this->date('start_date') inside the hook may return null, and your cross-field comparison will produce a misleading additional error. Guarding with $v->errors()->hasAny(['start_date', 'end_date']) prevents pile-on errors that confuse users — they fix one problem only to discover a new seemingly unrelated error on the next submission. Clean validation UX requires checking preconditions before adding cross-field errors.


Q: What is the difference between withValidator() and overriding getValidatorInstance() in a FormRequest?

withValidator(Validator $validator) is a post-construction hook: the Validator has already been built from rules() when this method is called. You can call $validator->after() to add hooks, add extra rules via $validator->addRules(), or even swap the MessageBag. getValidatorInstance() is the lower-level factory method — overriding it lets you return a completely different Validator subclass or configure it in ways not exposed by the trait API. For production code, withValidator() covers all practical use cases. Use getValidatorInstance() only when building a framework extension or custom validator subclass.


Q: Can after hooks add errors even if no rule-based failures occurred, and how does that affect the response?

Yes. after hooks can add errors to the MessageBag unconditionally. If $validator->errors()->any() returns true after all hooks have run, the Validator considers the validation failed. validate() throws a ValidationException carrying the full MessageBag, and the exception handler renders the errors the same way it would for any rule failure — 422 JSON for API requests, redirect with flashed errors for HTML forms. The user experiences no difference between rule-level and hook-level errors; the framework does not distinguish between them in the response.