wppw.org
enrues
Laravel 13.x · Docs

Validación condicional | Laravel 13.x en español - required_if, exclude_if, sometimes y closures

Actualizado 25 de mayo de 2026 Laravel 13.x 18 min lectura
En esta página 8

Casi todo formulario real tiene campos que solo importan en determinadas circunstancias. El IBAN solo se pide si el pago es por transferencia; el campo razon_volumen aparece cuando el cliente declara más de 100 unidades; el campo direccion_entrega desaparece cuando se marca “recoger en tienda”. Esto es validación condicional: una regla condicional solo aplica si se cumple cierta condición sobre el resto del input.

Laravel cubre el patrón con tres mecanismos que se solapan más de lo que parece. Primero, una familia de reglas modificadoras (required_*, exclude_*, prohibited_*, missing_*) que activan o desactivan otras reglas según el valor o la presencia de otro campo. Segundo, la regla sometimes (que salta validación si el campo no está) y el método $validator->sometimes() (que añade reglas dinámicas con closure). Tercero, los factories Rule::requiredIf, Rule::excludeIf, Rule::prohibitedIf, que aceptan un bool o un closure y envuelven cualquier regla condicional cuando la decisión depende del usuario autenticado o de algo externo al array de datos.

Saber cuál herramienta usar evita validadores con if/else mezclados con reglas y deja la condición visible donde realmente vive: en la lista de reglas, no escondida en el controlador.

La familia required: cuándo el valor debe llegar según otro campo

required_if:otro_campo,valor exige que el campo bajo validación venga presente y no vacío si otro_campo es igual a valor. La comparación es case-sensitive entre strings: required_if:role,admin no se activa si el cliente manda role=Admin. Entre tipos distintos es loose: required_if:flag,1 salta también con flag=true o flag="1" (Laravel activa la comparación estricta solo cuando el valor recibido es bool o null). El caso más frecuente de “mi regla condicional no salta” es justamente el casing entre frontend y backend.

public function prepareForValidation(): void
{
    $this->merge([
        'payment_method' => strtolower((string) $this->input('payment_method')),
    ]);
}

public function rules(): array
{
    return [
        'payment_method' => 'required|in:card,transfer,paypal',
        'iban' => 'required_if:payment_method,transfer|string',
    ];
}

Cuando varias listas de valores deben disparar la misma exigencia, se enumeran después del campo: required_if:status,active,pending exige el campo si el estado es uno de los dos. Es semántica OR entre los valores listados.

'razon_baja' => 'required_if:estado,cancelado,suspendido|string|max:500',

required_unless es el espejo: el campo se exige salvo que otro_campo valga uno de los valores listados. Tiene una precondición que sorprende: salvo que el valor de comparación sea null, el otro campo debe venir en el request. Si tipo_destinatario falta del payload, la condición “unless” no se cumple y nif se exige. La forma segura es marcar también tipo_destinatario como required para no depender de ese matiz.

public function rules(): array
{
    return [
        'tipo_destinatario' => 'required|in:empresa,particular,consumidor_final',
        // Salvo cuando el destino es consumidor final, hay que dar NIF
        'nif' => 'required_unless:tipo_destinatario,consumidor_final|string|size:9',
    ];
}

Cuidado con el resto del pipe: required_unless solo desactiva la exigencia de “required”, no las reglas posteriores. Si un consumidor_final envía nif="abc", size:9 falla aunque el dato debería ser irrelevante. Cuando ese chequeo extra molesta, exclude_if:tipo_destinatario,consumidor_final|required|string|size:9 deja al campo verdaderamente fuera del juego.

Para checkboxes y campos truthy/falsy hay dos atajos. required_if_accepted:otro_campo se dispara cuando otro_campo vale "yes", "on", 1, "1", true o "true". required_if_declined:otro_campo reconoce los valores opuestos ("no", "off", 0, "0", false, "false"). Ahorran tener que pensar qué string concreto manda el frontend.

'metodo_envio_alternativo' => 'required_if_declined:envio_estandar',
'autorizacion_padre' => 'required_if_accepted:menor_de_edad',

required_with vs required_without: por presencia, no por valor

required_with:foo,bar activa la exigencia cuando alguno de los campos listados está presente y no vacío. required_with_all:foo,bar solo se dispara si todos están presentes. La diferencia es OR vs AND, y mezclarlos cuesta caro porque el comportamiento se parece pero no coincide.

// Cualquiera de los dos canales presente exige consentimiento explícito
'consentimiento_marketing' => 'required_with:email_marketing,telefono_marketing|accepted',

