Unique y exists: validación con base de datos en Laravel 13
La mayoría de reglas de validación trabajan únicamente sobre los datos del request: formato de cadena, rango numérico, fecha. Dos reglas rompen ese patrón porque consultan la base de datos. unique confirma que un valor no esté duplicado en una tabla. exists confirma lo contrario, que el valor sí esté presente. Entre ambas cubren la mayor parte de los escenarios donde la corrección depende del estado actual de la base: email de registro, slug de artículo, claves foráneas, selección desde catálogos.
Las dos admiten dos sintaxis: la cadena (exists:tabla,columna) y el constructor fluido (Rule::exists(), Rule::unique()). La sintaxis de cadena resuelve los casos básicos. Cualquier escenario con condiciones, exclusión de registros o soft deletes pide el constructor.
Para conceptos generales, ver la guía de validación básica. El uso típico es dentro de un Form Request, descrito en detalle en el artículo de Form Requests.
La regla unique
unique verifica que el valor del campo no exista en la columna indicada de una tabla. Internamente Laravel ejecuta SELECT count(*) FROM tabla WHERE columna = ? y falla si el contador es mayor que cero:
$request->validate([
'email' => 'required|email|unique:usuarios,email',
'slug' => 'required|string|max:120|unique:articulos,slug',
]);
Si se omite el nombre de la columna, Laravel usa el nombre del campo. Indicarlo de forma explícita evita ambigüedades y facilita el rastreo cuando los nombres de campo y de columna no coinciden.
En vez de la tabla, se puede pasar la clase del modelo Eloquent:
'email' => 'required|unique:App\Models\Usuario,email',
Laravel resuelve la tabla a partir del modelo. Si más adelante se cambia el nombre de la tabla, solo se actualiza el modelo. Para conectarse a otra base de datos, anteponer el nombre de la conexión:
'codigo' => 'required|unique:almacen.productos,sku',
Aquí almacen es la conexión definida en config/database.php y productos la tabla en esa conexión. Útil en arquitecturas con varias bases (microservicios, multi-tenant por conexión).
Un detalle a recordar: unique no trata null con lógica especial. Si llega un valor null y el campo está presente en el request, Laravel pasa el null al Query Builder, que lo convierte en WHERE telefono IS NULL. Si la tabla tiene filas con telefono null, el contador es mayor que cero y la validación falla. Para permitir varios usuarios sin teléfono, marcar el campo con nullable: nullable|unique:usuarios,telefono. La regla nullable indica al validador que el atributo puede ser null, y las reglas no-implícitas como unique se omiten cuando el valor es null. Funciona en cualquier posición del array de reglas, pero por convención se escribe primero para que la intención sea legible.
Ignorar el registro actual al actualizar
El problema número uno con unique aparece al editar un registro. Un usuario abre el formulario de perfil, no cambia nada, pulsa “Guardar” y recibe el error “este email ya está en uso”. Su propio email. Cada proyecto pasa por esto.
Rule::unique()->ignore() añade WHERE id != ? a la consulta, es decir, busca duplicados en toda la tabla excepto el registro indicado por su clave primaria:
use Illuminate\Validation\Rule;
$request->validate([
'email' => [
'required',
'email',
Rule::unique('usuarios')->ignore($usuario->id),
],
]);
En lugar del id se puede pasar la instancia del modelo entera. Laravel extrae la clave automáticamente:
Rule::unique('usuarios')->ignore($usuario),
Si la clave primaria no se llama id, indicar la columna como segundo argumento:
Rule::unique('usuarios')->ignore($usuario->uuid, 'uuid'),
Y si la columna a comprobar no coincide con el nombre del campo:
Rule::unique('usuarios', 'email_address')->ignore($usuario->id),
Nunca pasar input del usuario a ignore()
La documentación oficial avisa con un warning: nunca pasar input controlado por el usuario al método ignore(). El valor se serializa en la representación de la regla (unique:tabla,col,VALOR,idCol,wheres), Laravel le aplica addslashes y lo encierra entre comillas dobles, y al expandir vuelve a parsear con str_getcsv. La defensa cubre los casos triviales (una coma o comilla en el input no rompe el formato), pero con caracteres especiales no contemplados o estructuras anidadas un atacante puede sobrescribir el idColumn o inyectar parámetros where adicionales, lo que permite elegir qué fila se excluye o saltar la comprobación. La query final sigue pasando por bindings de PDO, así que no es ejecución de SQL arbitrario; el riesgo concreto es bypass de la regla unique (la doc oficial usa el término “SQL injection” por convención de severidad).
// Peligroso: el atacante controla el id excluido
Rule::unique('usuarios')->ignore($request->input('id'))
// Seguro: el id viene de la sesión autenticada o del route model binding
Rule::unique('usuarios')->ignore($request->user()->id)
Rule::unique('usuarios')->ignore($this->route('usuario'))
La forma $this->route('usuario') solo es segura cuando la ruta declara el parámetro con type-hint del modelo (Route::put('/usuarios/{usuario}', ...) y update(Usuario $usuario) en el controlador) o registra binding explícito. Con binding configurado, Laravel resuelve el segmento de URL a una instancia de Usuario antes de llamar al Form Request. Sin binding, $this->route('usuario') devuelve la cadena cruda de la URL sin ningún saneamiento, que vuelve a ser input arbitrario tan inseguro como $request->input('id'). La regla queda igual: solo valores generados por el sistema. Un id auto-incrementable, un UUID emitido por la aplicación o el modelo Eloquent ya resuelto por el binding.
Ignore en un Form Request
Dentro de una clase Form Request, el modelo actual se obtiene a través del route model binding:
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ActualizarPerfilRequest extends FormRequest
{
public function rules(): array
{
return [
'email' => [
'required',
'email',
Rule::unique('usuarios')->ignore($this->route('usuario')),
],
'handle' => [
'required',
'string',
'max:40',
Rule::unique('usuarios', 'handle')->ignore($this->route('usuario')),
],
];
}
}
$this->route('usuario') devuelve el modelo Usuario resuelto por el binding. El controlador recibe los datos validados sin choque de unicidad con el propio registro.
Cuando un mismo Form Request se reutiliza para POST /productos y PUT /productos/{producto}, el argumento ignore() queda como $this->route('producto')?->id. Es null en create (no se excluye nada, igual que un unique puro) y el id actual en update. El artículo de Form Requests describe cuándo conviene compartir la clase y cuándo separarla en GuardarProductoRequest y ActualizarProductoRequest.
Unique con condiciones: where()
El método where() añade filtros al SELECT de unicidad. El caso más común es la unicidad por tenant: el slug de un proyecto debe ser único dentro de la organización, no entre todas las organizaciones del sistema:
$request->validate([
'nombre_proyecto' => [
'required',
'string',
'max:255',
Rule::unique('proyectos', 'nombre')
->where('organizacion_id', $request->user()->organizacion_id)
->ignore($proyecto),
],
]);
Dos empresas pueden tener un proyecto llamado “Atlas”. Dentro de una sola empresa, el nombre no puede repetirse.
El atajo where('columna', valor) cubre la igualdad. Para operadores como >, <, LIKE o comparaciones con null, usar el closure y el Query Builder completo:
use Illuminate\Database\Query\Builder;
Rule::unique('codigos_promo', 'codigo')->where(function (Builder $query) use ($request) {
$query->where('campana_id', $request->integer('campana_id'))
->where('expira_en', '>', now());
}),
El código de la promoción es único dentro de una campaña activa. Las campañas expiradas no participan en la comprobación, así que el mismo código se reutiliza cuando la anterior caducó.
Unique multi-columna (clave compuesta)
Laravel no expone una sintaxis directa del estilo unique:tabla,col1,col2 para varias columnas. La unicidad compuesta se construye encadenando where():
$request->validate([
'estudiante_id' => 'required|exists:estudiantes,id',
'curso_id' => [
'required',
'exists:cursos,id',
Rule::unique('inscripciones')
->where('estudiante_id', $request->integer('estudiante_id'))
->ignore($inscripcion),
],
]);
Un estudiante no puede inscribirse al mismo curso dos veces. La verificación recae sobre la combinación estudiante_id + curso_id en la tabla inscripciones. En update, ignore() excluye la fila actual para que guardar sin cambios no provoque un falso positivo.
Para tres o más columnas, encadenar where() por cada una:
Rule::unique('reservas')
->where('sala_id', $request->integer('sala_id'))
->where('fecha', $request->input('fecha'))
->where('turno', $request->input('turno')),
Respaldarlo con un índice en la base
Rule::unique()->where() previene duplicados al nivel de aplicación. Dos peticiones concurrentes pueden pasar la validación al mismo tiempo y ambas insertar la misma fila. Un índice único compuesto en la migración es la última línea de defensa:
Schema::table('inscripciones', function (Blueprint $table) {
$table->unique(['estudiante_id', 'curso_id']);
});
Si dos requests pasan la validación en paralelo, el segundo INSERT falla con QueryException. En escenarios con alta concurrencia (formularios públicos, alta de registros por API), envolver la inserción en try/catch o usar firstOrCreate():
try {
Inscripcion::create($validated);
} catch (\Illuminate\Database\QueryException $e) {
if ($this->esViolacionDeUnique($e)) {
return back()->withErrors(['curso_id' => 'Ya existe esta inscripción.']);
}
throw $e;
}
private function esViolacionDeUnique(\Illuminate\Database\QueryException $e): bool
{
// PostgreSQL distingue: 23505 = unique_violation, 23503 = FK, 23502 = NOT NULL.
if ('23505' === $e->getCode()) {
return true;
}
// MySQL/MariaDB usan errorInfo[1] = 1062 solo para duplicate entry.
if ('23000' === $e->getCode() && 1062 === ($e->errorInfo[1] ?? 0)) {
return true;
}
// SQLite usa errorInfo[1] = 19 para CUALQUIER violación de constraint.
// El texto exacto del mensaje permite distinguir unique de FK / NOT NULL.
if ('23000' === $e->getCode()
&& str_contains($e->errorInfo[2] ?? '', 'UNIQUE constraint failed')) {
return true;
}
return false;
}
PostgreSQL devuelve códigos específicos por tipo de violación (23505 unique, 23503 FK, 23502 NOT NULL). MySQL/MariaDB y SQLite agrupan todas las violaciones de integridad bajo SQLSTATE 23000, así que comprobar solo el SQLSTATE atraparía también claves foráneas inexistentes y campos NOT NULL vacíos, mostrando un mensaje engañoso al usuario. MySQL expone 1062 (duplicate entry) en errorInfo[1], código exclusivo de violaciones de índice único. En SQLite el código nativo 19 (SQLITE_CONSTRAINT) lo devuelve también para FK y NOT NULL; el texto de errorInfo[2] arranca con UNIQUE constraint failed: solo en violaciones de unicidad, así que anclar la búsqueda a esa frase completa evita falsos positivos cruzados. Versiones recientes de SQLite exponen un código extendido (2067 = SQLITE_CONSTRAINT_UNIQUE) si se activa explícitamente, alternativa al chequeo por texto. getCode() devuelve el SQLSTATE como string, no como entero, por eso las comparaciones son contra string sin castear.
Validación y restricción de base se complementan: la validación atrapa la mayoría de duplicados antes del INSERT, el índice gestiona la carrera puntual.
Unique y soft deletes
La regla unique no pasa por Eloquent: va directo al Query Builder y no conoce el trait SoftDeletes ni los scopes globales. Por eso, si un usuario eliminó su cuenta con email [email protected], ese registro sigue contando en la consulta de unicidad (la fila existe con deleted_at no nulo) y bloquea cualquier registro nuevo con el mismo correo.
withoutTrashed() añade explícitamente whereNull('deleted_at') a la consulta:
Rule::unique('usuarios')->withoutTrashed(),
Solo aplicar withoutTrashed() en tablas que efectivamente tengan la columna. Llamarlo sobre una tabla sin esa columna provoca un error SQL en tiempo de ejecución durante la validación. Si la columna de soft delete tiene otro nombre, pasarlo como argumento:
Rule::unique('usuarios')->withoutTrashed('eliminado_en'),
Encadenando con ignore() y where():
Rule::unique('usuarios', 'email')
->ignore($usuario)
->withoutTrashed()
->where('organizacion_id', $organizacionId),
La cadena se lee de izquierda a derecha: email único, excluyendo el usuario actual, ignorando borrados, dentro de la organización indicada.
Hay que pensar antes de activar withoutTrashed(). Si un usuario eliminado conserva pedidos, facturas o auditoría asociada a su email, permitir un nuevo usuario con el mismo correo crea ambigüedad de identidad. En muchos sistemas la decisión correcta es mantener el email reservado incluso tras la eliminación.
La regla exists
exists valida que un valor exista en una tabla. Sirve para validar selección desde catálogos (país, categoría, estado), ids de relaciones, códigos de cupón:
$request->validate([
'categoria_id' => 'required|integer|exists:categorias,id',
'moneda' => 'required|string|exists:monedas,codigo',
]);
Sin la columna explícita, Laravel usa el nombre del campo. exists:categorias para un campo categoria_id busca una columna categoria_id en categorias, lo que casi nunca es lo deseado. La columna se especifica siempre.
Se acepta también un modelo Eloquent o una conexión distinta:
use App\Models\Autor;
'autor_id' => ['required', Rule::exists(Autor::class, 'id')],
'almacen_id' => 'required|exists:inventario.almacenes,id',
El uso típico de exists no es comprobar autorización: es comprobar que el id seleccionado existe. Si la categoría existe pero el usuario no debería verla, esa es una decisión de policy o de scope. exists se combina con where() para añadir esa capa.
Exists con condiciones y campos nullable
Cuando un dropdown solo muestra opciones activas, la validación debe reflejar lo mismo:
use Illuminate\Database\Query\Builder;
use Illuminate\Validation\Rule;
$request->validate([
'plan_id' => [
'required',
Rule::exists('planes', 'id')->where(function (Builder $query) {
$query->where('activo', true);
}),
],
]);
Sin la condición, un usuario podría enviar el id de un plan archivado todavía presente en la tabla. La forma corta where('columna', valor) cubre la igualdad. El closure desbloquea operadores y whereNull.
En aplicaciones multi-tenant, el patrón se repite limitando la búsqueda al tenant del usuario:
'factura_id' => [
'required',
Rule::exists('facturas', 'id')->where('tenant_id', $request->user()->tenant_id),
],
Para validar que una fila exista comprobando varias columnas a la vez (id + estado + dueño), encadenar tantos where() como sean necesarios. Lo que en unique sirve para construir clave compuesta, en exists sirve para restringir la búsqueda en múltiples columnas:
'numero_pedido' => [
'required',
'string',
Rule::exists('pedidos', 'numero')
->where('cliente_id', $request->user()->id)
->where('estado', 'pendiente'),
],
El pedido debe existir con ese número, pertenecer al cliente autenticado y estar en estado pendiente. Tres comprobaciones combinadas en una sola consulta.
Una clave foránea opcional necesita una declaración explícita de “puede ser null”. Sin nullable, si el campo llega con valor null y está presente en el request, exists corre y construye WHERE id IS NULL sobre la tabla destino. Como id suele ser NOT NULL, el contador es cero y la validación falla:
// Mal: exists corre con null y rechaza el registro
'supervisor_id' => 'exists:usuarios,id',
// Bien: el atributo está marcado como nullable, exists se omite cuando es null
'supervisor_id' => ['nullable', 'integer', 'exists:usuarios,id'],
nullable indica al validador que el campo puede ser null. Cuando lo es, las reglas no-implícitas posteriores (como exists o integer) se omiten para ese atributo. La posición de nullable en el array no es determinante (Laravel detecta su presencia con hasRule), pero por convención se escribe primero. Si el campo puede faltar por completo en el request (un PATCH parcial, por ejemplo), añadir además sometimes. La diferencia entre nullable y sometimes está en el artículo de validación condicional.
Arrays de ids con unique y exists
Selecciones múltiples y pickers de etiquetas envían arrays. Para exists, colocar la regla en el campo padre junto con array y Laravel construye un solo WHERE IN (...):
$request->validate([
'tag_ids' => ['required', 'array', 'min:1', Rule::exists('etiquetas', 'id')],
]);
Una consulta para todo el array. Si exists se pone en tag_ids.*, cada elemento dispara su propia query, lo que es tolerable con tres etiquetas pero doloroso con treinta.
Cuando el array necesita un filtro adicional, where() a nivel del campo padre se añade al mismo SELECT. El verificador construye un único whereIn y adjunta tanto los pares escalares (where('columna', valor)) como los closures al mismo Query Builder, así que el número de consultas no cambia con el tipo de filtro. Lo que sí genera una query por elemento es poner exists sobre el wildcard .* en lugar del padre:
'destinatarios' => [
'required',
'array',
'max:20',
Rule::exists('usuarios', 'id')->where('activo', true),
],
'destinatarios.*' => 'integer',
Cada destinatario debe ser un usuario activo. Las reglas básicas de tipo (integer) van por elemento. Las comprobaciones de base de datos van en el padre cuando es posible, para mantener una sola query.
Para unicidad dentro del array, distinct impide repeticiones internas. Combinado con unique se cubren ambos frentes:
$request->validate([
'invitaciones' => ['required', 'array', 'min:1', 'max:10'],
'invitaciones.*' => [
'required',
'email',
'distinct',
Rule::unique('usuarios', 'email'),
],
]);
distinct detecta duplicados dentro del request (['[email protected]', '[email protected]']). unique detecta correos ya presentes en la base. Ambas son necesarias: dos correos nuevos idénticos pasan unique (todavía no están en la tabla) pero fallan distinct.
distinct es sensible a mayúsculas por defecto. Se puede añadir distinct:ignore_case, pero normalizar en prepareForValidation() es más robusto porque también afecta al unique:
protected function prepareForValidation(): void
{
if ($this->has('invitaciones')) {
$this->merge([
'invitaciones' => array_map('mb_strtolower', $this->input('invitaciones', [])),
]);
}
}
Sin normalización, [email protected] y [email protected] pasan distinct como valores diferentes pero apuntan al mismo buzón.
Exists y soft deletes
El mismo método withoutTrashed() está disponible en exists a través del trait DatabaseRule:
'revisor_id' => [
'required',
Rule::exists('usuarios', 'id')->withoutTrashed(),
],
Un revisor desactivado no puede asignarse a un nuevo documento. Sin withoutTrashed(), el id pasa la validación porque la fila existe, solo tiene deleted_at no nulo.
Para el caso opuesto (un formulario de “restaurar cuenta” que debe encontrar al usuario eliminado), filtrar con un closure:
'email' => [
'required',
'email',
Rule::exists('usuarios', 'email')->where(function (Builder $query) {
$query->whereNotNull('deleted_at');
}),
],
Detalle a tener en cuenta: las reglas unique y exists no pasan por Eloquent ni aplican scopes globales. Si el modelo tiene un BootedScope que filtra registros archivados, ese scope no se aplica a la validación. La comprobación va directo al Query Builder y solo respeta los filtros que se añadan con where() o withoutTrashed().
Sintaxis cadena vs Rule builder
La sintaxis cadena de unique admite ignore con parámetros posicionales:
'email' => 'unique:usuarios,email,'.$usuario->id.',id',
Funciona, pero el orden de los argumentos no es evidente. El tercer parámetro es el valor a ignorar, el cuarto la columna de la clave primaria. Una coma de menos y la validación falla en silencio.
El builder siempre es más claro:
Rule::unique('usuarios', 'email')->ignore($usuario->id, 'id'),
La regla práctica: si la regla cabe en una línea corta sin concatenar, la cadena es aceptable (unique:usuarios,email, exists:paises,id). En el momento en que se necesita un . para construir el string con un id, cambiar al builder. Para where(), ignore(), withoutTrashed() o cualquier combinación de ellos, el builder es obligatorio.
El builder también admite composición condicional:
$regla = Rule::unique('usuarios', 'email');
if ($this->route('usuario')) {
$regla->ignore($this->route('usuario'));
}
if (in_array(SoftDeletes::class, class_uses_recursive(Usuario::class))) {
$regla->withoutTrashed();
}
Construir esto con la sintaxis de cadena requiere concatenaciones que se rompen al menor descuido.
Validación de email de extremo a extremo
El registro y la actualización de perfil son los dos escenarios donde unique aparece con más fricción. La diferencia entre ambos es el ignore:
class RegistrarRequest extends FormRequest
{
public function rules(): array
{
return [
'nombre' => ['required', 'string', 'max:80'],
'email' => [
'required',
'email:rfc,dns',
Rule::unique('usuarios')->withoutTrashed(),
],
'password' => ['required', 'confirmed', 'min:8'],
];
}
protected function prepareForValidation(): void
{
if ($this->has('email')) {
$this->merge([
'email' => mb_strtolower($this->input('email')),
]);
}
}
}
prepareForValidation normaliza el correo a minúsculas antes de la comprobación de unicidad. Sin esto, [email protected] y [email protected] se consideran distintos por unique, pero los servidores de correo los tratan como el mismo destinatario.
Para el formulario de actualización, la única diferencia es ignore():
class ActualizarPerfilRequest extends FormRequest
{
public function authorize(): bool
{
return null !== $this->user();
}
public function rules(): array
{
return [
'nombre' => ['required', 'string', 'max:80'],
'email' => [
'required',
'email:rfc,dns',
Rule::unique('usuarios')
->ignore($this->user())
->withoutTrashed(),
],
];
}
protected function prepareForValidation(): void
{
if ($this->has('email')) {
$this->merge([
'email' => mb_strtolower($this->input('email')),
]);
}
}
}
$this->user() devuelve null si la petición llega sin sesión activa o sin guard. Pasar null a ignore() no excluye nada y la regla degrada a un unique puro: el propio correo del usuario sería marcado como duplicado. El método authorize() (o el middleware auth en la definición de la ruta) garantiza que ese caso se rechace antes de validar. Cualquier otro correo dispara la validación con normalidad. La política sobre contraseñas se describe aparte en el artículo de validación de contraseñas.
Recetas comunes
Slug único por autor
Dos autores distintos pueden usar el mismo slug en sus respectivas publicaciones, pero un solo autor no debe tener dos slugs iguales:
'slug' => [
'required',
'string',
'max:120',
'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/',
Rule::unique('articulos', 'slug')
->where('autor_id', $request->user()->id)
->ignore($articulo),
],
Exists condicional con Rule::when
Cuando un campo solo necesita validarse contra la base si otro campo tiene un valor determinado:
$request->validate([
'metodo_pago' => ['required', 'in:tarjeta,transferencia,monedero'],
'monedero_id' => [
Rule::when(
'monedero' === $request->input('metodo_pago'),
['required', Rule::exists('monederos', 'id')->where('usuario_id', $request->user()->id)],
['nullable'],
),
],
]);
El primer argumento de Rule::when() admite tanto un booleano como un callable. Con booleano (caso de arriba) la condición se resuelve cuando se ejecuta el cuerpo del método (en este ejemplo, al construir el array que se pasa a validate()). Con closure el resolutor de reglas la invoca más tarde, pasando un Illuminate\Support\Fluent con los datos del Validator:
use Illuminate\Support\Fluent;
'monedero_id' => [
Rule::when(
fn (Fluent $input) => 'monedero' === $input->metodo_pago,
['required', Rule::exists('monederos', 'id')->where('usuario_id', $request->user()->id)],
['nullable'],
),
],
En un Form Request ambas variantes ven los datos ya tratados por prepareForValidation(), porque rules() se ejecuta después de esa fase. La diferencia práctica entre booleano y closure es que el closure recibe un Fluent construido por el Validator, lo que permite condicionar por varios campos sin reescribir referencias a $this->input(...). Para condiciones que necesitan acceso al Validator completo o que dependen de validación previa, la alternativa es $validator->sometimes() con closure. Más detalles en el artículo de validación condicional.
”No existe” mediante unique
Laravel no tiene una regla not_exists. La función la cumple unique, que falla cuando encuentra un registro coincidente, que es exactamente la semántica deseada:
// El usuario no debe estar ya suscrito a este boletín
'boletin_id' => [
'required',
Rule::unique('suscripciones')->where('usuario_id', $request->user()->id),
],
Si existe una suscripción de ese usuario a ese boletín, unique falla. Lo que se pide es justo eso.
Claves foráneas en formularios anidados
Un formulario de pedido con líneas de productos necesita exists en varios niveles, cada uno con su filtro:
$request->validate([
'cliente_id' => [
'required',
Rule::exists('clientes', 'id')->where('activo', true),
],
'metodo_envio_id' => 'required|exists:metodos_envio,id',
'lineas' => 'required|array|min:1',
'lineas.*.producto_id' => [
'required',
Rule::exists('productos', 'id')->where('disponible', true),
],
'lineas.*.cantidad' => 'required|integer|min:1|max:100',
'cupon' => [
'nullable',
'string',
Rule::exists('cupones', 'codigo')
->where('activo', true)
->where(fn (Builder $query) => $query->where('expira_en', '>', now())),
],
]);
Cada clave foránea tiene su propia regla con filtro. Los errores se asocian al campo concreto que falló: la API recibe un objeto errors con lineas.0.producto_id o cupon y puede mostrar el mensaje justo al lado del input correspondiente.
A diferencia de un array plano de ids (tag_ids), un campo con wildcard como lineas.*.producto_id no se puede levantar al padre: la regla exists se ejecuta por cada elemento del array, generando una query por línea. Para arrays grandes, la sección de rendimiento describe la alternativa con after().
not_in y different
not_in excluye valores de una lista cerrada. Útil para reservar identificadores especiales:
use Illuminate\Validation\Rule;
$request->validate([
'username' => [
'required',
'string',
'max:30',
Rule::notIn(['admin', 'root', 'system', 'soporte']),
],
]);
Rule::notIn() acepta un array, más legible que la cadena not_in:admin,root,system. La lista se puede leer desde un config o una constante en lugar de inline:
'username' => [
'required',
'string',
Rule::notIn(config('app.usernames_reservados')),
],
different:campo comprueba que dos campos del request tengan valores distintos. El uso típico es la confirmación de cambio de contraseña, donde la nueva no debe coincidir con la anterior:
$request->validate([
'password_actual' => 'required|string|current_password',
'password' => 'required|string|min:8|different:password_actual|confirmed',
]);
different compara valores del propio request, no contra la base. current_password sí toca el guard: lee el guard indicado (current_password:api, por ejemplo) o el guard por defecto y contrasta el valor con el hash del usuario autenticado. Si el guard no tiene usuario, la regla añade un error de validación al campo, no una excepción de autorización. La precondición real es por tanto la presencia de sesión en el guard, no que la ruta tenga un middleware concreto. Para flujos donde no hay sesión (reset por enlace de email, por ejemplo) current_password no aplica; la comparación se hace manualmente con Hash::check. Más detalles en el artículo de validación de contraseñas.
A diferencia de unique y exists, not_in no toca la base. Para listas grandes que se actualizan en una tabla, exists es la opción correcta. Para conjuntos pequeños y estables (códigos de error reservados, roles del sistema), not_in evita un round-trip a la base por cada validación.
Rendimiento
Cada regla exists y unique ejecuta una query SQL. Un formulario con diez claves foráneas son diez consultas solo durante la validación.
Para cortocircuitar tras un fallo previo, anteponer bail:
'email' => ['bail', 'required', 'email', 'unique:usuarios'],
Sin bail, Laravel ejecuta la consulta unique aunque el email sea claramente inválido (no-es-un-email). Con bail la cadena se detiene al primer error del campo.
array + exists en el mismo campo genera una única consulta WHERE IN:
'role_ids' => ['required', 'array', Rule::exists('roles', 'id')],
Para diez ids, sigue siendo una consulta. La diferencia con poner exists en el .* es proporcional al tamaño del array.
Para datos que cambian poco (países, monedas, estados de pedido), reemplazar exists por in con un array cacheado evita la base entera:
use Illuminate\Support\Facades\Cache;
$monedas = Cache::remember('monedas_activas', 3600, fn () =>
DB::table('monedas')->where('activa', true)->pluck('codigo')->all()
);
$request->validate([
'moneda' => ['required', Rule::in($monedas)],
]);
Cero consultas durante la validación de ese campo.
Verificar que las columnas usadas en unique y exists tengan índice. Las dos reglas buscan por valor de columna. Sin índice, la consulta se convierte en un full scan. Para comprobaciones compuestas con where(), añadir un índice compuesto:
$table->index(['tenant_id', 'email']);
Cuando un wildcard con where() dispara una query por elemento, se puede mover la comprobación a after() y agrupar con whereIn:
public function after(): array
{
return [
function (\Illuminate\Validation\Validator $validator) {
$ids = collect($this->input('lineas', []))->pluck('producto_id')->filter();
if ($ids->isEmpty()) {
return;
}
$validos = DB::table('productos')
->whereIn('id', $ids)
->where('tienda_id', $this->integer('tienda_id'))
->pluck('id');
foreach ($ids as $indice => $id) {
if (false === $validos->contains($id)) {
$validator->errors()->add(
"lineas.{$indice}.producto_id",
'El producto no existe en la tienda seleccionada.'
);
}
}
},
];
}
Una sola consulta en vez de N. Para arrays de cincuenta líneas la diferencia es perceptible. Los detalles sobre after() y el ciclo del Form Request están en el artículo de Form Requests.
Si hace falta saber cuántas queries genera la validación, activar el log temporal:
DB::enableQueryLog();
$request->validate([...]);
dd(DB::getQueryLog());
Con el log se ve qué reglas producen consultas y dónde se agrupan o no.
Mensajes personalizados
Los mensajes por defecto para unique y exists son funcionales pero genéricos. “The selected categoria_id is invalid” no aporta mucho. La sobreescritura se hace en messages() del Form Request:
public function messages(): array
{
return [
'email.unique' => 'Este email ya está registrado.',
'categoria_id.exists' => 'La categoría seleccionada no existe o ya no está disponible.',
'cupon.exists' => 'Este cupón no es válido o ha caducado.',
'lineas.*.producto_id.exists' => 'El producto número :position no se encuentra en el catálogo.',
];
}
:position inserta el índice del array empezando en 1. Útil cuando el usuario envía varias líneas y necesita saber cuál falló. La personalización a nivel global (todos los mensajes de unique) vive en lang/es/validation.php. Los detalles sobre prioridades de mensajes y :attribute están en mensajes de error.
Pruebas
unique y exists necesitan una base real. Las pruebas usan RefreshDatabase para garantizar un estado limpio en cada test:
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ValidacionRegistroTest extends TestCase
{
use RefreshDatabase;
public function test_email_duplicado_rechazado_en_registro(): void
{
Usuario::factory()->create(['email' => '[email protected]']);
$respuesta = $this->postJson('/api/registro', [
'nombre' => 'Nuevo',
'email' => '[email protected]',
'password' => 'secret1234',
'password_confirmation' => 'secret1234',
]);
$respuesta->assertJsonValidationErrors(['email']);
}
public function test_email_propio_pasa_al_actualizar_perfil(): void
{
$usuario = Usuario::factory()->create(['email' => '[email protected]']);
$this->actingAs($usuario);
$respuesta = $this->putJson('/api/perfil', [
'nombre' => 'Actualizado',
'email' => '[email protected]',
]);
$respuesta->assertJsonMissingValidationErrors(['email']);
}
public function test_categoria_inexistente_rechazada(): void
{
$respuesta = $this->postJson('/api/productos', [
'nombre' => 'Widget',
'categoria_id' => 9999,
'precio' => 29.99,
]);
$respuesta->assertJsonValidationErrors(['categoria_id']);
}
public function test_email_soft_deleted_disponible_con_without_trashed(): void
{
$antiguo = Usuario::factory()->create(['email' => '[email protected]']);
$antiguo->delete();
$respuesta = $this->postJson('/api/registro', [
'nombre' => 'Nueva titular',
'email' => '[email protected]',
'password' => 'secret1234',
'password_confirmation' => 'secret1234',
]);
$respuesta->assertJsonMissingValidationErrors(['email']);
}
public function test_unique_compuesto_bloquea_asignacion_duplicada(): void
{
$empleado = Empleado::factory()->create();
$proyecto = Proyecto::factory()->create();
Asignacion::factory()->create([
'empleado_id' => $empleado->id,
'proyecto_id' => $proyecto->id,
]);
$respuesta = $this->postJson('/api/asignaciones', [
'empleado_id' => $empleado->id,
'proyecto_id' => $proyecto->id,
]);
$respuesta->assertJsonValidationErrors(['proyecto_id']);
}
}
Para unique cubrir tres escenarios mínimos: valor nuevo pasa, duplicado falla, valor propio pasa en update. Para exists: id válido pasa, id inexistente falla, id válido que no cumple el where() también falla.
Una trampa común en tests: las factorías generan emails aleatorios y, con poca frecuencia pero ocurre, coliden con valores fijos del propio test. Si la prueba comprueba unicidad contra un email concreto, crear el registro de forma explícita en lugar de confiar en defaults aleatorios. Tests que dependen del seed aleatorio acaban siendo intermitentes.
Para withoutTrashed(), crear un registro y eliminarlo en el propio test antes de la aserción. Para unicidad compuesta, verificar que combinaciones distintas pasan (empleado A en proyecto 1 + empleado B en proyecto 1 ambas pasan) mientras una combinación repetida falla.
Errores frecuentes
Olvidar ignore en update. El formulario de creación funciona, el de edición rechaza los propios datos. Solución: cualquier unique que se use en un contexto de actualización lleva ignore() con la fuente correcta (modelo del binding o usuario autenticado).
Input del usuario en ignore(). Rule::unique('usuarios')->ignore($request->input('user_id')) es vector de SQL injection. Solo modelos o ids del sistema. Repetido aquí porque es el fallo más caro y el más fácil de cometer.
Omitir la columna en exists. exists:usuarios para un campo usuario_id busca una columna usuario_id en usuarios. La columna real es id. La validación falla siempre. Escribir exists:usuarios,id.
Exists sobre null sin nullable. Una clave foránea opcional validada con exists:tabla,id falla cuando el valor es null. Anteponer nullable.
Soft deletes bloqueando nuevos registros. unique cuenta los registros archivados por defecto. Un email de cuenta eliminada impide registrar uno nuevo con el mismo. Si la política lo permite, withoutTrashed().
N+1 en arrays con where dinámico. Colocar Rule::exists('productos', 'id')->where(...) en lineas.*.producto_id genera una consulta por elemento. Para arrays grandes, mover al hook after() con un único whereIn.
Sintaxis cadena con ignore concatenado. El formato posicional unique:tabla,columna,excepto,id_columna se rompe al menor descuido con las comas. El builder es claro y seguro. Reservar la cadena para los casos más simples.
Mezcla de scopes globales y reglas de validación. Los scopes globales del modelo no se aplican a unique ni a exists. Si la lógica de negocio exige excluir ciertos registros, la condición tiene que estar explícita en where() o withoutTrashed().
Para reglas que necesitan lógica más compleja que where(), ver reglas personalizadas. La referencia completa de reglas está en reglas de validación. El uso dentro del ciclo del Form Request, en Form Requests.