Validar fechas y horas en Laravel 13
En esta página 20
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. 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ón | Ejemplo | Uso típico |
|---|---|---|
Y-m-d | 2025-06-15 | APIs, almacenamiento |
d/m/Y | 15/06/2025 | España, Latinoamérica |
d.m.Y | 15.06.2025 | Centroeuropa |
m/d/Y | 06/15/2025 | EE.UU. |
Y-m-d H:i:s | 2025-06-15 14:30:00 | Datetime completo |
Y-m-d\TH:i:sP | 2025-06-15T14:30:00+02:00 | ISO 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'],
Los especificadores j y n aceptan día y mes sin ceros (1-31 y 1-12). Combinar ambos formatos cubre los dos casos sin tocar el dato antes de la validación.
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 primero intenta parsear el argumento como fecha mediante strtotime/DateTime. Si el parseo falla, trata el argumento como nombre de otro campo del request y compara contra su valor. Por eso palabras reservadas como today, tomorrow y yesterday se interpretan siempre como fechas, incluso si existe un campo con ese nombre.
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(),
],
]);
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(). Los cuatro últimos comparan contra now(): past() equivale a before('now'), future() a after('now'), y las variantes nowOrPast/nowOrFuture permiten la igualdad con el instante actual.
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.
Límites dinámicos
Cuando el rango depende de lógica de negocio (por ejemplo, reservas hasta 90 días por delante), el cálculo va en rules():
public function rules(): array
{
$maxFecha = now()->addDays(90)->format('Y-m-d');
return [
'fecha_evento' => [
'required',
'date_format:Y-m-d',
'after_or_equal:today',
"before_or_equal:{$maxFecha}",
],
];
}
Con el builder fluido queda más limpio:
'fecha_evento' => [
'required',
Rule::date()
->format('Y-m-d')
->todayOrAfter()
->beforeOrEqual(now()->addDays(90)),
],
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),
]);
}
}
Si el frontend siempre envía ISO 8601 pero con variaciones (con o sin milisegundos, con Z o con offset), date es más permisiva que date_format y suele bastar:
'empieza_el' => ['required', 'date'],
'termina_el' => ['required', 'date', 'after:empieza_el'],
strtotime acepta las variaciones habituales de ISO 8601 sin tropezar con los milisegundos.
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.
Filtrar por país es útil cuando el usuario ya eligió su país en un paso previo: para España, timezone:per_country,ES reduce la lista a Europe/Madrid, Africa/Ceuta y Atlantic/Canary, evitando que aparezcan zonas asiáticas o americanas en el desplegable.
Validar año
El año es un número entero de cuatro cifras. Para campos como año de nacimiento o año de graduación, las reglas numéricas bastan:
$request->validate([
'anio_nacimiento' => ['required', 'integer', 'digits:4', 'min:1900', 'max:' . date('Y')],
'graduacion' => ['required', 'integer', 'digits:4', 'min:2000', 'max:2100'],
]);
digits:4 garantiza exactamente cuatro dígitos. min y max acotan el rango. Para el año de nacimiento la cota superior es el año actual; para eventos futuros, un límite razonable.
Cuando se necesita año + mes (caducidad de tarjeta, periodo contable), date_format resuelve la combinación:
'caducidad_tarjeta' => ['required', 'date_format:m/Y', 'after:today'],
El formato m/Y acepta 03/2027. La regla after:today garantiza que la tarjeta no esté caducada.
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.
Date.now() de JavaScript devuelve milisegundos en lugar de segundos. Si la API recibe timestamps desde JS, hay dos opciones. Validar la longitud:
'js_timestamp' => ['required', 'numeric', 'digits:13'],
digits valida la longitud como cadena de dígitos (rechaza cualquier carácter no numérico, incluido el punto decimal). Se prefiere numeric sobre integer aquí porque numeric acepta strings numéricos largos sin pasar por casting a int, evitando matices de overflow en entornos PHP de 32 bits.
O dividir entre 1000 antes de la validación, usando aritmética sobre cadena para no depender del tamaño del int del runtime:
protected function prepareForValidation(): void
{
if ($this->expira_el && is_numeric($this->expira_el) && strlen((string) $this->expira_el) === 13) {
$this->merge(['expira_el' => (int) substr((string) $this->expira_el, 0, -3)]);
}
}
Cortar los tres últimos dígitos con substr divide por 1000 sin pasar por (int) sobre el valor original. En servidores PHP de 64 bits, intdiv((int) $val, 1000) también funciona, pero el patrón con substr es robusto en cualquier arquitectura.
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.
Aplicaciones multi-zona
En aplicaciones con usuarios de zonas horarias distintas, el patrón habitual es almacenar fechas en UTC y mostrarlas en la zona local del usuario. La validación recibe la fecha junto con la zona del usuario, y la conversión a UTC se hace en el controlador justo antes de guardar:
class EventoRequest extends FormRequest
{
public function rules(): array
{
return [
'empieza_el' => ['required', 'date_format:Y-m-d H:i'],
'zona_horaria' => ['required', 'timezone'],
];
}
}
En el controlador:
use Illuminate\Support\Arr;
use Illuminate\Validation\ValidationException;
use Carbon\Carbon;
public function store(EventoRequest $request)
{
$data = $request->validated();
$carbon = Carbon::createFromFormat('Y-m-d H:i', $data['empieza_el'], $data['zona_horaria']);
if (!$carbon) {
throw ValidationException::withMessages([
'empieza_el' => 'La combinación de fecha y zona horaria no es válida.',
]);
}
$data['empieza_el'] = $carbon->utc()->format('Y-m-d H:i:s');
Evento::create(Arr::except($data, 'zona_horaria'));
}
createFromFormat devuelve false si la cadena no encaja con el formato; lanzar ValidationException::withMessages() mantiene el contrato de respuesta 422 con la clave errors que el resto del flujo de Laravel ya utiliza (formularios redirigen con withErrors, APIs reciben JSON con shape estándar). Como EventoRequest ya valida empieza_el con date_format:Y-m-d H:i y zona_horaria con timezone, en condiciones normales el constructor de Carbon no falla; el guard cubre el caso límite (cambios de regla, llamadas con all() en vez de validated()).
Arr::except retira la zona horaria del array antes de pasarlo a create(): si la tabla eventos no tiene esa columna, Eloquent mandará un INSERT con una columna inexistente y MySQL devolverá QueryException (no MassAssignmentException, ese es otro mecanismo). Cuando $fillable está acotado y no incluye zona_horaria, Eloquent descarta la clave silenciosamente; aun así conviene ser explícito.
La conversión inversa para mostrar se hace en el frontend o en un accesor del modelo.
Un detalle importante si se prefiere empaquetar la conversión dentro del FormRequest con passedValidation() + $this->merge(): el merge actualiza el input bag del Request (lo que devuelven $request->input() o $request->empieza_el), pero NO actualiza lo que devuelve $request->validated(), que captura un snapshot del validator anterior al merge. El controlador que use $request->validated('empieza_el') recibirá el valor ORIGINAL sin convertir, no el valor mergeado. Si se sigue ese patrón, leer en el controlador con $request->input('empieza_el') en vez de validated(), o convertir directamente en el controlador como en el ejemplo anterior.
Sobre config('app.timezone'): por defecto es UTC y se inyecta como zona por defecto de PHP, así que now(), today() y los Carbons nuevos sin zona explícita la heredan. Eloquent también la usa al leer fechas desde la base: el cast datetime interpreta la cadena devuelta por el driver como si estuviera en app.timezone y construye el Carbon con esa etiqueta. No reconvierte el instante, solo lo etiqueta. Si la cadena en la base se escribió desde otra zona, el Carbon resultante apunta a un instante real distinto del que se quería guardar.
Otro matiz: en MySQL, las columnas TIMESTAMP (las que crean $table->timestamps(), $table->timestamp('...') o el created_at/updated_at por defecto) aplican conversión session-TZ ↔ UTC al escribir y al leer. Las columnas DATETIME ($table->dateTime('...')) almacenan la cadena tal cual, sin conversión. La diferencia importa al cambiar de zona: con TIMESTAMP, MySQL ya hace parte del trabajo si la sesión declara su zona; con DATETIME, la conversión manual a UTC antes de guardar es obligatoria para que registros generados desde sesiones distintas convivan coherentemente.
Para aplicaciones multi-zona conviene fijar UTC en app.timezone y convertir solo al presentar.
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)->startOfDay())
->after(now()->subYears(120)),
],
La cota inferior (-120 años) evita errores tipográficos absurdos como 1850. No es una protección de seguridad, sino una validación de cordura.
Un matiz sobre el cálculo del límite: date_format:Y-m-d parsea la fecha del usuario a las 00:00:00 (medianoche), mientras que -18 years se resuelve con strtotime, que arrastra la hora actual del servidor. Resultado: el corte no ocurre exactamente al instante del cumpleaños, sino al instante actual del día en que se cumplen 18 años. Para la mayoría de validaciones legales eso basta, pero si necesitas un corte estricto por día, calcula el límite a medianoche con now()->subYears(18)->startOfDay() (como en el ejemplo fluido) y compara con before_or_equal.
Horarios de trabajo y franjas
Las reglas after y before no acotan la hora por sí solas: comparan fechas completas. after:09:00 no significa “más tarde de las 9”: strtotime('09:00') devuelve hoy a las 9, y la comparación termina contra ese timestamp puntual. Para horario de oficina conviene un hook after() o una regla personalizada.
Versión con hook dentro del FormRequest:
class CitaRequest extends FormRequest
{
public function rules(): array
{
return [
'fecha' => ['required', Rule::date()->format('Y-m-d')->todayOrAfter()],
'hora' => ['required', 'date_format:H:i'],
];
}
public function after(): array
{
return [
function ($validator) {
$hora = $this->input('hora');
if ($hora && ('09:00' > $hora || '18:00' < $hora)) {
$validator->errors()->add('hora', 'El horario de atención es de 09:00 a 18:00.');
}
},
];
}
}
La comparación de cadenas ('09:00' > $hora) es segura porque date_format:H:i ya garantiza que $hora venga con dos dígitos en hora y minuto. Sin date_format previo, el orden lexicográfico mentiría con valores como 9:00 o 8:30.
Para reutilizar en varios formularios, conviene una regla:
class HorarioLaboral implements ValidationRule
{
public function __construct(
private string $desde = '09:00',
private string $hasta = '18:00',
) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (!preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', (string) $value)) {
$fail('El formato de hora debe ser HH:mm.');
return;
}
if ($this->desde > $value || $this->hasta < $value) {
$fail("La hora de :attribute debe estar entre {$this->desde} y {$this->hasta}.");
}
}
}
Uso:
'hora_cita' => ['required', 'date_format:H:i', new HorarioLaboral('10:00', '19:00')],
El guard inicial protege contra el caso en que alguien use la regla sin date_format:H:i previo. Sin ese check, una entrada como 9:00 (sin cero a la izquierda) rompería la comparación lexicográfica: '18:00' < '9:00' resulta en true porque el primer carácter ('1' < '9' en ASCII) decide el orden, y '9:00' sería rechazado como fuera de horario sin razón real.
Más sobre reglas propias en el artículo de reglas personalizadas.
Fechas periódicas
A veces interesa restringir la fecha a determinados días de la semana: envío solo entre semana, citas únicamente los lunes:
public function after(): array
{
return [
function ($validator) {
$fecha = $this->validated('fecha_entrega') ?? null;
if ($fecha && Carbon::parse($fecha)->isWeekend()) {
$validator->errors()->add(
'fecha_entrega',
'Los envíos solo se realizan de lunes a viernes.'
);
}
},
];
}
Carbon expone isMonday(), isTuesday(), isWeekend(), isLastOfMonth() y la propiedad quarter, entre otros. Cubren los casos típicos sin parsear el día a mano.
Si la lista de fechas válidas es fija (festivos, vacaciones de la empresa), envuelve la lógica en una regla personalizada para reutilizarla:
class FechaLaborable implements ValidationRule
{
public function __construct(private array $festivos = []) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$dia = Carbon::parse($value);
if ($dia->isWeekend() || in_array($dia->format('Y-m-d'), $this->festivos, true)) {
$fail('El :attribute debe ser un día laborable.');
}
}
}
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.
Casts de fechas en Eloquent
Después de la validación, la fecha es una cadena. Para trabajarla en el modelo, declara un cast:
protected function casts(): array
{
return [
'check_in' => 'date',
'check_out' => 'date',
'empieza_el' => 'datetime',
'anio_nacimiento' => 'integer',
];
}
date convierte a Carbon con la hora a cero. datetime mantiene fecha y hora. Se puede fijar formato de almacenamiento:
'fecha_evento' => 'date:Y-m-d',
'empieza_el' => 'datetime:Y-m-d H:i',
Tras el cast, todos los métodos de Carbon están disponibles:
$reserva->check_in->format('d/m/Y');
$reserva->check_in->diffInDays($reserva->check_out);
$reserva->empieza_el->isPast();
Al serializar a JSON (respuestas de API), Carbon emite ISO 8601 por defecto. Para otro formato, redefine serializeDate en el modelo:
protected function serializeDate(DateTimeInterface $date): string
{
return $date->format('Y-m-d H:i:s');
}
Esto afecta a todas las fechas serializadas del modelo. Para sobreescribir solo un atributo concreto, usa un accessor que devuelva la cadena formateada.
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:
- Validar directamente en el formato de la base de datos (
date_format:Y-m-d) para que la salida devalidated()se use sin transformar. - Aceptar un formato amigable (
date_format:d/m/Y), convertir enprepareForValidation()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.
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', new HorarioLaboral],
'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 con regla HorarioLaboral 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.
Filtro de API por rango de fechas
La filtración de registros por rango de fechas es habitual en APIs. Ambos extremos son opcionales, pero si llega la fecha final, no puede ser anterior a la inicial:
class FiltroPedidosRequest extends FormRequest
{
public function rules(): array
{
return [
'desde' => ['nullable', 'date_format:Y-m-d'],
'hasta' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:desde'],
'estado' => ['nullable', 'in:pendiente,pagado,enviado'],
];
}
public function after(): array
{
return [
function ($validator) {
$desde = $this->validated('desde');
$hasta = $this->validated('hasta');
if ($desde && $hasta) {
$dias = Carbon::parse($desde)->diffInDays(Carbon::parse($hasta), absolute: true);
if ($dias > 365) {
$validator->errors()->add('hasta', 'El rango no puede superar 365 días.');
}
}
},
];
}
}
diffInDays con absolute: true evita sorpresas entre versiones de Carbon: Carbon 3 (Laravel 11+) devuelve un float con signo por defecto, mientras que Carbon 2 antiguo (Laravel ≤ 9) devolvía int absoluto. El argumento nombrado requiere PHP 8.0+ y Carbon 2.62 o superior. Con el flag explícito, el código se comporta igual en ambas versiones, y la comparación $dias > 365 sigue siendo correcta para el float de Carbon 3.
El controlador aplica los filtros solo cuando llegan:
public function index(FiltroPedidosRequest $request): JsonResponse
{
$query = Pedido::query();
if ($request->filled('desde')) {
$query->whereDate('created_at', '>=', $request->validated('desde'));
}
if ($request->filled('hasta')) {
$query->whereDate('created_at', '<=', $request->validated('hasta'));
}
return response()->json($query->paginate());
}
La cota de 365 días defiende contra consultas absurdamente amplias que podrían degradar la base. La validación de arrays y JSON cubre el caso de recibir una lista de fechas en un solo campo.
Testing de validación de fechas
Las pruebas que dependen de “hoy” o “mañana” son inestables si no se fija el tiempo. Carbon permite congelar el reloj con travelTo():
public function test_la_fecha_de_check_in_debe_ser_hoy_o_posterior(): void
{
$this->travelTo('2026-06-15');
$response = $this->post('/reservas', [
'fecha' => '2026-06-14',
'hora' => '10:00',
'zona' => 'Europe/Madrid',
'duracion' => 60,
]);
$response->assertSessionHasErrors('fecha');
}
public function test_check_out_debe_ser_posterior_a_check_in(): void
{
$this->travelTo('2026-06-15');
$response = $this->post('/reservas', [
'check_in' => '2026-06-20',
'check_out' => '2026-06-18',
]);
$response->assertSessionHasErrors('check_out');
}
travelTo() fija now() y today() durante todo el test. Sin esto, las aserciones con after_or_equal:today darían resultados distintos según el día en que se ejecutaran.
Para APIs con respuesta JSON, hay que mandar el header Accept: application/json (lo hace automáticamente postJson()):
public function test_formato_de_fecha_invalido_devuelve_422(): void
{
$response = $this->postJson('/api/eventos', [
'empieza_el' => 'no-es-una-fecha',
]);
$response->assertStatus(422)
->assertJsonValidationErrors('empieza_el');
}
Sin Accept: application/json, una validación fallida emite redirect 302 al referer y la aserción assertStatus(422) falla con un mensaje confuso. Es uno de los errores más comunes al portar tests de formularios web a APIs.
Casos límite que merece la pena cubrir aparte: el propio día con todayOrAfter, el último día del mes, el 29 de febrero en años bisiestos y no bisiestos, y formatos no estándar. Un data provider de PHPUnit centraliza los casos:
#[DataProvider('fechasInvalidas')]
public function test_rechaza_fechas_invalidas(string $fecha): void
{
$response = $this->post('/eventos', ['empieza_el' => $fecha]);
$response->assertSessionHasErrors('empieza_el');
}
public static function fechasInvalidas(): array
{
return [
'cadena vacia' => [''],
'texto' => ['no-es-fecha'],
'formato incorrecto' => ['15/03/2026'],
'dia inexistente' => ['2026-02-30'],
'febrero 29 no bisiesto' => ['2025-02-29'],
];
}
Errores frecuentes
Usar date cuando se necesita date_format - date 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. Laravel aplica el formato del campo bajo validación a ambos lados; si el otro campo no encaja, cae a Date::parse (envoltorio de Carbon sobre strtotime). El comportamiento es determinista pero contraintuitivo fuera de EE.UU.: para separadores /, strtotime asume m/d/Y; para - y . asume d-m-Y. La comparación puede resolverse contra una fecha distinta a la esperada o lanzar excepción si el resultado no es parseable. Mantén ambos campos con el mismo formato para evitar la sorpresa.
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:today vs todayOrAfter() - after:today se evalúa contra strtotime('today'), que devuelve la medianoche actual. Si el usuario envía la fecha de hoy, fallará: el valor coincide exactamente con la medianoche, y after exige estrictamente mayor. Para “hoy o más adelante”, usa after_or_equal:today o Rule::date()->todayOrAfter().
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'],
Formato dd.mm.yyyy mal escrito - PHP usa una sola letra por componente: d día, m mes, Y año de cuatro dígitos. Escribir date_format:dd.mm.yyyy no falla con error, pero interpreta dd como “día seguido de literal d”. Lo correcto es date_format:d.m.Y. La y minúscula da año de dos dígitos (25), la Y mayúscula da cuatro (2025).
after/before sin date o date_format - estas reglas no validan el formato del valor; lo dan por hecho. Sin date o date_format previo, una cadena como pizza pasa required y llega a after:today con un valor inutilizable, lo que puede dar comportamiento impredecible. Siempre encadena un parseador antes:
// Correcto
'inicio' => ['required', 'date', 'after:today'],
// También correcto
'inicio' => ['required', 'date_format:Y-m-d', 'after:today'],
Año bisiesto y 29 de febrero - date_format:Y-m-d acepta 2024-02-29 (bisiesto) y rechaza 2025-02-29 (no bisiesto). DateTime::createFromFormat comprueba la existencia real de la fecha. En cambio, strtotime('2025-02-29') convierte silenciosamente la fecha en 2025-03-01, sin marcar error. Otro motivo para preferir date_format cuando importa la corrección estricta.
Datepicker JavaScript que envía ISO completo - bibliotecas como flatpickr o el componente de Vue suelen enviar 2026-03-15T14:30:00.000Z. La regla date lo acepta, pero date_format:Y-m-d lo rechaza por el sufijo de tiempo. La opción más limpia es configurar la salida del datepicker para que emita solo Y-m-d. Si no se puede, hay que recortar en prepareForValidation(), pero con cuidado de la zona horaria:
protected function prepareForValidation(): void
{
if ($this->fecha_evento && str_contains($this->fecha_evento, 'T')) {
$tz = $this->input('zona_horaria') ?: config('app.timezone');
if (!in_array($tz, DateTimeZone::listIdentifiers(), true)) {
$tz = config('app.timezone');
}
$this->merge([
'fecha_evento' => Carbon::parse($this->fecha_evento)->setTimezone($tz)->format('Y-m-d'),
]);
}
}
Dos detalles. Primero, dentro de prepareForValidation() el validator todavía no existe, así que $this->validated() lanza excepción; hay que leer la entrada cruda con $this->input(). Segundo, esa entrada no está validada aún, por lo que conviene comprobar que sea una zona conocida antes de pasarla a setTimezone(); sin esa salvaguarda, un payload con zona_horaria=basura provoca InvalidTimeZoneException antes de que cualquier regla pueda rechazarlo.
Carbon::parse('...Z') produce un Carbon en UTC. Sin setTimezone(), format('Y-m-d') da la fecha UTC, no la fecha calendario que el usuario eligió en su zona local. Para un usuario en Asia/Tokyo que escoge el 16 de marzo, el datepicker puede enviar 2026-03-15T15:00:00.000Z (16 marzo JST = 15 marzo UTC), y sin convertir se acabaría guardando 15 de marzo.
Fechas relativas en tests - after: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().
Zona horaria no afecta a la comparación - after y before comparan timestamps sin convertir zonas. Si el usuario está en Europe/Madrid y envía 2026-06-15 23:00, pero el servidor está en UTC, la comparación se hace contra la hora del servidor. Para comportamiento correcto con zonas, convierte en prepareForValidation() o usa el hook after() con Carbon::createFromFormat($formato, $valor, $tz).
Para la lista completa de reglas, 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.
¿Te ha resultado útil este artículo?
¡Gracias por tu opinión!