Reglas de validación en Laravel 13: referencia completa

Laravel trae decenas de reglas de validación integradas y, cuando llevas poco tiempo con el framework, es fácil confundir unas con otras. nullable y sometimes parecen hacer lo mismo hasta que una petición PATCH rompe el formulario. boolean acepta "1" pero rechaza "true", y el campo pasa la validación o no dependiendo de cómo el frontend serialice el checkbox. Esta referencia recorre las reglas más utilizadas con ejemplos propios y aclara las diferencias que la documentación oficial menciona de pasada.

Si buscas una introducción general a la validación (cómo llamar a validate(), manejar errores en Blade, diferencias entre controlador y Form Request), el punto de partida es la guía rápida.

Sintaxis: tres formas de escribir reglas

Antes de entrar en las reglas individuales, conviene repasar cómo se declaran. Laravel acepta tres formatos y la elección afecta tanto a la legibilidad como a la compatibilidad con ciertos parámetros.

Cadena separada por pipe, la variante más corta:

$request->validate([
    'nombre' => 'required|string|max:120',
    'edad'   => 'required|integer|min:18',
]);

Array de reglas, necesario cuando alguna regla contiene comas o usa un objeto Rule:

use Illuminate\Validation\Rule;

$request->validate([
    'email' => ['required', 'email:rfc,dns', Rule::unique('usuarios')->ignore($usuario->id)],
    'pais'  => ['required', Rule::in(['mx', 'es', 'ar', 'co'])],
]);

Fluent builder a través de la clase Rule:

use Illuminate\Validation\Rule;

$request->validate([
    'codigo' => [
        'required',
        Rule::string()->min(4)->max(20)->alphaDash(ascii: true),
    ],
    'inicio' => [
        'required',
        Rule::date()->afterToday(),
    ],
]);

Los tres formatos se combinan dentro de la misma llamada a validate(). El pipe funciona bien para reglas simples, el array es obligatorio cuando aparece Rule::*, y el fluent builder brilla en lógica condicional con when().

Presencia y obligatoriedad de campos

Esta familia de reglas controla si un campo debe existir en la petición y qué valores se consideran vacíos. La confusión entre ellas es la fuente de errores más repetida en validación.

required

El campo tiene que estar presente y no ser vacío. Laravel considera vacíos: null, cadena vacía "", array vacío y un archivo subido sin ruta.

$request->validate([
    'titulo'  => 'required|string|max:200',
    'cuerpo'  => 'required|string|min:10',
    'etiquetas' => 'required|array|min:1',
]);

filled

Si el campo aparece en la petición, no puede estar vacío. Pero si no viene, la validación pasa sin problema. Es el caso típico de un endpoint API donde el cliente puede omitir campos opcionales, pero si los envía debe darles un valor real.

$request->validate([
    'apodo' => 'filled|string|max:60',
]);

// { } -> ok, el campo no existe
// { "apodo": "dev42" } -> ok
// { "apodo": "" } -> falla

present vs required

present exige que la clave exista en los datos, pero acepta cualquier valor, incluyendo null y cadena vacía. required exige la clave y además un valor no vacío.

$request->validate([
    'comentario' => 'present|nullable|string|max:500',
]);

// { "comentario": null } -> ok
// { "comentario": "" } -> ok
// { } -> falla, la clave no existe

Variantes condicionales: present_if, present_unless, present_with y present_with_all condicionan la obligatoriedad de la clave a otros campos del request.

nullable

Permite que el valor sea null. Sin esta regla, null falla cualquier validación de tipo (string, integer, date).

$request->validate([
    'segundo_nombre' => 'nullable|string|max:60',
    'eliminado_en'   => 'nullable|date',
]);

Un punto que genera confusión: nullable no hace que el campo sea opcional. Si delante hay required, el campo sigue siendo obligatorio pero puede llegar como null.

sometimes: validar solo si el campo esta presente

sometimes le dice al validador que ejecute el resto de reglas únicamente si la clave existe en los datos. Si el campo no viene, se salta la cadena entera.

$validator = Validator::make($datos, [
    'telefono' => 'sometimes|required|string|min:10',
]);

Aquí, cuando telefono no forma parte de $datos, ninguna regla se ejecuta. Pero si la clave existe, required entra en juego y una cadena vacía no pasa.

sometimes vs nullable: la diferencia real

