Пользовательские правила валидации в Laravel 13

Встроенных правил Laravel хватает для большинства задач. Но рано или поздно появляется проверка, которую required|string|max:255 не выразит: формат телефона конкретной страны, бизнес-ограничение вроде “нельзя бронировать на прошедшую дату больше чем за 3 дня”, или фильтрация XSS в пользовательском вводе. Для таких случаев Laravel предлагает три механизма: Rule-класс, замыкание и implicit-правило.

Основы валидации - в отдельной статье.

Rule-класс

Основной способ создания переиспользуемого правила. Генерируется через Artisan:

php artisan make:rule PhoneNumber

Файл появится в app/Rules/PhoneNumber.php. Класс реализует интерфейс ValidationRule с единственным методом validate:

namespace App\Rules;

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

class PhoneNumber implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (!preg_match('/^\+7\d{10}$/', $value)) {
            $fail('Поле :attribute должно быть в формате +7XXXXXXXXXX.');
        }
    }
}

Три параметра: имя атрибута, значение, замыкание $fail для регистрации ошибки. Если $fail не вызван, значение считается валидным.

Подключение в правилах:

use App\Rules\PhoneNumber;

$request->validate([
    'phone' => ['required', new PhoneNumber],
]);

Конструктор для параметризации

Rule-класс - обычный PHP-класс. Параметры передаются через конструктор:

class MaxWords implements ValidationRule
{
    public function __construct(private int $limit = 100) {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $count = str_word_count($value);

        if ($this->limit < $count) {
            $fail("Поле :attribute не должно содержать больше {$this->limit} слов (сейчас {$count}).");
        }
    }
}
'bio'     => ['required', 'string', new MaxWords(200)],
'summary' => ['required', 'string', new MaxWords(50)],

Один класс, разные лимиты. Это главное преимущество Rule-класса перед замыканием: параметризация, переиспользование и изолированное тестирование. Rule-класс можно проверить юнит-тестом без HTTP-запроса.

Перевод сообщений

Вместо строки в $fail можно передать ключ перевода:

public function validate(string $attribute, mixed $value, Closure $fail): void
{
    if (!preg_match('/^\+7\d{10}$/', $value)) {
        $fail('validation.phone_number')->translate();
    }
}

Laravel найдёт перевод в lang/ru/validation.php:

'phone_number' => 'Поле :attribute должно быть в формате +7XXXXXXXXXX.',

Метод translate() принимает плейсхолдеры и язык:

$fail('validation.max_words')->translate([
    'limit' => $this->limit,
], 'ru');

Замыкание (closure)

Для одноразовой проверки, которая нигде больше не понадобится, хватает замыкания прямо в массиве правил:

$request->validate([
    'username' => [
        'required',
        'string',
        function (string $attribute, mixed $value, Closure $fail) {
            if (str_contains($value, ' ')) {
                $fail('Имя пользователя не должно содержать пробелов.');
            }
        },
    ],
]);

Сигнатура та же, что у метода validate в Rule-классе. Разница: замыкание не переиспользуется и не параметризуется (без костылей с use).

Когда closure, когда класс

Замыкание - для проверки, которая встречается в одном месте и не требует параметров. Как только проверка появляется в двух Form Request или нуждается в конструкторе - выносите в класс.

На практике большинство проектов начинают с замыкания, а при первом дублировании рефакторят в Rule-класс. Это нормальный процесс.

Замыкание в Form Request

В Form Request замыкания используются так же, как и в $request->validate():

public function rules(): array
{
    return [
        'slug' => [
            'required',
            'string',
            function (string $attribute, mixed $value, Closure $fail) {
                if (preg_match('/[A-Z]/', $value)) {
                    $fail('Slug должен быть в нижнем регистре.');
                }
            },
        ],
    ];
}

И обычное замыкание, и стрелочная функция внутри метода класса имеют доступ к $this. Разница - в захвате локальных переменных: стрелочная функция делает это автоматически, обычному замыканию нужен use:

public function rules(): array
{
    $maxLength = 'short' === $this->input('type') ? 50 : 200;

    return [
        'slug' => [
            'required',
            fn (string $attribute, mixed $value, Closure $fail) =>
                preg_match('/[A-Z]/', $value) ? $fail('Slug в нижнем регистре.') : null,
        ],
        'title' => [
            'required',
            // стрелочная функция захватывает $maxLength автоматически
            fn (string $attribute, mixed $value, Closure $fail) =>
                mb_strlen($value) > $maxLength ? $fail("Максимум {$maxLength} символов.") : null,
        ],
    ];
}

DataAwareRule - доступ ко всем данным

Иногда правило для одного поля зависит от значения другого. Интерфейс DataAwareRule даёт доступ ко всему набору данных:

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

class EndAfterStart implements DataAwareRule, ValidationRule
{
    protected array $data = [];

    public function setData(array $data): static
    {
        $this->data = $data;

        return $this;
    }

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $start = $this->data['start_date'] ?? null;

        if ($start && $value <= $start) {
            $fail(':attribute должен быть позже даты начала.');
        }
    }
}

Laravel автоматически вызовет setData() перед validate(). В $this->data будут все поля запроса, а не только проверяемое.

Частые применения: сравнение двух полей (новый пароль не совпадает с email), проверка уникальности в рамках набора (все email в массиве должны быть разными), зависимость от типа записи (для юрлица обязателен ИНН).

Данные в $this->data - это сырые данные запроса, не прошедшие валидацию. Не полагайтесь на их тип или формат - проверяйте наличие через ?? и валидность перед использованием. Порядок выполнения правил для разных полей не гарантирован, поэтому значение start_date может быть ещё не проверено к моменту, когда запускается ваше правило для end_date.

ValidatorAwareRule - доступ к валидатору

Если нужен доступ к самому объекту валидатора (например, чтобы добавить ошибку к другому полю):

use Illuminate\Contracts\Validation\ValidatorAwareRule;
use Illuminate\Validation\Validator;

class UniqueInBatch implements ValidationRule, ValidatorAwareRule
{
    protected Validator $validator;

    public function setValidator(Validator $validator): static
    {
        $this->validator = $validator;

        return $this;
    }

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $allEmails = $this->validator->getData()['emails'] ?? [];
        $duplicates = array_count_values($allEmails);

        if (1 < ($duplicates[$value] ?? 0)) {
            $fail('Email :attribute дублируется в списке.');
        }
    }
}

ValidatorAwareRule нужен реже, чем DataAwareRule. Основной сценарий - взаимодействие с ошибками других полей или с самим валидатором.

Оба интерфейса можно комбинировать в одном классе, если нужен доступ и к данным, и к валидатору. Laravel вызовет оба сеттера (setData и setValidator) перед запуском validate().

Implicit-правила

По умолчанию кастомные правила не запускаются, если поле отсутствует или содержит пустую строку. Это логично для большинства проверок - незачем проверять формат пустого значения.

Но иногда правило должно работать и для пустых значений. Например, проверка “поле обязательно при определённом условии”. Для этого при генерации добавьте флаг --implicit:

php artisan make:rule RequiredIfAdmin --implicit

Сгенерированный класс помечается как implicit - Laravel запустит его даже для пустых значений:

// Сгенерирован с --implicit
class RequiredIfAdmin implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (auth()->user()?->isAdmin() && empty($value)) {
            $fail('Поле :attribute обязательно для администраторов.');
        }
    }
}

Без флага --implicit правило не запустится для пустого поля - Laravel пропустит его до вызова validate().

Implicit-правила - редкость. Большинство кастомных проверок работают с непустыми значениями. Используйте --implicit только когда правило должно контролировать обязательность поля, а не его формат.

Ещё один пример - условная обязательность на основе роли:

// Сгенерирован с --implicit
class RequiredForRole implements ValidationRule
{
    public function __construct(private string $role) {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $user = auth()->user();

        if ($user && $this->role === $user->role && empty($value)) {
            $fail(":attribute обязательно для роли {$this->role}.");
        }
    }
}
'department' => [new RequiredForRole('manager')],
'badge_id'   => [new RequiredForRole('security')],