// El descuento combinado solo aplica si llegan los dos datos
'codigo_promo' => 'required_with_all:tipo_cliente,segmento|string',

required_without y required_without_all son el complemento por ausencia: el campo se exige solo cuando los otros faltan o llegan vacíos. Sirven para “required si otro campo vacío” o “required si otro campo no llega”. La trampa es asumir que required_without:foo equivale a la negación de required_if:foo,*. No, son cosas distintas. required_if mira el valor del otro campo cuando llega; required_without mira si el otro campo llega o no, y si está vacío.

// Hay que dejar email o teléfono, al menos uno
'email' => 'required_without:telefono|nullable|email',
'telefono' => 'required_without:email|nullable|string',

Una variante frecuente del intent es “required si no es null”: cuando se quiere que el campo aparezca solo si el otro tiene contenido. required_with encaja porque se activa por presencia con valor no vacío. Importante: required_with considera “vacío” también la cadena vacía, el array vacío, el archivo sin path y null por igual. Hay un matiz extra del stack por defecto: el middleware global ConvertEmptyStringsToNull ya normaliza "" a null antes de que las reglas corran, así que cualquier check basado en is_null() desde un closure ve null en ambos casos. Si el dominio realmente necesita distinguir “vino vacío” de “no vino”, hay que excluir ese middleware en bootstrap/app.php (o leer el cuerpo crudo) antes de poder diferenciarlos.

// Solo si has retirado ConvertEmptyStringsToNull del stack;
// con el middleware activo, el closure recibe null en ambos casos.
'fecha_fin' => Rule::requiredIf(fn () => ! is_null($this->input('fecha_inicio'))),

Encadenar required_if|required_if en la misma cadena sí produce OR sobre la exigencia: basta con que cualquiera se active para que el campo se vuelva obligatorio. Atención: si ambas condiciones se cumplen a la vez y el campo está vacío, el bag de errores recibe dos mensajes idénticos para el mismo campo. Para combinar múltiples condiciones (AND, OR con varios operandos, expresiones mixtas como “(A=x y B=y) o C=z”) con un único mensaje, Rule::requiredIf con un closure deja la lógica completa en una sola expresión:

use Illuminate\Validation\Rule;

public function rules(): array
{
    return [
        'comentario' => [
            // El closure evalúa una vez; un único 'comentario es obligatorio' cuando devuelve true.
            Rule::requiredIf(fn () => ('reclamacion' === $this->input('tipo') && 'alta' === $this->input('prioridad'))
                || in_array($this->input('puntuacion'), [1, 2])),
            'string',
            'max:1000',
        ],
    ];
}

Rule::requiredIf también acepta un bool directo, así que decisiones que dependen del usuario autenticado quedan en una sola línea: Rule::requiredIf($this->user()->isAdmin()). Lo mismo aplica a Rule::requiredUnless.

exclude_if y la cascada que rompe controladores

exclude_if:otro,valor no solo evita que el campo se valide: lo quita del array que devuelven validate() y validated(). Es la herramienta correcta cuando los datos no van al modelo en absoluto (se decide otra rama lógica, se mandan a otro servicio o se descartan a partir de cierto estado).

public function rules(): array
{
    return [
        'tiene_cita' => 'required|boolean',
        'fecha_cita' => 'exclude_if:tiene_cita,false|required|date|after:today',
        'nombre_doctor' => 'exclude_if:tiene_cita,false|required|string',
    ];
}

public function store(StoreReservaRequest $request)
{
    $validated = $request->validated();

    if (! ($validated['tiene_cita'] ?? false)) {
        // Sin cita los campos ya no están en $validated; encolamos a otra mesa.
        return $this->guardarComoLeadSinCita($validated);
    }

    // tiene_cita es un flag de UI, no una columna del modelo: lo excluimos del insert.
    Reserva::create(Arr::except($validated, 'tiene_cita'));
}

Si en realidad lo que se busca es persistir esos campos como null cuando tiene_cita=false, la herramienta correcta no es exclude_if (que purga la clave) sino nullable, que mantiene el valor en $validated:

'fecha_cita' => 'nullable|required_if:tiene_cita,true|date|after:today',
'nombre_doctor' => 'nullable|required_if:tiene_cita,true|string',

Caveat: nullable permite null cuando la clave llega, pero si el cliente HTTP omite la clave (común en forms HTML cuando el campo no aplica), $validated no contiene fecha_cita y el insert deja la columna con su DEFAULT, no con null. Para forzar null explícito hay que inyectar la clave en prepareForValidation() ($this->merge(['fecha_cita' => null]) si no llega) o serializar la clave con null literal desde una SPA.

