Reglas de validación personalizadas en Laravel 13
Las reglas integradas de Laravel cubren la mayoría de situaciones, pero hay validaciones que no encajan en ninguna regla existente: verificar un código postal contra un servicio externo, comprobar que un horario no se solape con otro, o que una combinación de campos cumpla una regla de negocio específica. Para estos casos, Laravel ofrece tres mecanismos: clases de regla (Rule Objects), closures y reglas implícitas.
Para los conceptos generales de validación, consulta la guía de inicio rápido. Las reglas personalizadas se usan dentro de Form Requests y validadores manuales – los detalles están en el artículo de Form Requests.
Clases de regla (Rule Objects)
La forma principal de crear una regla reutilizable es una clase que implemente ValidationRule. El comando Artisan genera la estructura:
php artisan make:rule CodigoPostalValido
Esto crea app/Rules/CodigoPostalValido.php con un método validate():
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class CodigoPostalValido implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! preg_match('/^\d{5}$/', $value)) {
$fail('El :attribute debe ser un código postal de 5 dígitos.');
}
}
}
El método validate() recibe tres argumentos: el nombre del atributo, su valor y un closure $fail que se invoca cuando la validación falla. El método no devuelve nada – si no se llama a $fail(), la validación pasa. Este patrón es distinto al de versiones anteriores donde la clase tenía un método passes() que devolvía un booleano. Desde Laravel 10, make:rule genera la interfaz ValidationRule con validate(). Tutoriales que muestren passes() y message() son de versiones anteriores.
El placeholder :attribute dentro del mensaje de $fail() se reemplaza automáticamente por el nombre del campo (con guiones bajos convertidos en espacios). Otros placeholders como :min, :max o :value no se resuelven de forma automática en reglas personalizadas – hay que interpolar los valores directamente o usar translate() con un array de reemplazos.
Para usar la regla:
use App\Rules\CodigoPostalValido;
$request->validate([
'codigo_postal' => ['required', 'string', new CodigoPostalValido],
]);
Reglas con parámetros
Cuando la regla necesita configuración, se pasan parámetros al constructor:
class LongitudEntre implements ValidationRule
{
public function __construct(
protected int $min,
protected int $max,
) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$longitud = mb_strlen($value);
if ($longitud < $this->min || $longitud > $this->max) {
$fail("El :attribute debe tener entre {$this->min} y {$this->max} caracteres.");
}
}
}
Uso: new LongitudEntre(min: 3, max: 50). Los named arguments de PHP 8 hacen que la intención sea clara al instanciar la regla.
Un ejemplo más práctico – una regla que verifica que un valor no exista en una lista de palabras prohibidas:
class PalabrasProhibidas implements ValidationRule
{
public function __construct(
protected array $palabras = ['admin', 'root', 'system', 'null'],
) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (in_array(mb_strtolower($value), $this->palabras, true)) {
$fail('El :attribute contiene una palabra reservada.');
}
}
}
Mensajes traducibles
En vez de pasar un string literal a $fail(), se puede usar una clave de traducción:
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! $this->esValido($value)) {
$fail('validation.codigo_postal')->translate();
}
}
El método translate() busca la clave en el archivo de idioma (lang/es/validation.php). Para pasar placeholders y un idioma específico:
$fail('validation.entre_valores')->translate([
'min' => $this->min,
'max' => $this->max,
], 'es');
El placeholder :attribute se resuelve automáticamente. Los placeholders personalizados (como :min y :max en este ejemplo) se pasan como array al primer argumento de translate().
Acceso a otros campos: DataAwareRule
Cuando la regla necesita comparar el valor con otros campos del formulario, la clase implementa DataAwareRule. Laravel llama a setData() con todos los datos antes de ejecutar validate():
use Illuminate\Contracts\Validation\DataAwareRule;
use Illuminate\Contracts\Validation\ValidationRule;
class FechaFinPosterior implements DataAwareRule, ValidationRule
{
protected array $data = [];
public function setData(array $data): static
{
$this->data = $data;
return $this;
}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$inicio = $this->data['fecha_inicio'] ?? null;
if ($inicio && $value <= $inicio) {
$fail('La :attribute debe ser posterior a la fecha de inicio.');
}
}
}
Uso en las reglas:
$request->validate([
'fecha_inicio' => 'required|date',
'fecha_fin' => ['required', 'date', new FechaFinPosterior],
]);
El array $this->data contiene todos los datos del request, no solo los campos con reglas.
Acceso al validador: ValidatorAwareRule
Para casos donde se necesita el validador completo (por ejemplo, para añadir errores a otros campos o acceder a reglas ya evaluadas), la interfaz ValidatorAwareRule inyecta la instancia del validador:
use Illuminate\Contracts\Validation\ValidatorAwareRule;
use Illuminate\Validation\Validator;
class VerificarConsistencia implements ValidationRule, ValidatorAwareRule
{
protected Validator $validator;
public function setValidator(Validator $validator): static
{
$this->validator = $validator;
return $this;
}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
// Acceso a errores previos, datos, reglas, etc.
if ($this->validator->errors()->has('campo_relacionado')) {
return; // No validar si el campo relacionado ya tiene errores
}
// Lógica de validación...
}
}
DataAwareRule y ValidatorAwareRule se pueden combinar en la misma clase cuando se necesitan ambos.
Reglas con closure
Para validaciones que solo se usan una vez, un closure evita crear una clase:
$request->validate([
'codigo' => [
'required',
'string',
function (string $attribute, mixed $value, Closure $fail) {
if (0 !== $value[0] % 2) {
$fail('El :attribute debe empezar con un dígito par.');
}
},
],
]);
El closure recibe los mismos tres argumentos que validate() en una clase. La limitación: no es reutilizable. Si la misma lógica aparece en dos o más Form Requests, extraerla a una clase es mejor opción.
Los closures son útiles para validaciones rápidas que dependen del contexto del controlador:
$request->validate([
'cantidad' => [
'required',
'integer',
'min:1',
function (string $attribute, mixed $value, Closure $fail) use ($producto) {
if ($value > $producto->stock) {
$fail("Solo hay {$producto->stock} unidades disponibles.");
}
},
],
]);
La variable $producto viene del scope del controlador vía use. En una clase de regla, este dato se pasaría por el constructor.
Un patrón frecuente es usar closures para validaciones que dependen de relaciones entre campos sin la formalidad de DataAwareRule:
$request->validate([
'descuento' => [
'required',
'numeric',
'min:0',
function (string $attribute, mixed $value, Closure $fail) use ($request) {
$subtotal = $request->input('subtotal', 0);
if ($value > $subtotal) {
$fail('El descuento no puede superar el subtotal.');
}
},
],
]);
El closure accede a otros campos del request a través de use ($request). Funciona, pero si esta validación se repite en otro formulario, una clase con DataAwareRule es más mantenible.
Inyección de dependencias en reglas
Las clases de regla se instancian manualmente (new MiRegla), así que el contenedor de Laravel no inyecta dependencias automáticamente en el constructor. Para inyectar servicios:
class EmailDisponible implements ValidationRule
{
public function __construct(
protected RegistroService $servicio,
) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! $this->servicio->emailDisponible($value)) {
$fail('El :attribute ya está registrado en otro sistema.');
}
}
}
Se instancia pasando el servicio:
$request->validate([
'email' => ['required', 'email', new EmailDisponible(app(RegistroService::class))],
]);
O se puede resolver desde el contenedor directamente:
'email' => ['required', 'email', app(EmailDisponible::class)],
Cuando la clase de regla tiene las dependencias tipadas en el constructor, app() las resuelve automáticamente. Este enfoque es más limpio para reglas con varias dependencias.
Un ejemplo más complejo – una regla que verifica disponibilidad contra un servicio externo con caché:
class HorarioDisponible implements ValidationRule
{
public function __construct(
protected ReservaService $reservas,
protected string $sala,
protected string $fecha,
) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! $this->reservas->horaDisponible($this->sala, $this->fecha, $value)) {
$fail('La hora :attribute no está disponible para esta sala y fecha.');
}
}
}
// En el Form Request
'hora' => [
'required',
'date_format:H:i',
new HorarioDisponible(
reservas: app(ReservaService::class),
sala: $this->sala_id,
fecha: $this->fecha,
),
],
Los parámetros que vienen del request (sala_id, fecha) se pasan por el constructor. El servicio se resuelve desde el contenedor. Esta separación permite testear la regla inyectando un mock del servicio.
Para reglas que consultan la base de datos, hay que considerar el rendimiento. Cada regla personalizada se ejecuta por cada campo que la use. Si la regla hace una consulta pesada, combinarla con bail evita que se ejecute cuando reglas previas ya fallaron:
'email' => ['required', 'bail', 'email', new EmailDisponible(app(RegistroService::class))],
bail detiene la validación del campo tras el primer fallo. Si required o email fallan, la regla personalizada (y su consulta) no se ejecutan.
Otra técnica es cachear el resultado dentro de la instancia de la regla cuando se usa en campos de array con comodín:
class SkuExisteEnCatalogo implements ValidationRule
{
protected ?array $catalogoIds = null;
public function __construct(
protected int $catalogoId,
) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
// Solo consulta la DB una vez, no por cada elemento del array
if (null === $this->catalogoIds) {
$this->catalogoIds = Producto::where('catalogo_id', $this->catalogoId)
->pluck('sku')
->all();
}
if (! in_array($value, $this->catalogoIds, true)) {
$fail('El SKU :attribute no existe en el catálogo.');
}
}
}
// Se reutiliza la misma instancia para todos los elementos
$regla = new SkuExisteEnCatalogo($request->catalogo_id);
$request->validate([
'items' => 'required|array|min:1',
'items.*.sku' => ['required', 'string', $regla],
]);
La misma instancia de la regla se usa para cada elemento del array. La primera invocación carga los SKUs; las siguientes usan el caché en memoria.
Reglas implícitas
Por defecto, las reglas personalizadas no se ejecutan cuando el campo está ausente o vacío. Este comportamiento tiene sentido para la mayoría de reglas – no hay nada que validar si no hay valor. Pero algunas reglas necesitan ejecutarse incluso sin valor (como una regla que verifica que un campo esté presente bajo ciertas condiciones).
Para crear una regla implícita:
php artisan make:rule CampoRequerido --implicit
La clase generada implementa ValidationRule con la propiedad $implicit = true:
use Illuminate\Contracts\Validation\ValidationRule;
class AceptarTerminos implements ValidationRule
{
public $implicit = true;
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (true !== $value && 'on' !== $value && 1 !== $value && '1' !== $value) {
$fail('Debes aceptar los :attribute para continuar.');
}
}
}
Con $implicit = true, la regla se ejecuta aunque el campo no esté en el request o esté vacío. La documentación de Laravel aclara que una regla implícita solo indica que el atributo se trata como requerido – es responsabilidad de la regla decidir si un valor vacío es válido o no.
La mayoría de las reglas personalizadas no necesitan ser implícitas. Solo tiene sentido cuando la ausencia del campo es precisamente lo que se quiere detectar. Por ejemplo, una regla que verifica que ciertos campos estén presentes dependiendo del valor de otro campo – similar a required_if pero con lógica de negocio compleja que no se puede expresar con las reglas integradas.
Un uso práctico de regla implícita – verificar que al menos uno de varios campos opcionales tenga valor:
class AlMenosUnContacto implements ValidationRule
{
public $implicit = true;
public function validate(string $attribute, mixed $value, Closure $fail): void
{
// $attribute aquí es un campo virtual, los datos reales se obtienen vía DataAwareRule
}
}
En la práctica, este tipo de validación es más común hacerla en el hook after() del Form Request o con required_without_all, reservando las reglas implícitas personalizadas para casos donde la lógica de negocio no encaja en ninguna regla existente.
Testing de reglas personalizadas
Las clases de regla se pueden testear de forma aislada, sin pasar por un HTTP test:
use App\Rules\CodigoPostalValido;
test('acepta codigo postal valido', function () {
$regla = new CodigoPostalValido;
$fallo = false;
$regla->validate('cp', '28001', function () use (&$fallo) {
$fallo = true;
});
expect($fallo)->toBeFalse();
});
test('rechaza codigo postal invalido', function () {
$regla = new CodigoPostalValido;
$mensaje = null;
$regla->validate('cp', 'ABCDE', function ($msg) use (&$mensaje) {
$mensaje = $msg;
});
expect($mensaje)->toContain('código postal');
});
Se invoca validate() directamente con un closure que captura si hubo fallo y el mensaje. Para reglas que implementan DataAwareRule, hay que llamar a setData() antes:
$regla = new FechaFinPosterior;
$regla->setData(['fecha_inicio' => '2026-01-01']);
$regla->validate('fecha_fin', '2025-12-31', function () use (&$fallo) {
$fallo = true;
});
expect($fallo)->toBeTrue();
Para un test de integración completo que verifica que la regla funciona dentro del flujo de validación:
test('formulario rechaza codigo postal invalido', function () {
$this->post('/direcciones', [
'calle' => 'Calle Mayor 1',
'codigo_postal' => 'XYZ',
])->assertInvalid('codigo_postal');
});
Ambos enfoques son complementarios: el test unitario verifica la lógica de la regla, el test de integración verifica que la regla esté conectada al formulario.
Para reglas con DataAwareRule que dependen de otros campos, el test unitario permite verificar diferentes combinaciones sin montar un request HTTP completo. Esto es especialmente valioso para reglas de negocio complejas con muchos caminos de ejecución.
Para reglas que dependen de servicios externos (APIs, base de datos), usar un mock del servicio en el test unitario permite testear los caminos de error sin depender de la disponibilidad del servicio:
test('rechaza email si el servicio externo lo bloquea', function () {
$servicio = Mockery::mock(RegistroService::class);
$servicio->shouldReceive('emailDisponible')
->with('[email protected]')
->andReturn(false);
$regla = new EmailDisponible($servicio);
$fallo = false;
$regla->validate('email', '[email protected]', function () use (&$fallo) {
$fallo = true;
});
expect($fallo)->toBeTrue();
});
Cuándo crear una regla vs cuándo usar closure
La decisión depende de la reutilización y la complejidad:
- Closure: validación específica de un solo formulario, lógica corta (3-5 líneas), depende de variables del scope local del controlador
- Clase de regla: la misma validación se usa en dos o más lugares, la lógica es compleja, necesita inyección de dependencias, necesita acceso a otros campos (
DataAwareRule), necesita ser testeada de forma aislada
Un closure que crece a más de 10 líneas o que se copia entre controladores es señal de que hay que extraer una clase. Si la validación necesita testeo aislado o documentación propia, una clase con un nombre descriptivo (HorarioDisponible, SkuExisteEnCatalogo) comunica la intención mejor que un closure anónimo.
Errores frecuentes
Devolver false en vez de llamar a $fail() – la interfaz ValidationRule usa $fail() para indicar fallo. Devolver false desde validate() no tiene efecto – la validación pasa como si fuera correcta. Tutoriales antiguos muestran el método passes() que devolvía booleano, pero esa interfaz (Rule con passes()/message()) ya no es la que genera make:rule.
:value no se resuelve automáticamente en reglas personalizadas – en las reglas integradas, placeholders como :value se resuelven con el valor del campo. En reglas personalizadas, solo :attribute se resuelve automáticamente. Para incluir el valor u otros datos en el mensaje, interpolarlos directamente en el string o usar translate() con un array de reemplazos.
Regla con consulta pesada sin bail – una regla que consulta un servicio externo o hace un JOIN complejo se ejecuta en cada validación. Sin bail, si required falla, la regla personalizada se ejecuta igualmente con un valor vacío. Poner bail antes de la regla personalizada evita consultas innecesarias.
Closure copiado entre Form Requests – si la misma validación aparece en dos lugares, extraerla a una clase. Los closures no se pueden compartir limpiamente entre clases.
DataAwareRule sin setData() – implementar la interfaz DataAwareRule sin definir el método setData() genera un error fatal. Laravel llama a setData() automáticamente antes de validate(). El método debe devolver static (o $this) para permitir el encadenamiento interno.
Regla implícita sin necesidad – añadir $implicit = true a una regla que no necesita ejecutarse con campos vacíos puede causar mensajes de error confusos. Solo usar reglas implícitas cuando la ausencia del campo es parte de la lógica de validación.
Olvidar Closure en el use type – en los closures de validación, el parámetro $fail es un Closure de Illuminate. El use Closure en la parte superior del archivo debe importar Closure del namespace global, no de otro namespace. Escribir use Closure; al inicio del archivo o usar \Closure en la firma.
Para la lista completa de reglas integradas, consulta la referencia de reglas. La validación condicional (cuando la regla depende de otros campos) se cubre en validación condicional.