Валидация дат и времени в Laravel

Даты приходят в формах как строки, и поле “дата рождения” со значением abc123 или 31.02.2025 без валидации попадёт в базу как есть. Laravel предоставляет набор правил для проверки дат, временных диапазонов и часовых поясов.

Если вы только знакомитесь с валидацией в Laravel, начните с основ. Для проверки строк, чисел и email - строки и числа.

Правило date

Правило date проверяет, что значение является валидной, нерелятивной датой через PHP-функцию strtotime(). Принимает большинство распространённых форматов:

$request->validate([
    'birthday'   => ['required', 'date'],
    'hired_at'   => ['nullable', 'date'],
]);

date пропустит 2025-03-15, March 15, 2025, 15.03.2025 - всё, что strtotime сумеет разобрать как абсолютную дату. Относительные выражения вроде next Monday или +3 days будут отвергнуты, хотя strtotime сам по себе их понимает.

В этой гибкости кроется проблема: если нужен конкретный формат ввода, date пропустит слишком многое. Для строгого формата используйте date_format.

Отдельное правило date_equals проверяет совпадение с конкретной датой:

$request->validate([
    'confirmation' => ['required', 'date', 'date_equals:2025-12-31'],
]);

Используется редко, но бывает нужно для подтверждения фиксированного дедлайна.

Формат даты: date_format

Когда важен точный формат ввода, используйте date_format вместо date. Правило принимает один или несколько форматов через запятую:

$request->validate([
    'birthday'    => ['required', 'date_format:Y-m-d'],
    'event_date'  => ['required', 'date_format:d.m.Y'],
    'flexible'    => ['required', 'date_format:Y-m-d,d/m/Y'],
]);

Форматы соответствуют PHP DateTime::createFromFormat(). Частые варианты:

Важно: date и date_format нельзя использовать вместе для одного поля. date опирается на strtotime, а date_format на DateTime::createFromFormat - логика парсинга у них разная, и результаты будут непредсказуемыми.

Fluent date builder

Laravel предоставляет Rule::date() - fluent-построитель правил для дат:

use Illuminate\Validation\Rule;

$request->validate([
    'start_date' => [
        'required',
        Rule::date()->format('Y-m-d'),
    ],
]);

Это эквивалент date_format:Y-m-d, но читается чище в цепочке с другими условиями. Основное преимущество builder проявляется, когда нужно комбинировать формат и диапазон - вместо нескольких строковых правил получается одна цепочка вызовов.

Диапазоны: after и before

Правила after и before сравнивают дату с заданной границей. Значение границы проходит через strtotime, поэтому работают как абсолютные даты, так и относительные выражения:

$request->validate([
    'departure' => ['required', 'date', 'after:tomorrow'],
    'return'    => ['required', 'date', 'after:departure'],
]);

В первом случае дата вылета должна быть позже завтрашнего дня. Во втором - дата возврата после даты вылета. Под капотом Laravel сначала пробует распарсить аргумент как дату через strtotime/DateTime (см. ValidatesAttributes::compareDates). Если распарсить не удалось, аргумент трактуется как имя другого поля и берётся его значение. Поэтому ключевые слова today, tomorrow, yesterday всегда читаются как даты, даже если в запросе есть поле с таким именем - выбора между «полем» и «датой» нет, побеждает успешный парсинг.

after_or_equal и before_or_equal

Включительные варианты - дата может совпадать с границей:

$request->validate([
    'check_in'  => ['required', 'date', 'after_or_equal:today'],
    'check_out' => ['required', 'date', 'after:check_in'],
]);

Заезд может быть сегодня (after_or_equal:today), а выезд - строго после заезда (after:check_in).

before и before_or_equal

Обратная сторона - верхняя граница:

$request->validate([
    'birthday'   => ['required', 'date', 'before:today'],
    'start_date' => ['required', 'date', 'before_or_equal:end_date'],
]);

Дата рождения строго в прошлом. Начало периода - не позже конца.

Fluent-синтаксис для диапазонов

Rule::date() делает цепочки нагляднее, особенно с вычисляемыми границами:

use Illuminate\Validation\Rule;

$request->validate([
    'event_date' => [
        'required',
        Rule::date()
            ->afterToday()
            ->before(now()->addMonths(6)),
    ],
    'birth_date' => [
        'required',
        Rule::date()
            ->format('Y-m-d')
            ->beforeToday(),
    ],
]);

Доступные методы:

Относительные выражения strtotime

Строковая версия after и before принимает любое выражение, понятное strtotime:

$request->validate([
    'delivery_date' => ['required', 'date', 'after:+3 days'],
    'deadline'      => ['required', 'date', 'before:+1 year'],
    'meeting'       => ['required', 'date', 'after:next monday'],
]);

Это удобно, но хрупко - строка +3 days вычисляется от текущего момента на сервере. В тестах это поведение нужно контролировать через travelTo().

Динамические границы

Иногда диапазон зависит от бизнес-логики - например, бронирование не дальше чем на 90 дней вперёд:

public function rules(): array
{
    $maxDate = now()->addDays(90)->format('Y-m-d');

    return [
        'event_date' => [
            'required',
            'date_format:Y-m-d',
            'after_or_equal:today',
            "before_or_equal:{$maxDate}",
        ],
    ];
}

С fluent builder это же выглядит чище:

'event_date' => [
    'required',
    Rule::date()
        ->format('Y-m-d')
        ->todayOrAfter()
        ->beforeOrEqual(now()->addDays(90)),
],

Валидация времени

Отдельного правила time в Laravel нет. Время валидируется через date_format с соответствующим форматом:

$request->validate([
    'start_time'  => ['required', 'date_format:H:i'],
    'precise_time' => ['required', 'date_format:H:i:s'],
]);

H:i принимает 14:30, H:i:s принимает 14:30:00. Если нужен 12-часовой формат с AM/PM:

'time' => ['required', 'date_format:g:i A'], // 2:30 PM

Время и дата вместе

Для полей datetime используйте составной формат:

$request->validate([
    'starts_at' => ['required', 'date_format:Y-m-d H:i'],
    'ends_at'   => ['required', 'date_format:Y-m-d H:i', 'after:starts_at'],
]);

Сравнение after и before учитывает и дату, и время - значения парсятся целиком.

ISO 8601 с таймзоной

Для полей, которые приходят из JS-фронтенда в полном формате:

'starts_at' => ['required', 'date_format:Y-m-d\TH:i:sP'],  // 2025-03-15T14:30:00+03:00
'ends_at'   => ['required', 'date_format:Y-m-d\TH:i:sP', 'after:starts_at'],

Спецификатор P принимает смещение вида +03:00. Символ T между датой и временем экранируется обратным слешем.

Валидация timestamp

Unix timestamp - целое число секунд с 1 января 1970. Laravel не имеет отдельного правила для timestamp, но комбинация integer + min + max закрывает задачу:

$request->validate([
    'created_after' => ['required', 'integer', 'min:0'],
    'expires_at'    => ['required', 'integer', 'min:0', 'max:4102444800'],
]);

max:4102444800 - это 2100 год. Верхняя граница защищает от абсурдных значений.

Для миллисекундных timestamp (JavaScript Date.now() отдаёт миллисекунды) делите на 1000 в prepareForValidation() или валидируйте как 13-значное число:

'js_timestamp' => ['required', 'numeric', 'digits:13'],

digits проверяет строковую длину, поэтому сочетается с numeric, а не с integer.

Если timestamp приходит как строка из формы, добавьте numeric вместо integer:

'timestamp' => ['required', 'numeric', 'min:0'],

numeric принимает и дробные числа, поэтому для строгой проверки целочисленного timestamp предпочтительнее integer.

Для API, где клиент передаёт timestamp, а сервер хранит datetime, конвертируйте в prepareForValidation():

protected function prepareForValidation(): void
{
    if ($this->expires_at && is_numeric($this->expires_at)) {
        $this->merge([
            'expires_at' => Carbon::createFromTimestamp($this->expires_at)->format('Y-m-d H:i:s'),
        ]);
    }
}

Или наоборот - для конвертации timestamp в Carbon после валидации:

$validated = $request->validated();
$date = Carbon::createFromTimestamp($validated['expires_at']);

Валидация года

Год - четырёхзначное число. Чистая дата-валидация здесь не нужна, хватит числовых правил:

$request->validate([
    'birth_year'    => ['required', 'integer', 'digits:4', 'min:1900', 'max:' . date('Y')],
    'graduation'    => ['required', 'integer', 'digits:4', 'min:2000', 'max:2100'],
]);

digits:4 гарантирует ровно 4 цифры. min / max задают допустимый диапазон. Для года рождения верхняя граница - текущий год, для будущих событий - разумный предел.

