Custom Rules

A rule object or a closure runs your own check and reports a failure by calling the $fail callback it is handed.

Syntaxpublic function validate(string $attribute, mixed $value, Closure $fail): void

When no built-in rule describes the constraint, you write one. There are two shapes, and both report failure the same way: by calling the $fail callback. Returning false does nothing.

A rule object

php artisan make:rule AfterStartDate writes app/Rules/AfterStartDate.php, a class implementing Illuminate\Contracts\Validation\ValidationRule with one method: validate(string $attribute, mixed $value, Closure $fail): void. Put the instance in the rules array alongside the strings. The message passed to $fail goes through the usual replacements, so :attribute works in it. $fail returns a PotentiallyTranslatedString, which is why $fail('validation.blocked')->translate(['domain' => $d]) is possible.

A closure rule

For a check used once, a closure in the rules array takes the same three parameters. The validator passes a fourth argument, the validator itself, which the documented signature omits. A closure also captures the surrounding scope, which is the quickest way to reach a value the rule needs.

Reading another field, and injecting a service

A rule object sees only its own value. To read a sibling field, also implement DataAwareRule and its setData(array $data) method; the validator calls it with every field under validation before validate() runs. ValidatorAwareRule and setValidator() hand you the validator instead. For a dependency, give the rule a constructor and build it yourself — or let the container do it: FormRequest invokes rules() through $this->container->call(), so a type-hinted parameter on rules() is resolved from the container.

Example

Example · php
<?php
// app/Rules/AfterStartDate.php  (php artisan make:rule AfterStartDate)

declare(strict_types=1);

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\DataAwareRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Carbon;

class AfterStartDate implements ValidationRule, DataAwareRule
{
    /** @var array<string, mixed> */
    private array $data = [];

    /**
     * The validator calls this before validate(), with EVERY field under
     * validation, not only the one being checked.
     *
     * @param  array<string, mixed>  $data
     * @return $this
     */
    public function setData(array $data): static
    {
        $this->data = $data;

        return $this;
    }

    /**
     * Nothing is returned. A failure is $fail(), and :attribute in the
     * message is replaced with the field's display name.
     */
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $start = $this->data['starts_at'] ?? null;

        if ($start === null) {
            return; // starts_at carries its own required rule.
        }

        if (Carbon::parse((string) $value)->lessThanOrEqualTo(Carbon::parse((string) $start))) {
            $fail('The :attribute must be later than the start date.');
        }
    }
}

// In a form request:
//
// return [
//     'starts_at' => ['required', 'date'],
//     'ends_at' => ['required', 'date', new AfterStartDate()],
// ];

When to use it

  • Compare two fields, such as an end date against a start date.
  • Check a value against an external service or a blocklist.
  • Enforce a format the regex rule would make unreadable.
  • Require a field only when another field has a particular value and no built-in rule fits.

More examples

The same check as a closure

The closure form for a rule used in one place. It is the shorthand for the rule object: identical parameters, identical $fail. Promote it to a class when a second endpoint needs it, when it needs its own unit test, or when the closure grows past a few lines.

Example · php
<?php
// app/Http/Requests/StoreEventRequest.php

declare(strict_types=1);

namespace App\Http\Requests;

use Closure;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon;

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

    /** @return array<string, array<mixed>> */
    public function rules(): array
    {
        return [
            'starts_at' => ['required', 'date'],
            'ends_at' => [
                'required',
                'date',
                // Same three parameters as the rule object's validate().
                // $this is the request here, so there is no need for
                // DataAwareRule to reach the sibling field.
                function (string $attribute, mixed $value, Closure $fail): void {
                    $start = $this->date('starts_at');

                    if ($start !== null && Carbon::parse((string) $value)->lessThanOrEqualTo($start)) {
                        $fail('The :attribute must be later than the start date.');
                    }
                },
            ],
        ];
    }
}

A rule with a service injected into it

How a rule reaches a service, API client or repository. The rule object is plain PHP, so the container does not touch it unless something asks for it; type-hinting the parameter on rules() is the one place where it does. If you need an instance outside a form request, construct it yourself and pass the dependency in.

Example · php
<?php
// app/Rules/NotBlockedDomain.php

declare(strict_types=1);

namespace App\Rules;

use App\Services\DomainBlocklist;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Str;

class NotBlockedDomain implements ValidationRule
{
    public function __construct(
        private readonly DomainBlocklist $blocklist,
    ) {
    }

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $domain = Str::after((string) $value, '@');

        if ($this->blocklist->contains($domain)) {
            // A translation key plus replacements. Add the line to
            // lang/en/validation.php:
            //   'blocked_domain' => 'The :attribute uses a blocked domain (:domain).',
            $fail('validation.blocked_domain')->translate(['domain' => $domain]);
        }
    }
}

// app/Http/Requests/StoreSubscriberRequest.php
//
// FormRequest calls rules() through $this->container->call(), so this
// parameter is resolved and constructed by the container.
//
// public function rules(NotBlockedDomain $notBlocked): array
// {
//     return ['email' => ['required', 'email', $notBlocked]];
// }

An implicit rule, which also runs when the field is empty

Reach for --implicit when the rule's job is to decide whether a value must be there. Any rule that enforces presence has to be implicit, because non-implicit rules never see an absent value. The built-in required_if family works the same way; this is the escape hatch for a condition those rules cannot express.

Example · php
<?php
// app/Rules/RequiredForPaidPlans.php
// php artisan make:rule RequiredForPaidPlans --implicit

declare(strict_types=1);

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\DataAwareRule;
use Illuminate\Contracts\Validation\ValidationRule;

class RequiredForPaidPlans implements ValidationRule, DataAwareRule
{
    /**
     * The --implicit flag adds this property. Without it the validator
     * skips the rule whenever the value is absent, null or an empty
     * string, so a missing field could never fail it.
     *
     * @var bool
     */
    public $implicit = true;

    /** @var array<string, mixed> */
    private array $data = [];

    /**
     * @param  array<string, mixed>  $data
     * @return $this
     */
    public function setData(array $data): static
    {
        $this->data = $data;

        return $this;
    }

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (($this->data['plan'] ?? 'free') !== 'free' && blank($value)) {
            $fail('The :attribute is required on a paid plan.');
        }
    }
}

Discussion

  • Be the first to comment on this lesson.