Esta comparación aparece con frecuencia porque ambas reglas parecen “relajar” la validación, pero operan a niveles distintos:

// PATCH: el cliente envía solo los campos que cambiaron
'bio' => 'sometimes|nullable|string|max:1000',

// POST: el campo siempre llega, pero el valor puede ser null
'supervisor_id' => 'required|nullable|integer|exists:usuarios,id',

El primer caso es habitual en peticiones PATCH, donde solo viajan los campos modificados. El segundo aplica cuando el frontend siempre envía la clave, pero el dato puede ser nulo (un empleado sin supervisor, por ejemplo).

Campos opcionales en la práctica

Laravel no tiene una regla optional como tal. Para hacer un campo completamente opcional se combina sometimes|nullable. El middleware ConvertEmptyStringsToNull (activo por defecto) transforma cadenas vacías a null, así que nullable suele acompañar a sometimes:

$request->validate([
    'web'   => 'sometimes|nullable|url',
    'notas' => 'sometimes|nullable|string|max:2000',
]);

Campos no mencionados en las reglas no se validan ni aparecen en validated(). Si necesitas el campo en el resultado pero es opcional, hay que declararlo con sometimes|nullable.

missing

Contrario a present: la clave no debe existir en los datos. Sirve para API versionadas donde un campo fue eliminado y se quiere rechazar a clientes que aún lo envían:

$request->validate([
    'campo_legacy' => 'missing',
]);

Variantes: missing_if, missing_unless, missing_with, missing_with_all.

bail: detener la cadena en el primer error

Por defecto Laravel ejecuta todas las reglas de cada campo y acumula errores. bail cambia ese comportamiento: al primer fallo en el campo, se detiene.

$request->validate([
    'email'    => 'bail|required|email:rfc|unique:usuarios',
    'password' => 'required|min:8',
]);

Si email falla en required, las reglas email:rfc y unique:usuarios no se ejecutan. En cambio, password sigue validándose porque bail solo afecta al campo donde se declara.

El uso principal es evitar consultas innecesarias. unique lanza un SELECT contra la base de datos; si el campo llegó vacío, ese query sobra. Por convención bail se coloca como primera regla para que sea visible de un vistazo.

Cuando bail estorba: si el formulario necesita mostrar todos los errores a la vez, bail fuerza al usuario a corregirlos uno por uno. Mejor reservarlo para reglas costosas (queries, llamadas externas) y no aplicarlo a cada campo.

Para detener toda la validación (todos los campos) tras el primer error de cualquiera, existe stopOnFirstFailure:

$validator = Validator::make($datos, $reglas);

if ($validator->stopOnFirstFailure()->fails()) {
    // solo el primer error del primer campo que falle
}

Restringir valores permitidos

in y not_in

in verifica que el valor pertenezca a una lista cerrada. Es la herramienta para campos con opciones fijas (estados, prioridades, roles).

$request->validate([
    'estado'    => 'required|in:borrador,publicado,archivado',
    'prioridad' => ['required', Rule::in([1, 2, 3, 4, 5])],
]);

Un detalle sobre tipos: con la sintaxis de cadena (in:1,2,3) la comparación es entre strings. Si necesitas estricta tipificación, añade integer o numeric antes de in para que el valor ya llegue validado como número.

not_in es lo contrario, rechaza si el valor pertenece a la lista:

$request->validate([
    'nombre_usuario' => ['required', 'string', Rule::notIn(['admin', 'root', 'sistema'])],
]);

enum: validación con PHP Enums

Para enumeraciones respaldadas (backed enums) Laravel ofrece Rule::enum(). Verifica que el valor enviado coincida con alguno de los cases del enum:

enum EstadoPedido: string
{
    case Pendiente  = 'pendiente';
    case Pagado     = 'pagado';
    case Enviado    = 'enviado';
    case Cancelado  = 'cancelado';
}

$request->validate([
    'estado' => ['required', Rule::enum(EstadoPedido::class)],
]);

Los métodos only() y except() restringen qué cases son válidos:

// el cliente solo puede seleccionar estos estados
Rule::enum(EstadoPedido::class)->only([
    EstadoPedido::Pendiente,
    EstadoPedido::Cancelado,
]);

// todos excepto cancelado
Rule::enum(EstadoPedido::class)->except([EstadoPedido::Cancelado]);

Lógica condicional con when():