exclude_unless:otro,valor invierte la condición: el campo se incluye solo cuando otro vale valor. La pareja exclude_with / exclude_without actúa por presencia del otro campo en lugar de por su valor concreto.

Para condiciones que dependen del usuario autenticado, del request completo o de cualquier cosa fuera del array de datos, está Rule::excludeIf con closure (mismo patrón que Rule::requiredIf). Hay que decidir conscientemente la semántica: excludeIf borra silenciosamente la clave, así que el cliente no se entera de que su valor se ignoró. Para asignación de roles, permisos sensibles o cualquier cosa donde el silencio engaña, Rule::prohibitedIf falla con 422 cuando el cliente envía algo que no le corresponde:

// Si no tiene permiso, se ignora silenciosamente; el cliente no se entera.
'rol_id' => Rule::excludeIf(fn () => $this->user()->cannot('asignar-rol')),

// Si no tiene permiso, falla 422 con 'no puedes enviar rol_id'.
'rol_id' => Rule::prohibitedIf(fn () => $this->user()->cannot('asignar-rol')),

exclude si null: cuándo la intención real es otra

Si la condición pensada es “exclude si null” (excluir un campo cuando otro llega como null), exclude_if:otro,null con literal null en pipe funciona en la práctica porque el parser de parámetros es común a los cuatro (required_if, required_unless, exclude_if, exclude_unless), pero la documentación solo garantiza explícitamente la conversión 'null'null PHP en exclude_unless y required_unless. Para no depender de un detalle no documentado, la alternativa estable es un closure:

// "Excluir cuando otro_campo es estrictamente null"
'campo' => Rule::excludeIf(fn () => is_null($this->input('otro_campo'))),

// "Excluir cuando otro_campo está vacío en cualquier forma" (null, "", [], archivo sin path)
'campo' => Rule::excludeIf(fn () => blank($this->input('otro_campo'))),

(Con ConvertEmptyStringsToNull activo, is_null() no distingue null real de "" enviado por el cliente; usar blank() o trabajar antes del middleware si la diferencia importa.)

Lo más común es querer “no exigir el campo cuando el otro no está” (lo cubre sometimes) o “permitir null sin que falle” (nullable). Borrar la clave del payload con exclude_if se justifica solo cuando el dato no va a llegar al modelo de ninguna manera.

prohibited y missing: forzar que el campo no llegue

prohibited_if:otro,valor acepta dos cosas: que la clave no esté en el payload, o que esté con valor “vacío” (null, string vacío, array vacío, archivo sin path). Solo falla si llega con contenido. missing_if:otro,valor es más estricto: la clave no puede aparecer en el payload en absoluto, ni siquiera con valor null.

public function rules(): array
{
    return [
        'tipo' => 'required|in:invitado,cliente',
        // Si es invitado, fiscalmente no aplica; aceptamos que llegue vacío pero no con valor
        'numero_fiscal' => 'prohibited_if:tipo,invitado|string',
        // Si es invitado, el id de cliente ni debe existir en el payload
        'cliente_id' => 'missing_if:tipo,invitado|integer|exists:clientes,id',
    ];
}

Precondición importante para missing_if: depende de cómo el cliente HTTP serialice el formulario. Una SPA típica con axios + objeto JS, FormData con shape completo o un componente controlado en React envía todas las claves del estado, incluyendo cliente_id: null o cliente_id: "". En ese caso missing_if falla aunque el usuario no haya tocado el campo. Solo funciona limpio cuando el cliente omite activamente la clave (formularios HTML clásicos lo hacen). Para APIs JSON con shape fijo, prohibited_if suele ser la opción correcta.

prohibits:otro_campo se lee al revés: si el campo bajo validación llega con contenido, todos los campos listados en otro_campo deben estar vacíos o ausentes. Es asimétrico: si el usuario manda iban sin tocar numero_cuenta, la regla no se evalúa. Para exclusividad real, repetir la regla en el otro lado y exigir al menos uno con required_without:

public function rules(): array
{
    return [
        'numero_cuenta' => 'prohibits:iban|required_without:iban|string',
        'iban' => 'prohibits:numero_cuenta|required_without:numero_cuenta|string',
    ];
}

Este patrón simétrico hereda el problema de duplicación de mensajes: si el cliente manda los dos campos a la vez, salta prohibits en ambos (dos mensajes idénticos); si no manda ninguno, salta required_without en ambos. Para un único mensaje agregado, mover la lógica a withValidator() con $validator->after() y añadir un mensaje propio en uno solo de los campos.

