Validar fechas y horas en Laravel 13

Un campo de fecha llega del formulario como texto plano. Si no se valida, valores como abc, 31/02/2025 o next Monday terminan en la base de datos provocando errores silenciosos o consultas rotas. Laravel incluye un conjunto de reglas para comprobar fechas, rangos temporales y zonas horarias sin necesidad de parsear a mano.

Para conceptos generales de validación, consulta la guía de inicio rápido. Las reglas numéricas (min, max, between) se tratan en cadenas y números.

La regla date

date comprueba que el valor sea una fecha absoluta válida usando strtotime() de PHP. Acepta la mayoría de formatos habituales:

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

Cadenas como 2025-06-15, June 15, 2025 o 15.06.2025 pasan sin problema. En cambio, expresiones relativas (next Monday, +3 days) se rechazan, aunque strtotime normalmente las entienda.

Esa flexibilidad es al mismo tiempo el punto débil: 01/02/2025 pasa la validación, pero strtotime interpreta el separador / como formato americano (m/d/Y), así que el resultado es 2 de enero, no 1 de febrero. La interpretación depende del separador, no del locale del servidor: / siempre es m/d/Y, - es d-m-Y cuando hay ambigüedad, . es d.m.Y. Si la API espera un formato concreto, date resulta demasiado permisiva. Para forzar un formato específico, date_format es la opción correcta.

date_equals

Comprueba que la fecha coincida exactamente con un valor fijo:

$request->validate([
    'fecha_firma' => ['required', 'date', 'date_equals:2026-07-01'],
]);

Poco frecuente, pero resulta práctico cuando un formulario exige que el usuario confirme una fecha inamovible (cierre fiscal, fecha límite de contrato).

Forzar un formato con date_format

Cuando el valor debe ajustarse a un formato preciso, date_format es la regla indicada. Acepta uno o varios patrones de formato:

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

Los formatos siguen la sintaxis de DateTime::createFromFormat(). Algunos patrones habituales:

PatrónEjemploUso típico
Y-m-d2025-06-15APIs, almacenamiento
d/m/Y15/06/2025España, Latinoamérica
d.m.Y15.06.2025Centroeuropa
m/d/Y06/15/2025EE.UU.
Y-m-d H:i:s2025-06-15 14:30:00Datetime completo
Y-m-d\TH:i:sP2025-06-15T14:30:00+02:00ISO 8601 con offset

No combines date y date_format en el mismo campo. date utiliza strtotime; date_format recurre a DateTime::createFromFormat. Son motores de parseo diferentes y pueden contradecirse.

El problema del cero inicial en dd/mm/yyyy

date_format:d/m/Y es estricto con los ceros a la izquierda. El valor 5/3/2025 se rechaza porque d exige dos dígitos (05). Si el frontend omite los ceros, hay dos opciones: rellenar en prepareForValidation(), o aceptar ambas variantes con múltiples formatos:

// Estricto: solo 05/03/2025
'evento' => ['required', 'date_format:d/m/Y'],

// Flexible: acepta 5/3/2025 y 05/03/2025
'evento' => ['required', 'date_format:d/m/Y,j/n/Y'],

Rule::date() – constructor fluido

Laravel ofrece Rule::date() como alternativa fluida a la cadena de texto:

use Illuminate\Validation\Rule;

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

Equivale a date_format:Y-m-d, pero la ventaja real aparece al encadenar restricciones de rango en la misma llamada, como se ve en las secciones siguientes.

Rangos de fechas: after y before

after y before comparan una fecha contra un límite. El límite se parsea con strtotime, por lo que acepta tanto fechas absolutas como expresiones relativas:

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

En la primera línea, la salida debe ser posterior a mañana. En la segunda, el regreso posterior a la salida. Laravel detecta cuándo el argumento coincide con el nombre de otro campo del request y compara contra su valor.

La regla after es lo que cubre la búsqueda “fecha mayor que” o “fecha posterior a hoy” en Laravel – no existe una regla greater_than separada para fechas.

Variantes inclusivas

after_or_equal y before_or_equal permiten que la fecha coincida con el límite:

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

El check-in puede ser hoy (after_or_equal:today), el check-out debe ser estrictamente posterior al check-in.

$request->validate([
    'nacimiento'  => ['required', 'date', 'before:today'],
    'fecha_inicio' => ['required', 'date', 'before_or_equal:fecha_fin'],
]);

Sintaxis fluida para rangos

Con Rule::date(), los rangos se expresan de forma encadenada:

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

Los métodos del builder: after($date), afterOrEqual($date), before($date), beforeOrEqual($date), afterToday(), todayOrAfter(), beforeToday(), todayOrBefore(), between($from, $to), betweenOrEqual($from, $to), past(), future(), nowOrPast(), nowOrFuture().

Expresiones relativas con strtotime

La versión de cadena de after y before acepta cualquier expresión que strtotime entienda:

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

Resulta cómodo, pero frágil: +3 days se calcula desde la hora actual del servidor. En tests, controla esto con $this->travelTo() para evitar aserciones inestables.