Rule::enum(EstadoPedido::class)->when(
    $usuario->esGerente(),
    fn ($regla) => $regla->only([EstadoPedido::Enviado, EstadoPedido::Cancelado]),
    fn ($regla) => $regla->except([EstadoPedido::Enviado]),
);

Rule::enum() necesita un backed enum (con tipo string o int). Si el enum es puro (sin valores asociados), la validación siempre falla porque Laravel no encuentra el método tryFrom() y devuelve false directamente. No hay excepción, pero tampoco hay match posible. Cuando los valores válidos no vienen de un enum sino de configuración o base de datos, Rule::in() es la alternativa directa.

accepted y declined

accepted valida que el valor sea uno de: "yes", "on", 1, "1", true, "true". Pensado para checkboxes de términos y condiciones:

$request->validate([
    'terminos'          => 'accepted',
    'politica_privacidad' => 'accepted',
]);

declined es lo opuesto: "no", "off", 0, "0", false, "false". Ambos tienen variantes condicionales: accepted_if:campo,valor y declined_if:campo,valor.

boolean: lo que acepta y lo que no

boolean admite true, false, 1, 0, "1" y "0". Lo que no acepta es "true" ni "false" como cadenas de texto. Esto es fuente constante de reportes de “booleano no funciona” cuando el frontend envía "true" desde un <select> o un JSON sin parsear.

La solución habitual es normalizar el campo antes de validar, en prepareForValidation() del Form Request:

protected function prepareForValidation(): void
{
    if (is_string($this->activo)) {
        $this->merge([
            'activo' => filter_var($this->activo, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE),
        ]);
    }
}

El parámetro strict restringe a los valores PHP nativos true y false:

$request->validate([
    'activo'         => 'boolean',
    'notificaciones' => 'boolean:strict',
]);

Un booleano con nullable permite tres estados (true, false, null), útil para campos como “preferencia no definida”:

$request->validate([
    'marketing_emails' => 'nullable|boolean',
]);

Comparación entre campos y confirmación

confirmed

Espera un segundo campo con el sufijo _confirmation. Si validas password, debe existir password_confirmation en la petición:

$request->validate([
    'password' => 'required|min:8|confirmed',
]);
// el request debe incluir password_confirmation

Desde Laravel 11 puedes indicar un nombre distinto para el campo de confirmación:

$request->validate([
    'email' => 'required|email|confirmed:email_repetido',
]);
// compara email con el campo email_repetido

Si el campo de confirmación no existe en la petición, la validación falla silenciosamente con un error genérico. Eso confunde porque no indica que falta el campo, solo que “no coincide”.

same y different

same compara dos campos por igualdad, different por desigualdad:

$request->validate([
    'nueva_password'              => 'required|min:8|different:password_actual',
    'nueva_password_confirmacion' => 'required|same:nueva_password',
]);

Diferencia con confirmed: confirmed se declara en el campo origen y busca automáticamente {campo}_confirmation. same se declara en el campo destino y recibe explícitamente el nombre del campo con el que comparar. Con nombres de campo estándar, confirmed es más práctico; con nombres arbitrarios, same.

gt, gte, lt, lte

Comparan el valor del campo contra otro campo o contra un número. El tipo de comparación depende de la regla de tipo declarada (string = longitud, numeric = valor, array = cantidad, file = KB):

$request->validate([
    'precio_minimo' => 'required|numeric|min:0',
    'precio_maximo' => 'required|numeric|gt:precio_minimo',
    'descuento'     => 'required|integer|lte:100',
]);

prohibited: campos que no deben tener valor

prohibited exige que el campo esté vacío o no exista. Los valores vacíos son: null, "", array vacío, archivo sin ruta.

$request->validate([
    'rol_admin' => 'prohibited',
]);

Las variantes condicionales son más habituales en la práctica. prohibited_if bloquea el campo cuando otro campo tiene un valor determinado:

$request->validate([
    'es_empresa'      => 'required|boolean',
    'razon_social'    => 'prohibited_if:es_empresa,false',
    'numero_personal' => 'prohibited_if:es_empresa,true',
]);

prohibits trabaja en sentido inverso: si el campo actual tiene valor, los campos listados quedan prohibidos:

$request->validate([
    // si se envía mensaje_personal, plantilla_id queda prohibido
    'mensaje_personal' => 'sometimes|string|max:500|prohibits:plantilla_id',
    'plantilla_id'     => 'required_without:mensaje_personal|integer|exists:plantillas,id',
]);