Правило как сервис

В сложных приложениях правило может зависеть от конфигурации, внешних API или кэша. Регистрация в service provider позволяет автоматически резолвить зависимости:

// AppServiceProvider::register()
$this->app->bind(ContentPolicy::class, function ($app) {
    return new ContentPolicy(
        bannedWords: config('moderation.banned_words', []),
        maxLinks: config('moderation.max_links', 3),
    );
});
class ContentPolicy implements ValidationRule
{
    public function __construct(
        private array $bannedWords,
        private int $maxLinks,
    ) {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        foreach ($this->bannedWords as $word) {
            if (false !== stripos($value, $word)) {
                $fail(':attribute содержит запрещённое слово.');
                return;
            }
        }

        $linkCount = preg_match_all('/https?:\/\//', $value);
        if ($this->maxLinks < $linkCount) {
            $fail(":attribute содержит больше {$this->maxLinks} ссылок.");
        }
    }
}

В Form Request:

'comment' => ['required', 'string', app(ContentPolicy::class)],

Конфигурация берётся из config/moderation.php, а не хардкодится в правиле. Смена списка запрещённых слов не требует изменения кода - достаточно обновить конфиг или переменные окружения.

Правило с обращением к базе данных

Кастомные правила часто проверяют данные по базе. Например, что промокод существует и ещё активен:

class ValidPromoCode implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $promo = PromoCode::where('code', $value)
            ->where('expires_at', '>', now())
            ->where('uses_left', '>', 0)
            ->first();

        if (null === $promo) {
            $fail('Промокод :attribute недействителен или истёк.');
        }
    }
}

Правило делает запрос к базе на каждый вызов. Для формы, где промокод необязателен, комбинируйте с nullable:

'promo_code' => ['nullable', 'string', new ValidPromoCode],

Если поле пустое, ValidPromoCode не запустится (это не implicit-правило), и лишнего запроса к базе не будет.

Инжекция зависимостей в Rule-класс

Rule-класс создаётся вручную через new, а не через service container. Если нужна зависимость, передавайте через конструктор:

class UniqueInTenant implements ValidationRule
{
    public function __construct(
        private TenantService $tenants,
        private string $table,
        private string $column = 'id',
    ) {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $exists = DB::table($this->table)
            ->where($this->column, $value)
            ->where('tenant_id', $this->tenants->currentId())
            ->exists();

        if ($exists) {
            $fail(':attribute уже существует в вашей организации.');
        }
    }
}

В Form Request:

public function rules(): array
{
    return [
        'email' => ['required', 'email', new UniqueInTenant(
            app(TenantService::class), 'users', 'email'
        )],
    ];
}

Или через app()->make() для автоматического разрешения:

'email' => ['required', 'email', app(UniqueInTenant::class, ['table' => 'users', 'column' => 'email'])],

Защита от XSS

Пользовательский ввод, который потом отображается на странице, нужно проверять на XSS. Blade экранирует вывод через {{ }}, но если где-то используется {!! !!} или данные попадают в атрибуты, проверка на этапе валидации не помешает:

class NoHtmlTags implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if ($value !== strip_tags($value)) {
            $fail(':attribute не должен содержать HTML-теги.');
        }
    }
}

Для полей, где HTML допустим (WYSIWYG-редактор), нужен более тонкий подход - разрешить безопасные теги, запретить скрипты:

class SafeHtml implements ValidationRule
{
    private array $allowedTags = ['p', 'b', 'i', 'a', 'ul', 'ol', 'li', 'br', 'strong', 'em'];

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $stripped = strip_tags($value, $this->allowedTags);

        if ($stripped !== $value) {
            $fail(':attribute содержит запрещённые HTML-теги.');
        }

        if (preg_match('/\bon\w+\s*=/i', $value) || false !== stripos($value, 'javascript:')) {
            $fail(':attribute содержит потенциально опасный код.');
        }
    }
}