Если нужно проверить год + месяц (например, срок действия карты), используйте date_format:

'card_expiry' => ['required', 'date_format:m/Y', 'after:today'],

Формат m/Y принимает 03/2027. Правило after:today гарантирует, что карта ещё не просрочена.

Часовой пояс: timezone

Правило timezone проверяет значение по списку DateTimeZone::listIdentifiers():

$request->validate([
    'tz' => ['required', 'timezone'],
]);

Принимает строки вроде Europe/Moscow, America/New_York, UTC. Можно фильтровать по региону:

// Только Европа
'tz' => ['required', 'timezone:Europe'],

// Только конкретная страна
'tz' => ['required', 'timezone:per_country,RU'],

// Все зоны (поведение по умолчанию)
'tz' => ['required', 'timezone:all'],

Фильтрация по стране полезна для форм, где пользователь уже указал страну - нет смысла показывать ему таймзоны Африки. Для России timezone:per_country,RU вернёт зоны от Калининграда до Камчатки.

Полный список регионов соответствует константам DateTimeZone: Africa, America, Antarctica, Arctic, Asia, Atlantic, Australia, Europe, Indian, Pacific. Значение all включает все зоны.

Проверка возраста

Прямого правила “минимальный возраст” нет, но before решает задачу:

$request->validate([
    'birthday' => [
        'required',
        'date_format:Y-m-d',
        'before:-18 years',    // старше 18
        'after:-120 years',    // но не 200 лет
    ],
]);

before:-18 years означает: дата рождения должна быть раньше, чем 18 лет назад от сегодня. strtotime('-18 years') вычислит границу автоматически.

Для точного контроля через fluent builder:

'birthday' => [
    'required',
    Rule::date()
        ->format('Y-m-d')
        ->beforeOrEqual(now()->subYears(18))
        ->after(now()->subYears(120)),
],

Рабочие часы и слоты

Валидация времени в рамках рабочего дня требует комбинации date_format и кастомной проверки через after:

class AppointmentRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'date' => [
                'required',
                Rule::date()->format('Y-m-d')->todayOrAfter(),
            ],
            'time' => ['required', 'date_format:H:i'],
        ];
    }

    public function after(): array
    {
        return [
            function ($validator) {
                $time = $this->input('time');
                if ($time && ('09:00' > $time || '18:00' < $time)) {
                    $validator->errors()->add('time', 'Приём только с 09:00 до 18:00.');
                }
            },
        ];
    }
}

Правилами after / before время само по себе не ограничить - они работают с полной датой. Для проверки только временного компонента нужен хук after() или кастомное правило.

Альтернативный подход - кастомный Rule-класс для переиспользования:

class WorkingHours implements ValidationRule
{
    public function __construct(
        private string $from = '09:00',
        private string $to = '18:00',
    ) {}

    // Строковое сравнение безопасно: date_format:H:i гарантирует двузначный формат
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if ($this->from > $value || $this->to < $value) {
            $fail("Время :attribute должно быть с {$this->from} до {$this->to}.");
        }
    }
}

Использование:

'appointment_time' => ['required', 'date_format:H:i', new WorkingHours('10:00', '19:00')],

Подробнее о кастомных правилах - в пользовательские правила.

Периодические даты

Иногда нужно проверить, что дата приходится на определённый день недели - например, доставка только по будням:

public function after(): array
{
    return [
        function ($validator) {
            $date = $this->validated('delivery_date') ?? null;
            if ($date && Carbon::parse($date)->isWeekend()) {
                $validator->errors()->add('delivery_date', 'Доставка по выходным не работает.');
            }
        },
    ];
}

Для проверки месяца, квартала или конкретного дня месяца работает та же схема - Carbon предоставляет методы isMonday(), isLastOfMonth(), quarter и другие.

Если доставка только в определённые даты (например, среда и пятница), логику можно завернуть в кастомное правило для переиспользования в нескольких формах. Подробнее - в пользовательские правила.

Хранение дат в базе данных

MySQL и PostgreSQL хранят даты в формате Y-m-d (DATE) и Y-m-d H:i:s (DATETIME/TIMESTAMP). Какой бы формат пользователь ни ввёл, к моменту вставки в базу дата должна быть в этом формате. Два подхода:

Первый - валидировать в нужном формате сразу (date_format:Y-m-d), чтобы validated-данные шли в модель напрямую.