Fecha entre dos valores

No existe una regla date_between. Para acotar una fecha a un rango, se combinan after_or_equal y before_or_equal:

$request->validate([
    'fecha_evento' => [
        'required',
        'date_format:Y-m-d',
        'after_or_equal:2026-01-01',
        'before_or_equal:2026-12-31',
    ],
]);

Con el builder fluido, betweenOrEqual lo resume en una sola restricción:

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

Para validar un rango de fechas donde ambos extremos llegan del usuario (fecha de inicio y fin de un informe, periodo de reserva), la combinación habitual es:

$request->validate([
    'desde' => ['required', 'date_format:Y-m-d'],
    'hasta' => ['required', 'date_format:Y-m-d', 'after:desde'],
]);

Validar hora

Laravel no tiene una regla time dedicada. Las horas se validan con date_format usando patrones de solo tiempo:

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

H:i acepta 14:30, H:i:s acepta 14:30:00. Para formato de 12 horas con AM/PM:

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

Campos datetime

Para campos que contienen fecha y hora juntas:

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

La comparación de after opera sobre el timestamp completo, fecha y hora incluidas.

ISO 8601 con zona horaria

Las aplicaciones SPA y móviles suelen enviar datetimes en formato ISO 8601:

'empieza_el' => ['required', 'date_format:Y-m-d\TH:i:sP'],
// acepta 2025-06-15T14:30:00+02:00

El especificador P captura el offset UTC como +02:00. La T entre fecha y hora se escapa con barra invertida.

Un detalle con Date.toISOString() de JavaScript: genera 2025-06-15T14:30:00.000Z con milisegundos. El patrón Y-m-d\TH:i:sP no los acepta. La solución más limpia es recortar los milisegundos en prepareForValidation():

protected function prepareForValidation(): void
{
    if ($this->empieza_el) {
        $this->merge([
            'empieza_el' => preg_replace('/\.\d{3}Z$/', '+00:00', $this->empieza_el),
        ]);
    }
}

Validar zona horaria

La regla timezone compara el valor contra DateTimeZone::listIdentifiers():

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

Acepta identificadores como Europe/Madrid, America/Mexico_City, UTC. Se puede filtrar por región o país:

// Solo Europa
'zona_horaria' => ['required', 'timezone:Europe'],

// Solo un país concreto
'zona_horaria' => ['required', 'timezone:per_country,ES'],

Regiones disponibles: Africa, America, Antarctica, Arctic, Asia, Atlantic, Australia, Europe, Indian, Pacific. El valor all (por defecto) incluye todas.

Timestamps Unix

Los timestamps (segundos desde 1970-01-01) no tienen regla propia. Se validan con integer y límites:

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

El tope de 4102444800 corresponde al año 2100 – un control de cordura contra valores absurdos. El Date.now() de JavaScript devuelve milisegundos en lugar de segundos; si la API recibe timestamps de JS, divide entre 1000 en prepareForValidation() o valida la longitud con digits:13.

Si la API acepta timestamp pero el modelo almacena una columna datetime, convierte en prepareForValidation():

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

Los detalles de prepareForValidation() se cubren en el artículo de Form Requests.

Zona horaria del servidor vs zona del usuario

Las reglas after:today y before:today se evalúan contra la hora del servidor, configurada en config/app.php (timezone). Un usuario en Ciudad de México que envía un formulario a las 23:30 CST podría estar en “mañana” según un servidor en Madrid (06:30 CET del día siguiente).

Una solución: aceptar la zona horaria junto con la fecha y calcular los límites en un hook after():

public function rules(): array
{
    return [
        'zona_horaria' => ['required', 'timezone'],
        'fecha_evento' => ['required', 'date_format:Y-m-d'],
    ];
}

public function after(): array
{
    return [
        function ($validator) {
            $tz = $this->validated('zona_horaria') ?? 'UTC';
            $fechaEvento = Carbon::createFromFormat('Y-m-d', $this->fecha_evento, $tz);
            $hoy = Carbon::now($tz)->startOfDay();

            if ($fechaEvento->lt($hoy)) {
                $validator->errors()->add(
                    'fecha_evento',
                    'La fecha debe ser hoy o posterior en tu zona horaria.'
                );
            }
        },
    ];
}

La otra opción – exigir que el frontend envíe datetimes en UTC (Y-m-d\TH:i:sZ) y convertir a la zona del usuario solo para mostrar. Simplifica la validación porque todas las comparaciones operan en la misma referencia temporal y no hay que transportar la zona del cliente en cada petición.

Verificación de edad

No hay regla “edad mínima”, pero before la resuelve:

$request->validate([
    'nacimiento' => [
        'required',
        'date_format:Y-m-d',
        'before:-18 years',
        'after:-120 years',
    ],
]);

before:-18 years significa que la fecha de nacimiento debe ser anterior a hace 18 años. strtotime('-18 years') calcula el límite. Con el builder fluido:

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

Fechas opcionales y nullable