Валидация - не замена санитизации. Для надёжной защиты используйте и проверку на входе, и экранирование на выходе. Пакеты вроде mews/purifier (обёртка над HTML Purifier) делают санитизацию HTML лучше, чем strip_tags.

Ещё один подход к XSS - санитизировать в prepareForValidation() и валидировать уже очищенный текст. Пользователь не увидит ошибку, но скрипты будут вырезаны:

protected function prepareForValidation(): void
{
    if ($this->filled('body')) {
        $this->merge(['body' => Purifier::clean($this->body)]);
    }
}

Этот подход мягче: вместо отказа “текст содержит запрещённые теги” приложение молча их убирает. Подходит для комментариев, сообщений, описаний товаров. Выбор между “отвергнуть” и “очистить” зависит от контекста: в формах администратора лучше отвергать и показывать ошибку, в публичных комментариях - очищать молча.

Для защиты от SQL-инъекций кастомные правила не нужны - Laravel использует параметризованные запросы. А вот для защиты от хранимых XSS (stored XSS) валидация на входе - первый рубеж обороны.

Реальные примеры кастомных правил

ИНН (Россия)

class Inn implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (!preg_match('/^\d{10}(\d{2})?$/', $value)) {
            $fail(':attribute должен содержать 10 или 12 цифр.');
            return;
        }

        if (10 === mb_strlen($value) && !$this->checkInn10($value)) {
            $fail(':attribute не прошёл проверку контрольной суммы.');
        }

        if (12 === mb_strlen($value) && !$this->checkInn12($value)) {
            $fail(':attribute не прошёл проверку контрольной суммы.');
        }
    }

    private function checkInn10(string $inn): bool
    {
        $coefficients = [2, 4, 10, 3, 5, 9, 4, 6, 8];
        $sum = 0;

        for ($i = 0; 9 > $i; $i++) {
            $sum += (int) $inn[$i] * $coefficients[$i];
        }

        return (int) $inn[9] === $sum % 11 % 10;
    }

    private function checkInn12(string $inn): bool
    {
        // 11-я цифра: коэффициенты для позиций 0-9
        $valid11 = $this->checkDigit($inn, [7, 2, 4, 10, 3, 5, 9, 4, 6, 8], 10);
        // 12-я цифра: коэффициенты для позиций 0-10
        $valid12 = $this->checkDigit($inn, [3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8], 11);

        return $valid11 && $valid12;
    }

    private function checkDigit(string $inn, array $coefficients, int $position): bool
    {
        $sum = 0;

        for ($i = 0; count($coefficients) > $i; $i++) {
            $sum += (int) $inn[$i] * $coefficients[$i];
        }

        return (int) $inn[$position] === $sum % 11 % 10;
    }
}

Запрет одноразовых email

class NotDisposableEmail implements ValidationRule
{
    private array $domains = [
        'mailinator.com', 'guerrillamail.com', 'tempmail.com',
        'throwaway.email', 'yopmail.com', 'sharklasers.com',
    ];

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $domain = strtolower(substr(strrchr($value, '@'), 1));

        if (in_array($domain, $this->domains, true)) {
            $fail('Одноразовые email не принимаются.');
        }
    }
}

Для production лучше подключить пакет с актуальной базой доменов (например, martijnvisser/disposable-email-domains), а не хардкодить список.

Проверка уникальности в рамках запроса

Когда пользователь отправляет массив email-адресов и все должны быть уникальны внутри этого массива (не в базе):

class UniqueInArray implements ValidationRule, DataAwareRule
{
    protected array $data = [];

    public function __construct(private string $arrayField) {}

    public function setData(array $data): static
    {
        $this->data = $data;

        return $this;
    }

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $items = $this->data[$this->arrayField] ?? [];
        $occurrences = array_count_values(array_map('strtolower', $items));

        if (1 < ($occurrences[strtolower($value)] ?? 0)) {
            $fail(':attribute дублируется в списке.');
        }
    }
}
'emails.*' => ['required', 'email', new UniqueInArray('emails')],