Cuidado: prohibits se basa en la noción de «vacío» que usa required (null, "", [], archivo sin path). Un false booleano no entra en esa lista, así que 'flag' => 'sometimes|boolean|prohibits:otro' también prohíbe otro cuando flag: false, lo que rara vez es lo deseado. Para banderas booleanas usa prohibited_if:flag,true o las variantes para checkboxes que aparecen a continuación.

Para checkboxes existen prohibited_if_accepted y prohibited_if_declined, que no requieren especificar el valor:

$request->validate([
    'usar_direccion_default' => 'required|boolean',
    'direccion_custom'       => 'prohibited_if_accepted:usar_direccion_default|string|max:400',
]);

Y para lógica compleja, Rule::prohibitedIf() con un closure:

$request->validate([
    'codigo_descuento' => Rule::prohibitedIf(fn () => $pedido->estaPagado()),
]);

Reglas para arrays: distinct y contains

La regla distinct garantiza que no haya duplicados dentro de un array. El escenario habitual es un formulario dinámico donde el usuario agrega filas (emails de destinatarios, SKUs de productos):

$request->validate([
    'destinatarios'          => 'required|array|min:1|max:10',
    'destinatarios.*.email'  => 'required|email|distinct',
    'destinatarios.*.nombre' => 'required|string|max:80',
]);

Sin distinct, dos emails idénticos pasan la validación y el correo se envía dos veces al mismo destinatario.

Por defecto distinct usa comparación no estricta. El parámetro strict activa comparación estricta (útil para arrays de IDs donde "1" y 1 deben considerarse distintos), y ignore_case ignora mayúsculas:

'items.*.sku' => 'distinct:strict',
'categorias.*' => 'distinct:ignore_case',

contains comprueba que el array incluya ciertos valores obligatorios:

use Illuminate\Validation\Rule;

$request->validate([
    'permisos' => [
        'required',
        'array',
        Rule::contains(['leer']),
    ],
]);

La guía completa de array, list, in_array, wildcard * y validación de estructuras anidadas está en la referencia de arrays y JSON.

Reglas de texto: alpha, regex y familia

alpha acepta por defecto caracteres Unicode: letras acentuadas, cirílico, ideogramas. Para restringir a ASCII puro (a-z, A-Z), se añade el parámetro ascii:

$request->validate([
    // "María" pasa: Unicode
    'nombre'         => 'required|alpha',
    // "María" falla: solo a-z A-Z
    'nombre_usuario' => 'required|alpha_dash:ascii',
    // letras y números, exactamente 6
    'codigo'         => 'required|alpha_num|size:6',
]);

La familia alpha no permite espacios. Para nombres compuestos como “Ana María”, alpha no sirve; ahí toca regex o simplemente string con restricción de longitud.

alpha_dash amplía alpha con dígitos, guión - y guión bajo _ (pero no punto ni espacio). alpha_num acepta letras y dígitos sin caracteres especiales.

Sobre regex: si la expresión regular contiene el carácter |, la sintaxis de cadena con pipe se rompe porque Laravel interpreta | como separador de reglas:

// Roto: Laravel ve "regex:/^(foo" como una regla y "bar)$/" como otra
'codigo' => 'required|regex:/^(foo|bar)$/',

// Correcto: array de reglas, regex como elemento independiente
'codigo' => ['required', 'regex:/^(foo|bar)$/'],

Lo mismo aplica para not_regex. Si el patrón contiene |, obligatoriamente array.

El detalle completo de string, email, url, uuid, regex, lowercase, uppercase, starts_with, ends_with se encuentra en la referencia de cadenas y números.

Reglas condicionales: dependencias entre campos

required_if, required_with, required_without y sus variantes hacen que un campo sea obligatorio según el estado de otros campos. Es el mecanismo para formularios donde las secciones cambian de forma dinámica.

$request->validate([
    'tipo_envio' => 'required|in:recogida,domicilio',
    'direccion'  => 'required_if:tipo_envio,domicilio|string|max:400',
    'hora_recogida' => 'required_if:tipo_envio,recogida|date',
]);

required_with obliga al campo si al menos uno de los campos listados existe:

$request->validate([
    'latitud'  => 'required_with:longitud|numeric|between:-90,90',
    'longitud' => 'required_with:latitud|numeric|between:-180,180',
]);