Второй - принять удобный для пользователя формат (date_format:d.m.Y), конвертировать в prepareForValidation() или passedValidation(), и передать в модель. Второй подход гибче: форма может принимать 15.03.2025, а в базу ляжет 2025-03-15.

Если модель использует каст date или datetime, Eloquent сам конвертирует Carbon в нужный формат при записи. Главное, чтобы значение было валидной датой.

Полный пример: бронирование отеля

Форма бронирования с проверкой дат заезда, выезда и времени:

class BookingRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'check_in'  => [
                'required',
                Rule::date()
                    ->format('Y-m-d')
                    ->todayOrAfter(),
            ],
            'check_out' => [
                'required',
                Rule::date()
                    ->format('Y-m-d')
                    ->after('check_in'),
            ],
            'arrival_time' => ['nullable', 'date_format:H:i'],
            'guest_tz'     => ['required', 'timezone'],
        ];
    }

    public function messages(): array
    {
        return [
            'check_in.required'  => 'Укажите дату заезда.',
            'check_out.after'    => 'Дата выезда должна быть позже даты заезда.',
        ];
    }
}

Контроллер:

public function store(BookingRequest $request): RedirectResponse
{
    $data = $request->validated();

    $checkIn = Carbon::parse($data['check_in']);
    $nights  = $checkIn->diffInDays(Carbon::parse($data['check_out']));

    $booking = Booking::create([
        'check_in'  => $data['check_in'],
        'check_out' => $data['check_out'],
        'nights'    => $nights,
        'timezone'  => $data['guest_tz'],
    ]);

    return to_route('bookings.show', $booking);
}

Полный пример: API-фильтр по датам

Фильтрация записей по диапазону дат - типичная задача для API. Оба поля опциональны, но если конечная дата указана, она не может быть раньше начальной:

class OrderFilterRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'date_from' => ['nullable', 'date_format:Y-m-d'],
            'date_to'   => ['nullable', 'date_format:Y-m-d', 'after_or_equal:date_from'],
            'status'    => ['nullable', 'in:pending,paid,shipped'],
        ];
    }
}
public function index(OrderFilterRequest $request): JsonResponse
{
    $query = Order::query();

    if ($request->filled('date_from')) {
        $query->whereDate('created_at', '>=', $request->validated('date_from'));
    }

    if ($request->filled('date_to')) {
        $query->whereDate('created_at', '<=', $request->validated('date_to'));
    }

    return response()->json($query->paginate());
}

Правило after_or_equal:date_from не сработает, если date_from имеет значение null. Но если ключ date_from вообще отсутствует в запросе (не null, а не передан), поведение может отличаться. Для таких случаев добавьте sometimes к date_to - правила применятся только при наличии поля в запросе.

Для защиты от слишком широких диапазонов добавьте проверку в after():

public function after(): array
{
    return [
        function ($validator) {
            $from = $this->validated('date_from');
            $to = $this->validated('date_to');
            if ($from && $to && Carbon::parse($from)->diffInDays(Carbon::parse($to)) > 365) {
                $validator->errors()->add('date_to', 'Диапазон не может превышать 365 дней.');
            }
        },
    ];
}

Приведение типов через cast

После валидации дата приходит как строка. Для работы с ней в модели используйте каст:

protected function casts(): array
{
    return [
        'check_in'   => 'date',
        'check_out'  => 'date',
        'starts_at'  => 'datetime',
        'birth_year' => 'integer',
    ];
}

Каст date конвертирует в Carbon с обнулённым временем. datetime сохраняет и дату, и время. Можно указать формат хранения:

'event_date' => 'date:Y-m-d',
'starts_at'  => 'datetime:Y-m-d H:i',

После каста доступны все методы Carbon:

$booking->check_in->format('d.m.Y');
$booking->check_in->diffInDays($booking->check_out);
$booking->starts_at->isPast();

При сериализации в JSON (API-ответы) Carbon по умолчанию выдаёт ISO 8601. Для другого формата переопределите serializeDate в модели:

protected function serializeDate(DateTimeInterface $date): string
{
    return $date->format('Y-m-d H:i:s');
}

Мультизонные приложения

В приложениях с пользователями из разных часовых поясов возникает задача: хранить даты в UTC, показывать в локальном времени пользователя. Валидация в этом случае принимает дату в зоне пользователя и конвертирует в UTC:

class EventRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'starts_at' => ['required', 'date_format:Y-m-d H:i'],
            'timezone'  => ['required', 'timezone'],
        ];
    }

    protected function passedValidation(): void
    {
        $userTz = $this->validated('timezone');
        $utc = Carbon::createFromFormat(
            'Y-m-d H:i',
            $this->validated('starts_at'),
            $userTz
        )->utc();

        $this->merge(['starts_at' => $utc->format('Y-m-d H:i:s')]);
    }
}

Конвертация происходит в passedValidation() - после проверки, но до попадания в контроллер. Контроллер получает дату уже в UTC и сохраняет её как есть. Обратная конвертация для отображения - на стороне фронтенда или в аксессоре модели.

Важно: config('app.timezone') по умолчанию UTC. Если вы поменяли её на Europe/Moscow, все даты в базе будут в московском времени. Для мультизонных приложений оставляйте UTC в конфиге и конвертируйте на уровне отображения.

prepareForValidation для дат

Пользователи вводят даты в разных форматах. Если форма принимает dd.mm.yyyy, но в базу нужен Y-m-d, приведите формат до валидации:

protected function prepareForValidation(): void
{
    if ($this->check_in) {
        $parsed = Carbon::createFromFormat('d.m.Y', $this->check_in);
        if ($parsed) {
            $this->merge(['check_in' => $parsed->format('Y-m-d')]);
        }
    }
}

public function rules(): array
{
    return [
        'check_in' => ['required', 'date_format:Y-m-d', 'after_or_equal:today'],
    ];
}

Другой подход - принять несколько форматов через date_format:Y-m-d,d.m.Y и привести к единому формату после валидации в passedValidation().

Даты в API: ISO 8601

Для API стандартом де-факто стал ISO 8601. Клиент отправляет 2025-03-15T14:30:00Z или 2025-03-15T14:30:00+03:00. Валидация:

$request->validate([
    'starts_at' => ['required', 'date'],
    'ends_at'   => ['required', 'date', 'after:starts_at'],
]);

Здесь date подходит лучше, чем date_format, потому что ISO 8601 имеет варианты: с миллисекундами и без, с Z или со смещением +03:00. Правило date через strtotime принимает все эти варианты.

Если нужен строгий ISO без вариаций:

'starts_at' => ['required', 'date_format:Y-m-d\TH:i:sP'],

Формат P означает смещение вида +03:00. Буква T экранируется обратным слешем, чтобы не быть интерпретированной как спецификатор.

Условная валидация дат

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

$request->validate([
    'start_date' => ['required', 'date_format:Y-m-d'],
    'end_date'   => ['required_with:start_date', 'nullable', 'date_format:Y-m-d', 'after:start_date'],
    'start_time' => ['nullable', 'date_format:H:i'],
    'end_time'   => ['required_with:start_time', 'nullable', 'date_format:H:i'],
]);

required_with:start_date означает: end_date обязательна, если start_date заполнена. Подробнее об условных правилах - в условная валидация.

Ещё один паттерн - выбор между полной датой и относительным периодом. Пользователь указывает либо конкретные даты, либо период “последние 7 дней”:

$request->validate([
    'period'    => ['required', 'in:custom,7d,30d,90d'],
    'date_from' => ['required_if:period,custom', 'nullable', 'date_format:Y-m-d'],
    'date_to'   => ['required_if:period,custom', 'nullable', 'date_format:Y-m-d', 'after_or_equal:date_from'],
]);

При period=custom обе даты обязательны. При period=30d они игнорируются, а контроллер вычислит диапазон сам.

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

Даты зависят от текущего времени, и тесты без фиксации даты будут нестабильными. Carbon позволяет заморозить время:

public function test_check_in_must_be_today_or_later(): void
{
    $this->travelTo('2025-06-15');

    $response = $this->post('/bookings', [
        'check_in'  => '2025-06-14',
        'check_out' => '2025-06-16',
        'guest_tz'  => 'Europe/Moscow',
    ]);

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

public function test_check_out_must_be_after_check_in(): void
{
    $this->travelTo('2025-06-15');

    $response = $this->post('/bookings', [
        'check_in'  => '2025-06-20',
        'check_out' => '2025-06-18',
        'guest_tz'  => 'Europe/Moscow',
    ]);

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

public function test_valid_booking_passes(): void
{
    $this->travelTo('2025-06-15');

    $response = $this->post('/bookings', [
        'check_in'  => '2025-06-20',
        'check_out' => '2025-06-25',
        'guest_tz'  => 'Europe/Moscow',
    ]);

    $response->assertSessionHasNoErrors();
}

travelTo() фиксирует “сейчас” для всего теста. Без этого after_or_equal:today будет давать разные результаты в разные дни.

Для API-тестов с датами:

public function test_api_date_filter(): void
{
    Order::factory()->create(['created_at' => '2025-06-10']);
    Order::factory()->create(['created_at' => '2025-06-20']);

    $response = $this->getJson('/api/orders?date_from=2025-06-15');

    $response->assertOk()
             ->assertJsonCount(1, 'data');
}

public function test_invalid_date_format_returns_422(): void
{
    $response = $this->postJson('/api/events', [
        'starts_at' => 'not-a-date',
    ]);

    $response->assertStatus(422)
             ->assertJsonValidationErrors('starts_at');
}

Отдельно тестируйте граничные случаи: сегодняшняя дата при todayOrAfter, последний день месяца, 29 февраля в високосный и обычный год. Именно на границах чаще всего ломается валидация.

Для массового тестирования форматов удобен data provider:

#[DataProvider('invalidDates')]
public function test_rejects_invalid_dates(string $date): void
{
    $response = $this->post('/events', ['starts_at' => $date]);
    $response->assertSessionHasErrors('starts_at');
}

public static function invalidDates(): array
{
    return [
        'empty string' => [''],
        'text'         => ['not-a-date'],
        'wrong format' => ['15/03/2025'],
        'invalid day'  => ['2025-02-30'],
    ];
}

Частые ошибки

date и date_format одновременно

// Ошибка: два парсера на одно поле
'birthday' => ['required', 'date', 'date_format:Y-m-d'],

date использует strtotime, date_format использует DateTime::createFromFormat. Два разных парсера на одно поле дают непредсказуемый результат. Выбирайте одно: date для гибкого ввода, date_format для строгого формата.

after:today vs afterToday()

Строковое after:today сравнивает через strtotime('today'), который возвращает полночь текущего дня. Если пользователь отправляет сегодняшнюю дату - она не пройдёт, потому что strtotime из неё тоже сделает полночь, а after требует строго больше. Для “сегодня или позже” используйте after_or_equal:today или Rule::date()->todayOrAfter().

Формат dd.mm.yyyy

// Не работает как ожидается
'date' => ['required', 'date_format:dd.mm.yyyy'],

// Правильно
'date' => ['required', 'date_format:d.m.Y'],

PHP использует одиночные буквы для форматов: d - день, m - месяц, Y - четырёхзначный год. Удвоенное dd интерпретируется как день + литерал d. Строчная y означает двузначный год (25), заглавная Y - четырёхзначный (2025).

Таймзона не влияет на сравнение

Правила after и before сравнивают даты без учёта таймзоны. Если пользователь в Europe/Moscow отправляет 2025-06-15 23:00, а сервер в UTC, сравнение пройдёт по серверному времени. Для корректной работы с таймзонами конвертируйте даты в prepareForValidation().

JavaScript-датапикер отправляет ISO 8601

Многие JS-компоненты (flatpickr, Vue datepicker) отправляют дату в формате 2025-03-15T14:30:00.000Z. Правило date это примет, а date_format:Y-m-d - нет. Если датапикер отправляет ISO 8601, либо настройте его формат вывода, либо обрежьте строку в prepareForValidation():

protected function prepareForValidation(): void
{
    if ($this->event_date && str_contains($this->event_date, 'T')) {
        $this->merge([
            'event_date' => Carbon::parse($this->event_date)->format('Y-m-d'),
        ]);
    }
}

Високосный год и 29 февраля

date_format:Y-m-d пропустит 2024-02-29 (високосный), но отвергнет 2025-02-29 (не високосный). Это корректное поведение - DateTime::createFromFormat проверяет существование даты. Но date с strtotime может повести себя иначе: strtotime('2025-02-29') тихо превратит дату в 2025-03-01. Ещё один повод предпочитать date_format для строгой валидации.

before без date / date_format

Правила after и before не проверяют формат сами по себе. Если написать 'start' => ['required', 'after:today'] без date или date_format, строка pizza пройдёт проверку required, а after получит невалидное значение и может повести себя непредсказуемо. Всегда комбинируйте с date или date_format:

// Правильно
'start' => ['required', 'date', 'after:today'],

// Тоже правильно
'start' => ['required', 'date_format:Y-m-d', 'after:today'],