Здесь DataAwareRule необходим - правило проверяет один элемент, но должно видеть весь массив для поиска дублей.

Бизнес-правило: лимит бронирований

Проверка, привязанная к бизнес-логике, а не к формату данных:

class BookingLimit implements ValidationRule
{
    public function __construct(private User $user) {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $activeBookings = $this->user->bookings()
            ->where('status', 'active')
            ->count();

        if (3 <= $activeBookings) {
            $fail('Достигнут лимит активных бронирований (максимум 3).');
        }
    }
}
'room_id' => ['required', 'exists:rooms,id', new BookingLimit($request->user())],

Такие правила делают бизнес-ограничения явными и тестируемыми, вместо того чтобы прятать их в if-ах контроллера. Ошибка привязана к конкретному полю и отображается в форме, как любая другая ошибка валидации.

Пакеты валидации

Для типовых проверок не нужно писать правила с нуля. Популярные пакеты:

Подключение пакетного правила ничем не отличается от собственного:

use Propaganistas\LaravelPhone\Rules\Phone;

$request->validate([
    'phone' => ['required', (new Phone)->country('RU')],
]);

Перед написанием своего правила проверьте Packagist - скорее всего, кто-то уже решил эту задачу.

Организация правил в проекте

В небольшом проекте все правила живут в app/Rules. По мере роста стоит группировать по доменам:

app/Rules/
├── Finance/
│   ├── Inn.php
│   ├── Bik.php
│   └── ValidPromoCode.php
├── Security/
│   ├── NoHtmlTags.php
│   ├── SafeHtml.php
│   └── NotDisposableEmail.php
└── Common/
    ├── MaxWords.php
    ├── PhoneNumber.php
    └── UniqueInArray.php

Namespace следует структуре папок: App\Rules\Finance\Inn, App\Rules\Security\NoHtmlTags. Artisan поддерживает вложенные пути:

php artisan make:rule Finance/Inn

Не создавайте абстрактные базовые классы для правил, если у них нет реально общей логики. Правила обычно настолько разные, что наследование не оправдано.

Правило с внешним API

Проверка адреса через геокодер или валидация SWIFT-кода через внешний сервис:

class ValidSwiftCode implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (!preg_match('/^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/', strtoupper($value))) {
            $fail(':attribute не соответствует формату SWIFT/BIC.');
            return;
        }

        $response = Http::timeout(5)->get("https://api.example.com/swift/{$value}");

        if ($response->failed() || !$response->json('valid')) {
            $fail(':attribute не найден в реестре SWIFT.');
        }
    }
}

Внешние вызовы замедляют валидацию. Ставьте такие правила последними в массиве с bail перед ними, чтобы HTTP-запрос не уходил при ошибке формата. В тестах мокайте HTTP через Http::fake():

public function test_swift_code_validated_against_api(): void
{
    Http::fake([
        'api.example.com/swift/*' => Http::response(['valid' => true]),
    ]);

    $rule = new ValidSwiftCode;
    $passed = true;

    $rule->validate('swift', 'DEUTDEFF', function () use (&$passed) {
        $passed = false;
    });

    $this->assertTrue($passed);
    Http::assertSentCount(1);
}

Правило с зависимостью от времени

Проверка, что действие выполняется в рабочее время:

class BusinessHours implements ValidationRule
{
    public function __construct(
        private string $timezone = 'Europe/Moscow',
        private int $startHour = 9,
        private int $endHour = 18,
    ) {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $now = now($this->timezone);

        if ($now->isWeekend()) {
            $fail('Операция доступна только в рабочие дни.');
            return;
        }

        if ($this->startHour > $now->hour || $this->endHour <= $now->hour) {
            $fail("Операция доступна с {$this->startHour}:00 до {$this->endHour}:00.");
        }
    }
}
'transfer_amount' => ['required', 'numeric', 'min:0.01', new BusinessHours('Europe/Moscow', 9, 18)],

Правило не проверяет значение поля - оно проверяет контекст. Это допустимо: правило привязано к полю, чтобы ошибка отобразилась в нужном месте формы. Тестируйте с travelTo():