Rule::prohibitedIf y Rule::prohibitedUnless cierran el lado fluent con closures, equivalentes a sus parejas de required y exclude.

La regla sometimes y el método sometimes()

Son dos cosas con el mismo nombre y semántica distinta.

La regla sometimes se pone en el array de reglas y dice: aplica las reglas que siguen solo si el campo está presente en los datos. No equivale a nullable (que permite null cuando el campo llega) ni implica “opcional” en sentido amplio; significa “si no llega, sáltate las reglas”.

public function rules(): array
{
    return [
        // Si llega email, debe ser válido; si no llega, no se valida en absoluto
        'email' => 'sometimes|required|email',
    ];
}

El método sometimes() del validador añade reglas dinámicamente según un closure que recibe los datos. Es la herramienta para condiciones complejas que dependen de operaciones sobre el input (sumas, conteos, comparaciones entre varios campos). El closure recibe Fluent $input con los datos del request ya pasados por prepareForValidation() (si el Form Request define ese hook); no recibe el estado de validación, solo el input ya parseado.

use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Fluent;

$validator = Validator::make($request->all(), [
    'email' => 'required|email',
    'unidades' => 'required|integer|min:1',
]);

$validator->sometimes('razon_volumen', 'required|max:500', function (Fluent $input) {
    return $input->unidades >= 100;
});

$validator->sometimes(['razon_volumen', 'contacto_comercial'], 'required', function (Fluent $input) {
    return $input->unidades >= 500;
});

$validator->validate();

Dentro de un Form Request, el mismo método se llama desde el hook withValidator(), que recibe la instancia ya construida pero antes de que las reglas corran:

use Illuminate\Contracts\Validation\Validator;
use Illuminate\Support\Fluent;

protected function withValidator(Validator $validator): void
{
    $validator->sometimes('razon_volumen', 'required|max:500', function (Fluent $input) {
        return $input->unidades >= 100;
    });
}

withValidator() es distinto de prepareForValidation() (que modifica el input antes de validar) y de after() (que recibe el validator después de correr las reglas, con los errores ya acumulados).

Cómo validar checkbox en Laravel: el patrón sometimes y accepted

Los checkboxes HTML solo envían su name=valor cuando están marcados; sin marcar, el campo no llega en absoluto. Esto cambia cómo se valida, y depende de que el frontend respete esa convención.

Para un checkbox opcional que se ignora cuando no se marca, sometimes|accepted evita el error “el campo es obligatorio”.

'recibir_newsletter' => 'sometimes|accepted',

Precondición: el frontend tiene que omitir la clave cuando el checkbox está desmarcado, que es lo que hace un <form> HTML nativo. Si en JS se serializa el formulario con JSON.stringify({recibir_newsletter: checkbox.checked}), va a llegar recibir_newsletter: false y entonces sometimes no salta (la clave está presente) y accepted falla porque false literal no está en la lista de valores aceptados. Solución estándar: normalizar en prepareForValidation() quitando la clave cuando valga false:

protected function prepareForValidation(): void
{
    if (false === $this->input('recibir_newsletter')) {
        // El bag correcto depende del Content-Type: $this->request guarda los
        // datos form-encoded; para application/json el payload vive en $this->json.
        ($this->isJson() ? $this->json() : $this->request)->remove('recibir_newsletter');
    }
}

Para un checkbox obligatorio (aceptar los términos antes de registrarse, por ejemplo), el patrón es required|accepted. accepted valida que el valor sea "yes", "on", 1, "1", true o "true", que es exactamente lo que mandan los checkboxes marcados.

'acepto_terminos' => 'required|accepted',

boolean no sirve aquí: rechaza los strings "true"/"false", y los checkboxes no envían "false" cuando están desmarcados, simplemente no envían nada. Mezclar sometimes|boolean confunde la intención porque cambia la semántica esperada cuando el campo llega como string desde el form.

Formularios polimórficos

Cuando un mismo controlador atiende varios “tipos” con campos distintos, devolver reglas distintas según el tipo desde rules() suele ser más legible que llenar el array con required_if y exclude_unless.