Un campo de fecha opcional necesita nullable. Sin ella, el middleware ConvertEmptyStringsToNull transforma "" en null, y null no pasa la validación de date:

$request->validate([
    'fecha_inicio' => ['required', 'date_format:Y-m-d'],
    'fecha_fin'    => ['nullable', 'date_format:Y-m-d', 'after:fecha_inicio'],
]);

Cuando fecha_fin es null, la regla nullable cortocircuita la cadena: date_format y after no se ejecutan. El campo aparece como null en validated().

Para peticiones PATCH donde la fecha puede no estar presente en el payload:

'limite' => ['sometimes', 'nullable', 'date_format:Y-m-d', 'after:today'],

sometimes ignora el campo si la clave no existe. nullable permite null. Juntos cubren los casos “no enviado” y “borrado explícitamente”. La diferencia entre estos modificadores se explica en la referencia de reglas.

Errores frecuentes

Usar date cuando se necesita date_formatdate acepta June 15, 2025 y 15.06.2025 por igual. Si el contrato de la API exige formato ISO, date no rechazará formatos europeos o textuales. Para cumplir un formato estricto, siempre date_format:Y-m-d.

Combinar date y date_format – motores de parseo distintos (strtotime vs DateTime::createFromFormat) que pueden contradecirse. Elige uno.

Comparar campos con formatos incompatibles – si fecha_inicio usa date_format:d/m/Y y fecha_fin usa date_format:Y-m-d, la comparación after:fecha_inicio puede dar resultados incorrectos. Cuando existen reglas date_format, Laravel intenta parsear cada valor con su formato declarado mediante DateTime::createFromFormat. Si los formatos no son consistentes entre los dos campos, la conversión interna puede fallar silenciosamente o comparar objetos DateTime mal construidos. Ambos campos deben compartir formato.

Olvidar nullable en fechas opcionales – un input vacío se convierte en null tras el middleware. Sin nullable, la regla date rechaza null. Las fechas opcionales siempre llevan nullable.

after contra un campo vacío – si el campo de referencia (fecha_inicio) es nullable y llega como null, la regla after:fecha_inicio sobre fecha_fin se comporta de forma impredecible. Combinar con required_with para garantizar que el campo de referencia exista cuando se necesita la comparación:

'fecha_fin' => ['required_with:fecha_inicio', 'nullable', 'date', 'after:fecha_inicio'],

Fechas relativas en testsafter:tomorrow o before:+30 days se calcula desde el reloj del servidor. Tests que se ejecutan a medianoche pueden pasar o fallar según la zona horaria. Congelar el tiempo con $this->travelTo().

Formulario de reserva completo

Un ejemplo que combina fecha, hora, zona horaria y un hook de validación:

class ReservaRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'fecha' => [
                'required',
                Rule::date()
                    ->format('Y-m-d')
                    ->todayOrAfter()
                    ->beforeOrEqual(now()->addDays(90)),
            ],
            'hora'     => ['required', 'date_format:H:i'],
            'zona'     => ['required', 'timezone'],
            'duracion' => ['required', 'integer', 'in:30,60,90'],
        ];
    }

    public function after(): array
    {
        return [
            function ($validator) {
                $fecha = $this->validated('fecha') ?? null;
                if ($fecha && Carbon::parse($fecha)->isWeekend()) {
                    $validator->errors()->add(
                        'fecha',
                        'Las reservas solo están disponibles de lunes a viernes.'
                    );
                }
            },
        ];
    }
}

Rule::date() para la fecha, date_format:H:i para la hora, timezone para la zona del usuario, y un hook after() para la restricción de día laborable. Cada parte utiliza la herramienta adecuada: reglas integradas donde encajan, lógica personalizada donde no. Más sobre reglas propias en el artículo de reglas personalizadas.

Almacenar fechas en la base de datos

MySQL y PostgreSQL almacenan fechas como Y-m-d (DATE) y Y-m-d H:i:s (DATETIME). Dos enfoques:

  1. Validar directamente en el formato de la base de datos (date_format:Y-m-d) para que la salida de validated() se use sin transformar
  2. Aceptar un formato amigable (date_format:d/m/Y), convertir en prepareForValidation() y pasar el resultado al modelo

Si el modelo define un cast date o datetime, Eloquent convierte Carbon al formato de la base de datos de forma automática. Lo importante es que el valor sea una fecha válida cuando llegue al modelo. El segundo enfoque tiene la ventaja de que los mensajes de error de validación muestran el formato original del usuario (15/06/2026), no el formato interno, lo que resulta menos confuso en formularios de cara al público.

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

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

El usuario escribe 15/06/2026, prepareForValidation() lo transforma en 2026-06-15, y las reglas validan el valor normalizado.

Para la lista completa de reglas de validación, consulta la referencia de reglas. La personalización de mensajes de error para campos de fecha se cubre en mensajes de error. La validación condicional de fechas (exigir una fecha solo cuando otro campo tiene cierto valor) está en validación condicional. Para validar arrays de fechas (por ejemplo, una lista de fechas de eventos), consulta arrays y JSON.