public function test_rejects_weekend_transfers(): void
{
    $this->travelTo('2025-06-14 12:00', 'Europe/Moscow'); // суббота

    $rule = new BusinessHours;
    $failed = false;

    $rule->validate('amount', 100, function () use (&$failed) {
        $failed = true;
    });

    $this->assertTrue($failed);
}

Миграция со старого API

В Laravel до версии 11 кастомные правила использовали другой интерфейс - Illuminate\Contracts\Validation\Rule с методами passes() и message(). Этот интерфейс устарел. Если в проекте есть такие правила, миграция простая:

// Старый API (deprecated)
class OldRule implements \Illuminate\Contracts\Validation\Rule
{
    public function passes($attribute, $value): bool
    {
        return 10 <= mb_strlen($value);
    }

    public function message(): string
    {
        return ':attribute слишком короткий.';
    }
}

// Новый API
class NewRule implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (10 > mb_strlen($value)) {
            $fail(':attribute слишком короткий.');
        }
    }
}

Логика passes() инвертируется: вместо “вернуть true, если валидно” - “вызвать fail, если невалидно”. Сообщение передаётся прямо в $fail вместо отдельного метода message().

Новые правила пишите через ValidationRule.

Комбинирование с встроенными правилами

Кастомное правило - такой же элемент массива, как required или string. Порядок выполнения соответствует порядку в массиве:

'inn' => [
    'required',           // 1. Поле обязательно
    'string',             // 2. Должна быть строка
    'regex:/^\d{10,12}$/', // 3. 10 или 12 цифр
    new Inn,              // 4. Проверка контрольной суммы
],

Если string провалится, Inn всё равно запустится (если не стоит bail). Для тяжёлых правил с запросами к базе ставьте их последними и добавляйте bail первым:

'promo_code' => [
    'bail',              // остановить валидацию при первой ошибке
    'required',
    'string',
    'max:20',
    new ValidPromoCode,  // запрос к базе только если базовые правила прошли
],

bail действует как флаг на весь атрибут: при первой проваленной проверке остальные пропускаются. Без bail все правила в массиве выполнятся, даже если первое уже провалилось - и кастомное правило сделает запрос к базе зря.

Кастомные правила и массивы

Для валидации элементов массива правило применяется через wildcard *:

'participants.*.email' => ['required', 'email', new NotDisposableEmail],
'participants.*.phone' => ['nullable', new PhoneNumber],

Правило запустится для каждого элемента массива отдельно. Если нужно проверить массив целиком (например, что все email уникальны между собой), используйте DataAwareRule или вынесите логику в after() хук Form Request. Подробнее о валидации массивов - в статье про массивы и JSON.

Тестирование кастомных правил

Rule-класс можно тестировать изолированно, без HTTP-запроса:

public function test_phone_number_accepts_valid(): void
{
    $rule = new PhoneNumber;
    $passed = true;

    $rule->validate('phone', '+79161234567', function () use (&$passed) {
        $passed = false;
    });

    $this->assertTrue($passed);
}

public function test_phone_number_rejects_invalid(): void
{
    $rule = new PhoneNumber;
    $message = null;

    $rule->validate('phone', '12345', function ($msg) use (&$message) {
        $message = $msg;
    });

    $this->assertNotNull($message);
}

Для полной проверки через HTTP:

public function test_registration_rejects_disposable_email(): void
{
    $response = $this->post('/register', [
        'name'                  => 'Test',
        'email'                 => '[email protected]',
        'password'              => 'SecurePass123!',
        'password_confirmation' => 'SecurePass123!',
    ]);

    $response->assertSessionHasErrors('email');
}

Изолированные тесты быстрее и точнее показывают, какое правило сломалось. HTTP-тесты проверяют интеграцию с Form Request.

Тестирование DataAwareRule

Для правил с DataAwareRule нужно вызвать setData() перед validate():