La referencia completa de required_if, required_unless, required_with_all, required_without, required_without_all y el método $validator->sometimes() para lógica programática se encuentra en la guía de validación condicional.

anyOf: lógica OR entre conjuntos de reglas

Rule::anyOf() define conjuntos alternativos de reglas. El campo es válido si pasa al menos uno:

$request->validate([
    'contacto' => [
        'required',
        Rule::anyOf([
            ['email'],
            ['string', 'regex:/^\+\d{10,15}$/'],
        ]),
    ],
]);

El campo contacto puede ser un email o un teléfono en formato internacional. Sin anyOf, habría que escribir una regla personalizada o separar el campo en dos inputs.

Otro caso frecuente: un campo que acepta UUID o ID numérico:

$request->validate([
    'referencia' => [
        'required',
        Rule::anyOf([
            ['uuid'],
            ['integer', 'min:1'],
        ]),
    ],
]);

Validación contra base de datos

Las reglas unique y exists ejecutan queries SQL para comprobar valores. Ambas soportan el builder fluent de Rule con filtros, soft deletes y referencias a modelos Eloquent.

$request->validate([
    'email'        => ['required', 'email', Rule::unique('usuarios')->ignore($usuario->id)],
    'categoria_id' => ['required', Rule::exists('categorias', 'id')->where('activa', true)],
]);

Unicidad multicolumna, soft deletes, conexiones personalizadas y condiciones avanzadas con where() tienen su propia guía: unique y exists.

Orden de las reglas y cómo interactúan

Las reglas se ejecutan de izquierda a derecha, y algunas cortan la cadena.

nullable: si el valor es null, las reglas siguientes no se evalúan. El campo se considera válido sin más comprobaciones:

// null pasa, y email:rfc no se ejecuta
'email_contacto' => 'nullable|email:rfc|unique:usuarios',

sometimes actúa antes: si la clave no existe en los datos, toda la cadena se salta. No es que “la regla falle” – el campo directamente no participa en la validación ni aparece en validated().

La regla de tipo (string, integer, array, file) determina cómo interpretan min, max, size y between su argumento. Sin regla de tipo, Laravel intenta adivinar el tipo a partir del valor, lo que lleva a resultados inesperados:

// con string: min:3 exige al menos 3 caracteres
'nombre' => 'required|string|min:3',

// con integer: min:3 exige valor >= 3
'cantidad' => 'required|integer|min:3',

// sin tipo: si llega "10", min:3 comprueba longitud del string "10" (2 chars) y falla
'ambiguo' => 'required|min:3',

Regla: declarar siempre el tipo antes de las reglas de tamaño.

Reglas por tipo de dato

Cada tipo de dato tiene su propia guía con un análisis en profundidad. Aquí va una orientación rápida sobre qué regla elegir.

Validación dinámica con Rule::when

Hay situaciones donde las reglas no se pueden definir de forma estática: dependen de quién hace la petición, de un feature flag, o del estado de otro registro. Rule::when() resuelve esto dentro del array de reglas sin tener que construir el array en un if externo:

use Illuminate\Validation\Rule;

$request->validate([
    'descuento' => [
        'required',
        'integer',
        'min:0',
        Rule::when(
            $request->user()->esAdmin(),
            ['max:100'],
            ['max:30'],
        ),
    ],
]);

Si el usuario es admin, el descuento máximo es 100; si no, 30. Las dos ramas reciben arrays de reglas, no reglas sueltas.

Otro patrón habitual: aplicar unique solo en creación, ignorar en update:

'email' => [
    'required',
    'email:rfc',
    Rule::when(
        null === $this->route('usuario'),
        [Rule::unique('usuarios')],
        [Rule::unique('usuarios')->ignore($this->route('usuario'))],
    ),
],

Rule::when() evalúa la condición en el momento en que se construye el validador, no dentro del bucle de validación. Cuando el primer argumento es un booleano ($request->user()->esAdmin()), PHP lo resuelve al montar el array de reglas. Cuando es un closure, Laravel lo ejecuta al procesar las reglas condicionales, pero igualmente antes de fails() o passes().

Para aplicar reglas que dependen de los datos ya presentes en la petición, el método $validator->sometimes() ofrece una alternativa con acceso directo al input:

$validator = Validator::make($datos, [
    'tipo_pago'    => 'required|in:tarjeta,transferencia',
    'numero_cuenta' => 'string|size:20',
]);