public function rules(): array
{
    return match ($this->input('tipo')) {
        'particular' => [
            // Cada rama re-declara 'tipo' para que aparezca en validated() y
            // se valide contra el subconjunto permitido en este branch.
            'tipo' => 'required|in:particular,empresa',
            'dni' => 'required|string|size:9',
        ],
        'empresa' => [
            'tipo' => 'required|in:particular,empresa',
            'cif' => 'required|string|size:9',
            'razon_social' => 'required|string|max:200',
        ],
        // El default cubre 'tipo' inválido o ausente: impide bypass enviando
        // tipo=foo para librarse del conjunto estricto de su categoría real.
        default => [
            'tipo' => 'required|in:particular,empresa',
        ],
    };
}

Esta forma asume un único request con el discriminator en el payload. En multi-step forms (wizard donde cada paso es un POST distinto), $this->input('tipo') no estará en el step 2 ni en los siguientes: el discriminator vive en la sesión o en una entidad parcial en BD. La estructura del Form Request es la misma, pero el discriminador se recupera de session('wizard.tipo') o de $this->draft()->tipo antes del match.

Nota sobre el lifecycle: rules() se ejecuta una sola vez por request, así que cualquier condición ahí dentro (incluyendo when() y unless() sobre rule builders fluent de Enum, Password o File, y el match del ejemplo) se evalúa antes de que el validador corra. Esto no es una limitación específica de los builders fluent: es el lifecycle natural de rules(). Para condiciones que dependen del input ya parseado pero no del estado de validación, $validator->sometimes() con closure añade reglas dinámicas. Para reaccionar al resultado de validar otros campos, el sitio correcto es $validator->after(), que recibe el Validator con su estado tras pasar las reglas iniciales.

Errores frecuentes

  • required_if:role,admin no salta con Admin: la comparación es case-sensitive entre strings, aunque loose entre tipos (required_if:flag,1 también dispara con flag=true). Para casing, normalizar en prepareForValidation() o listar variantes (required_if:role,admin,Admin).
  • required_unless exige que el otro campo esté en el request: salvo cuando el valor de comparación es null. Si el frontend puede omitir el campo de comparación, marcarlo también como required evita comportamientos inesperados.
  • required_unless no desactiva el resto del pipe: si el campo no se requiere pero llega con valor, string|size:9 siguen corriendo. Cuando ese chequeo extra molesta, exclude_if saca el campo de la cadena por completo.
  • exclude_if borra el campo de validated(): si lo que querías era persistir null en BD, usa nullable en su lugar. exclude_if + array_merge con null es contradictorio: excluyes para reinyectar.
  • Rule::excludeIf para autorización borra silenciosamente: el cliente no se entera de que su rol_id se ignoró. Para fallar ruidosamente, Rule::prohibitedIf o controlar el permiso en authorize().
  • required_without:foo no es lo opuesto de required_if:foo,*: el primero mira presencia/ausencia y vacío del otro campo; el segundo mira su valor concreto. Si foo llega con valor falsy pero presente, required_without no se activa.
  • required_with no distingue null de cadena vacía: ambos cuentan como “no presente”. El middleware ConvertEmptyStringsToNull ya normaliza "" a null antes de las reglas, así que is_null() desde un closure tampoco ayuda salvo que retires el middleware del stack.
  • required_if|required_if encadenado produce dos mensajes idénticos si ambas condiciones disparan a la vez. Para OR con un único mensaje, Rule::requiredIf(fn () => $cond1 || $cond2).
  • prohibited_if permite que el campo llegue vacío: si quieres que la clave directamente no aparezca en el payload, usa missing_if. Pero missing_if falla con SPA que serializan el shape completo del form: en esos casos prohibited_if casi siempre es más práctico.
  • prohibits es asimétrico: solo dispara cuando el campo bajo validación tiene contenido. Para exclusividad mutua, repetir la regla en el otro lado y combinar con required_without (asumiendo la duplicación de mensajes; para un único mensaje, $validator->after()).
  • sometimes|required no es lo mismo que nullable|required: sometimes se salta toda la cadena si el campo no llega; nullable permite null cuando llega. Para “el campo puede no llegar, pero si llega no puede ser null”, la combinación es sometimes|required|integer.
  • sometimes|accepted se rompe si el frontend manda false explícito: el patrón asume que la clave se omite cuando el checkbox no está marcado (HTML nativo lo hace; JSON serializado a mano, no). Normaliza en prepareForValidation().

Para encajar reglas condicionales dentro de un Form Request con authorize(), prepareForValidation() y withValidator(), ver Form Requests. Para reglas de unicidad con Rule::unique()->ignore() en flujos update donde la condición se calcula sobre la propia entidad, ver unique y exists. Para personalizar mensajes que mencionan campos condicionales (incluyendo arrays con índice y posición), ver mensajes de error.

¿Te ha resultado útil este artículo?

Reportar un error