public function test_end_after_start_rejects_earlier_date(): void
{
    $rule = new EndAfterStart;
    $rule->setData(['start_date' => '2025-06-15']);

    $failed = false;
    $rule->validate('end_date', '2025-06-10', function () use (&$failed) {
        $failed = true;
    });

    $this->assertTrue($failed);
}

Тестирование с параметрами

#[DataProvider('wordLimits')]
public function test_max_words_validates_correctly(string $text, int $limit, bool $shouldPass): void
{
    $rule = new MaxWords($limit);
    $passed = true;

    $rule->validate('bio', $text, function () use (&$passed) {
        $passed = false;
    });

    $this->assertSame($shouldPass, $passed);
}

public static function wordLimits(): array
{
    return [
        'under limit'  => ['Hello world', 5, true],
        'at limit'     => ['one two three four five', 5, true],
        'over limit'   => ['one two three four five six', 5, false],
        'empty string' => ['', 5, true],
    ];
}

DataProvider - идеальный инструмент для тестирования правил с разными входами и параметрами.

Полный пример: Form Request с кастомными правилами

Форма создания вакансии с несколькими кастомными правилами:

class StoreJobRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'title'       => ['required', 'string', 'max:255', new MaxWords(15)],
            'description' => ['required', 'string', 'min:100', new SafeHtml],
            'salary_min'  => ['required', 'integer', 'min:0'],
            'salary_max'  => ['required', 'integer', 'gte:salary_min'],
            'company_inn' => ['required', 'string', new Inn],
            'email'       => ['required', 'email', new NotDisposableEmail],
            'phone'       => ['nullable', new PhoneNumber],
            'expires_at'  => ['required', 'date', 'after:today'],
        ];
    }
}

Пять кастомных правил в одной форме: MaxWords для заголовка, SafeHtml для описания с WYSIWYG, Inn для проверки реквизитов, NotDisposableEmail против мусорных ящиков, PhoneNumber для формата телефона. Каждое правило тестируется изолированно и переиспользуется в других формах.

Почему валидация не работает

Частые причины, по которым кастомное правило не срабатывает:

Пустое поле

Правило без --implicit не запускается для пустых значений и отсутствующих полей. Если ожидаете проверку пустого поля, перегенерируйте правило с флагом --implicit.

Опечатка в имени класса

// Ошибка: забыли new
'phone' => ['required', PhoneNumber::class],

// Правильно
'phone' => ['required', new PhoneNumber],

Строка PhoneNumber::class - это просто FQCN как строка. Laravel попытается найти встроенное правило с таким именем, не найдёт и молча пропустит. Всегда передавайте экземпляр через new.

$fail не вызван

Если в validate() нет вызова $fail, правило всегда проходит. Проверьте условие - возможно, логика инвертирована.

Правило в строке вместо массива

// Кастомное правило нельзя передать строкой
'phone' => 'required|string|phone_number',

// Только массивом
'phone' => ['required', 'string', new PhoneNumber],

Строковый синтаксис с | работает только для встроенных правил Laravel.

Правило работает в одном Form Request, но не в другом

Проверьте, что передаёте экземпляр через new, а не строку. Также убедитесь, что поле присутствует в запросе - без --implicit правило для отсутствующего поля не запустится. Добавьте required или sometimes перед кастомным правилом.

DataAwareRule не получает данные

setData() вызывается Laravel автоматически, но только если класс реализует интерфейс DataAwareRule. Если забыли указать интерфейс в implements, $this->data останется пустым без ошибки - метод setData() просто не будет вызван.

Правило пропускает невалидные данные

Внутри validate() не забывайте обрабатывать edge cases: null, пустую строку, массив вместо строки. Laravel не гарантирует тип $value - он может быть чем угодно, что пользователь отправил в запросе. Добавляйте проверку типа в начале:

public function validate(string $attribute, mixed $value, Closure $fail): void
{
    if (!is_string($value)) {
        $fail(':attribute должен быть строкой.');
        return;
    }

    // основная логика проверки
}

Или комбинируйте кастомное правило с string в массиве правил - тогда к моменту вызова вашего правила значение уже будет гарантированно строкой (если стоит bail).