$validator->sometimes('numero_cuenta', 'required', function ($input) {
    return 'transferencia' === $input->tipo_pago;
});

El callback de sometimes() también se resuelve al momento de la llamada (no dentro del loop de reglas), pero la ventaja es que en ese punto $input ya contiene todos los datos del request, lo que permite condicionar un campo según el valor de otro sin hardcodear el estado fuera del validador.

Tabla de referencia rápida

Para localizar una regla de un vistazo, agrupadas por función.

Presencia y obligatoriedad

ReglaFunción
requiredCampo obligatorio y no vacío
filledNo vacío si está presente
presentLa clave debe existir
nullablePermite null
sometimesValidar solo si la clave existe
missingLa clave no debe existir
prohibitedCampo vacío o ausente

Condicionales

ReglaFunción
required_ifObligatorio si otro campo tiene un valor
required_unlessObligatorio si otro campo no tiene el valor
required_withObligatorio si otros campos existen
required_withoutObligatorio si otros campos faltan
required_if_acceptedObligatorio si otro campo es accepted
prohibited_ifProhibido si otro campo tiene un valor
prohibited_unlessProhibido salvo en un caso concreto
exclude_ifExcluir de validated() si se cumple condición
exclude_unlessIncluir en validated() si se cumple condición

Tipo de valor

ReglaFunción
stringCadena de texto
integerNúmero entero
numericNúmero (incluye float)
booleanBooleano o convertible a booleano
arrayArray PHP
listArray con índices consecutivos
jsonCadena JSON válida
fileArchivo subido
dateFecha válida

Comparación

ReglaFunción
confirmedCoincide con {campo}_confirmation
same:campoCoincide con el campo indicado
different:campoDistinto al campo indicado
gt:campoMayor que otro campo
gte:campoMayor o igual
lt:campoMenor que otro campo
lte:campoMenor o igual

Restricción de valores

ReglaFunción
in:a,b,cUno de los valores listados
not_in:a,b,cNo pertenece a la lista
Rule::enum()Valor de un PHP enum
acceptedEquivalente a yes/on/true/1
declinedEquivalente a no/off/false/0
Rule::anyOf()Pasa al menos un conjunto de reglas

Tamaño y rango

ReglaFunción
min:nMínimo (chars, valor, elementos, KB)
max:nMáximo
size:nValor exacto
between:min,maxRango inclusivo
digits:nCantidad exacta de dígitos
min_digits:nMínimo de dígitos
max_digits:nMáximo de dígitos

Strings

ReglaFunción
alphaSolo letras (Unicode por defecto)
alpha_dashLetras, números, guión, guión bajo
alpha_numLetras y números
emailFormato email
urlFormato URL
uuidFormato UUID
regex:patronCoincide con expresión regular
starts_with:a,bEmpieza con uno de los valores
ends_with:a,bTermina con uno de los valores

Control de flujo

ReglaFunción
bailDetener validación del campo en el primer error
excludeExcluir del resultado de validated()

Patrones de uso frecuente

Varias combinaciones que aparecen una y otra vez en proyectos reales.

Para peticiones PATCH donde el cliente envía solo los campos que modificó:

// UpdatePerfilRequest
public function rules(): array
{
    return [
        'nombre' => 'sometimes|required|string|max:100',
        'email'  => ['sometimes', 'required', 'email', Rule::unique('usuarios')->ignore($this->user()->id)],
        'bio'    => 'sometimes|nullable|string|max:500',
    ];
}

Para formularios donde las secciones dependen del tipo de entidad, required_if combinado con prohibited_if:

$request->validate([
    'tipo'     => 'required|in:persona,empresa',
    'rfc'      => 'required_if:tipo,empresa|prohibited_if:tipo,persona|string|size:13',
    'curp'     => 'required_if:tipo,persona|prohibited_if:tipo,empresa|string|size:18',
]);

bail antes de reglas que consultan la base de datos:

$request->validate([
    'email'         => 'bail|required|email:rfc|unique:usuarios',
    'codigo_invitacion' => 'bail|required|string|exists:invitaciones,codigo',
]);

Cuando las reglas crecen y dejan de caber en el controlador, lo siguiente es moverlas a un Form Request. Si las reglas integradas no cubren tu caso, puedes crear reglas propias. El manejo de textos de error tiene su propia referencia, y la guía rápida cubre los fundamentos desde cero.