Validar cadenas y números en Laravel 13
La validación de cadenas y números concentra la mayoría de las reglas que se usan a diario en un proyecto Laravel. Parece simple hasta que min:3 rechaza el precio 2.50 porque falta la regla numeric, o hasta que email acepta una dirección técnicamente válida que ningún servidor va a recibir. Cada regla interpreta el valor de forma distinta según el tipo de dato del campo, y esa dependencia implícita es la fuente principal de errores.
Esta guía recorre las reglas de texto, número, formato y tamaño con ejemplos propios. Para la mecánica general de validación (cómo llamar a validate(), manejar errores, Form Requests), consulta la guía rápida. Las reglas de presencia como required, nullable o sometimes están en la referencia de reglas.
Regla string y el tipo de dato
string comprueba que el valor sea de tipo PHP string. No admite enteros ni booleanos, ni siquiera si contienen texto numérico representado como otro tipo:
$request->validate([
'nombre' => 'required|string|max:120',
'apodo' => 'nullable|string|min:2',
]);
Si el campo puede llegar vacío desde el frontend, combinar string con nullable evita que Laravel rechace un null como tipo inválido. Sin nullable, un campo que llega como null falla la regla string antes de que cualquier otra regla se ejecute. Esto pasa con frecuencia en APIs JSON donde campos opcionales llegan como null en lugar de omitirse.
Hay un matiz con las peticiones de formulario HTML. Laravel incluye el middleware ConvertEmptyStringsToNull que transforma cadenas vacías en null. Un campo <input> vacío no llega como "" sino como null, así que string lo rechaza a menos que nullable esté presente.
Builder fluido Rule::string()
Laravel 13 ofrece un constructor fluido que agrupa restricciones de texto en una sola cadena legible:
use Illuminate\Validation\Rule;
$request->validate([
'slug' => [
'required',
Rule::string()
->min(3)
->max(80)
->alphaDash(ascii: true)
->lowercase(),
],
'titulo' => [
'required',
Rule::string()
->min(5)
->max(200)
->doesntStartWith('.')
->doesntEndWith('.'),
],
]);
Los métodos disponibles incluyen alpha, alphaDash, alphaNumeric, ascii, between, doesntEndWith, doesntStartWith, endsWith, exactly, lowercase, max, min, startsWith y uppercase. Al implementar Conditionable, el builder acepta when y unless para aplicar restricciones condicionales:
Rule::string()
->min(3)
->max(100)
->when($request->boolean('is_public'), fn ($rule) => $rule->ascii()),
Números: numeric vs integer vs digits
Tres reglas, tres comportamientos distintos. La confusión entre ellas es una de las dudas más repetidas en StackOverflow:
| Regla | Qué acepta | Función PHP subyacente |
|---|---|---|
numeric | Cualquier valor numérico: enteros, flotantes, cadenas numéricas ("3.14", "-7", "1e5") | is_numeric() |
integer | Valores que pasan FILTER_VALIDATE_INT: 42, "42", "-3" | filter_var() |
digits:N | Solo dígitos (0-9) con exactamente N caracteres | strlen() + regex |
Para ver la diferencia con un ejemplo concreto:
// Valor: "3.14"
// numeric → pasa (is_numeric devuelve true)
// integer → falla (FILTER_VALIDATE_INT rechaza decimales)
// digits:4 → falla (el punto no es un dígito)
// Valor: "42"
// numeric → pasa
// integer → pasa
// digits:2 → pasa
// Valor: "007"
// numeric → pasa
// integer → pasa (filter_var acepta ceros a la izquierda)
// digits:3 → pasa
integer no garantiza que el valor sea de tipo PHP int – la cadena "42" también pasa. Para rechazar cadenas y aceptar solo enteros reales, existe el modificador strict:
// Solo acepta tipo int, rechaza "42" como string
'cantidad' => 'integer:strict',
Lo mismo aplica a numeric:strict, que rechaza cadenas numéricas y solo acepta int o float reales. Los modos strict son relevantes sobre todo en APIs que reciben JSON tipado, donde {"cantidad": 5} es distinto de {"cantidad": "5"}.
Separadores decimales y la coma
numeric usa is_numeric() de PHP, que solo reconoce el punto como separador decimal. El valor "1.500,75" (formato habitual en España y gran parte de Latinoamérica) no es numérico para PHP y falla la validación.
Si tu formulario envía precios con coma, necesitas transformar el valor antes de validar. El método prepareForValidation() en un Form Request es el lugar natural:
protected function prepareForValidation(): void
{
if ($this->has('precio')) {
// "1.500,75" → "1500.75"
$limpio = str_replace(['.', ','], ['', '.'], $this->precio);
$this->merge(['precio' => $limpio]);
}
}
La transformación depende del locale. En algunos países latinoamericanos se usa la coma para miles y el punto para decimales, igual que en EE.UU. Asegúrate de documentar qué formato espera el formulario, o normaliza en el frontend antes del envío.
Otra estrategia es trabajar siempre en centavos (enteros) y mover el formato decimal al frontend. Si precio se envía como 150075 (centavos) en lugar de "1.500,75", la validación se reduce a 'precio' => 'required|integer|min:1' y no hay ambigüedad.
digits, digits_between, max_digits y min_digits
digits:5 valida que la cadena numérica tenga exactamente 5 caracteres. No valida el valor numérico sino la longitud: digits:5 rechaza tanto 999 (3 caracteres) como 100000 (6 caracteres). Muchos desarrolladores confunden esta regla con max o max_digits y terminan rechazando entradas válidas.
$request->validate([
// Exactamente 5 dígitos (códigos postales de España, Colombia, etc.)
'codigo_postal' => 'required|digits:5',
// Entre 7 y 10 dígitos (documentos de identidad)
'numero_documento' => 'required|digits_between:7,10',
// PIN de 4 a 6 dígitos
'pin' => 'required|min_digits:4|max_digits:6',
]);
max_digits y min_digits establecen límites de longitud sin fijar un número exacto. La diferencia con max y min es clave: max:6 sobre un campo con regla numeric o integer compara el valor numérico (max 6 como número). max_digits:6 compara la cantidad de dígitos (hasta 6 cifras, es decir, hasta 999999). Sin regla de tipo explícita, max:6 en un string compara la longitud de la cadena, lo cual a veces coincide con max_digits:6 y a veces no (si hay signo negativo, por ejemplo).
decimal para precisión de decimales
Cuando necesitas controlar cuántos decimales tiene un valor, decimal es más directo que un regex:
$request->validate([
// Exactamente 2 decimales: 9.99 pasa, 9.9 no
'precio' => 'required|numeric|decimal:2',
// Entre 2 y 4 decimales
'tasa_interes' => 'required|numeric|decimal:2,4',
// Coordenada: entre 4 y 8 decimales
'latitud' => 'required|numeric|decimal:4,8|between:-90,90',
]);
decimal comprueba internamente que el valor sea numérico (llama a la misma lógica que numeric), así que técnicamente no necesita una regla numeric aparte. Aun así, incluirla de forma explícita deja más claro al lector qué tipo de dato se espera.
Para precios, la combinación típica es numeric|decimal:2|min:0|max:999999.99. Añadir max previene que valores como "1e15" (que pasan numeric) desborden la columna de la base de datos.
Tamaño: min, max, size y between
Estas cuatro reglas comparten una lógica contextual que depende del tipo declarado en las reglas del campo. Si no hay regla de tipo, Laravel infiere el tipo desde el valor recibido, con resultados que pueden desorientar:
| Regla de tipo presente | Qué mide min/max/size/between |
|---|---|
string | Cantidad de caracteres |
numeric / integer | Valor numérico |
array | Cantidad de elementos (count) |
file | Tamaño en kilobytes |
| Ninguna | Laravel adivina: cadena → caracteres, número → valor |
$request->validate([
// Cadena: min 2 caracteres, max 100
'ciudad' => 'required|string|min:2|max:100',
// Número: valor mínimo 0, máximo 999999
'salario' => 'required|numeric|min:0|max:999999',
// Array: exactamente 3 elementos
'colores' => 'required|array|size:3',
// Entero entre 1 y 10 (inclusivo)
'calificacion' => 'required|integer|between:1,10',
]);
Declarar el tipo de forma explícita evita sorpresas. Sin string, el valor "100" podría interpretarse como número 100, y min:3 lo dejaría pasar porque 100 >= 3. Este es probablemente el error más común en validación de Laravel y aparece en foros con mucha frecuencia.
Entero mayor que cero
Un patrón muy frecuente al validar cantidades o IDs:
$request->validate([
'cantidad' => 'required|integer|min:1',
'producto_id' => 'required|integer|min:1',
]);
min:1 sobre un campo con regla integer compara el valor numérico, no la longitud. El valor 0 falla y 1 pasa. Para un rango acotado de ambos lados:
$request->validate([
// Entero positivo con techo
'pagina' => 'required|integer|min:1|max:1000',
'por_pagina' => 'required|integer|min:5|max:100',
]);
min/max (tamaño) vs gt/gte/lt/lte (comparación)
Hay una distinción que muchos tutoriales omiten. min/max comparan contra un valor literal, mientras que gt/gte/lt/lte comparan contra otro campo del formulario:
$request->validate([
// min/max: comparan contra valor fijo
'precio_min' => 'required|numeric|min:0',
'precio_max' => 'required|numeric|max:999999',
// gt: precio_max debe ser mayor que el campo precio_min
'precio_max' => 'required|numeric|gt:precio_min',
]);
Ambos grupos siguen la misma lógica de tipo: para strings comparan longitud, para numéricos comparan valor, para arrays comparan count, para archivos comparan kilobytes. La diferencia es que gt:campo referencia otro campo y min:N usa un valor fijo. La referencia de reglas cubre gt/gte/lt/lte en la sección de comparación.
Validar email
La regla email por defecto aplica solo RFCValidation, que acepta direcciones técnicamente válidas según los RFC pero que en la práctica ningún servidor entrega. Direcciones como user@[192.168.1.1] o admin@localhost pasan la validación por defecto.
Laravel usa la librería egulias/email-validator internamente y ofrece seis validadores combinables:
rfc– validación según RFC (por defecto). Acepta la sintaxis más ampliastrict– igual querfcpero falla si hay advertencias RFC, como puntos finales o puntos consecutivosdns– comprueba que el dominio tenga registro MX válido. Hace una consulta DNS realspoof– detecta caracteres Unicode homógrafos que podrían confundir al usuario (por ejemplo,аcirílico vsalatino)filter– usafilter_var()de PHP, más restrictiva querfcen algunos casosfilter_unicode– variante defilterque permite ciertos caracteres Unicode
Los validadores se combinan separados por coma:
$request->validate([
// Validación básica RFC (por defecto, igual que 'email')
'email_contacto' => 'required|email',
// RFC + verificación DNS del dominio
'email_registro' => 'required|email:rfc,dns',
// RFC estricto + DNS + anti-spoofing
'email_corporativo' => 'required|email:strict,dns,spoof',
// Solo filter_var de PHP (más conservadora, rechaza más)
'email_simple' => 'required|email:filter',
]);
El validador dns hace una consulta DNS real durante la validación, lo que tiene dos implicaciones: agrega latencia (normalmente milisegundos, pero puede llegar a segundos con DNS lentos) y falla en entornos sin acceso a red. En tests automatizados o pipelines CI, dns va a rechazar emails válidos si la resolución DNS no está disponible. Los validadores dns y spoof requieren la extensión PHP intl, que no siempre viene instalada en entornos mínimos de Docker.
¿Qué combinación usar en cada caso?
- Formulario de registro →
email:rfc,dns. Descarta dominios inexistentes. El usuario puede corregir un typo en el dominio antes de crear la cuenta - Formulario de contacto →
email(solo RFC). No importa si el dominio tiene MX porque no vas a enviar un correo de verificación - Panel de administración →
email:strict,dns,spoof. Máxima protección contra direcciones engañosas - Importación masiva de datos →
email:filter. Rápida y sin consultas DNS, acepta lo mínimo razonable
Builder fluido para email
use Illuminate\Validation\Rule;
$request->validate([
'email' => [
'required',
Rule::email()
->rfcCompliant(strict: false)
->validateMxRecord()
->preventSpoofing(),
],
]);
El builder fluido es equivalente a 'email:rfc,dns,spoof' pero resulta más legible cuando se combina con otras reglas en sintaxis de array.
Email único en base de datos
Para verificar que el email no exista ya en la tabla de usuarios, se combina la validación de formato con la regla unique:
use Illuminate\Validation\Rule;
// Registro: el email no debe existir
$request->validate([
'email' => [
'required',
'email:rfc,dns',
Rule::unique('users', 'email'),
],
]);
// Actualización de perfil: ignorar el registro del propio usuario
$request->validate([
'email' => [
'required',
'email:rfc,dns',
Rule::unique('users', 'email')->ignore($user->id),
],
]);
Los detalles sobre unique e ignore, conexiones de base de datos, y soft deletes están en la guía de validación con base de datos.
Validar URL
La regla url verifica que el valor sea una URL válida. Por defecto acepta cualquier esquema, pero en la mayoría de formularios querrás restringir a HTTP/HTTPS:
$request->validate([
'sitio_web' => 'required|url:http,https',
'callback_uri' => 'required|url:https',
'enlace_steam' => 'nullable|url:steam',
]);
url requiere que el esquema esté presente en el valor. Si tu formulario espera que el usuario escriba ejemplo.com sin https://, necesitas anteponer el protocolo antes de validar. El método prepareForValidation() en un Form Request funciona bien:
protected function prepareForValidation(): void
{
if ($this->filled('sitio_web') && !str_starts_with($this->sitio_web, 'http')) {
$this->merge(['sitio_web' => 'https://' . $this->sitio_web]);
}
}
active_url va un paso más allá: extrae el hostname con parse_url() y verifica que tenga un registro DNS A o AAAA mediante dns_get_record(). Las mismas advertencias que con el validador dns de email aplican aquí: latencia extra, falla sin acceso a red, inadecuada para tests. URLs internas como http://api.local o http://localhost:8080 no pasan active_url porque no tienen registro DNS público.
Para la mayoría de formularios públicos, url:http,https combinada con max:2048 (límite práctico de longitud) es suficiente:
'enlace' => 'nullable|url:http,https|max:2048',
UUID y ULID
$request->validate([
'pedido_id' => 'required|uuid',
'session_id' => 'required|ulid',
]);
uuid acepta cualquier UUID válido según RFC 9562 (versiones 1, 3, 4, 5, 6, 7 y 8). Si tu sistema genera UUIDs v4 y quieres rechazar otras versiones:
'token' => 'required|uuid:4',
'tracking_id' => 'required|uuid:7',
ulid valida el formato ULID (26 caracteres, base32 Crockford, ordenable cronológicamente). Ambas reglas son útiles cuando recibes identificadores externos de APIs o microservicios que usan estos formatos. No verifican que el identificador exista en la base de datos – para eso necesitas combinar con exists (ver validación con base de datos).
Si tu aplicación genera UUIDs internamente con Str::uuid(), la regla uuid en la entrada protege contra valores malformados que llegarían a consultas WHERE uuid = ? y fallarían silenciosamente o retornarían resultados vacíos. Lo mismo para modelos que usan HasUuids o HasUlids como clave primaria.
Expresiones regulares: regex y not_regex
regex valida que el campo coincida con un patrón de expresión regular de PHP (preg_match). El patrón necesita delimitadores, igual que al llamar a preg_match() directamente:
$request->validate([
// Código de producto: 2 letras mayúsculas + guion + 4 dígitos
'codigo' => ['required', 'regex:/^[A-Z]{2}-\d{4}$/'],
// Nombre: letras Unicode, espacios, apóstrofos y guiones
'nombre' => ['required', 'string', 'regex:/^[\p{L}\s\'\-]+$/u'],
// No puede contener etiquetas HTML
'comentario' => ['required', 'string', 'not_regex:/<[^>]+>/'],
]);
La trampa del pipe en la sintaxis de string
Si la expresión regular contiene el carácter | y usas la sintaxis de pipes para separar reglas, Laravel lo interpreta como separador de reglas en lugar de alternancia regex:
// MAL: Laravel ve 3 trozos: required, regex:/^(AB, y CD)-\d+$/
'codigo' => 'required|regex:/^(AB|CD)-\d+$/'
// BIEN: la sintaxis de array elimina la ambigüedad
'codigo' => ['required', 'regex:/^(AB|CD)-\d+$/']
Este error no lanza una excepción clara – simplemente la validación se comporta de forma inesperada. Por seguridad, usa siempre la sintaxis de array cuando haya un regex o not_regex en las reglas, independientemente de si el patrón contiene pipes.
Caracteres especiales y modificadores
Los delimitadores más comunes son /, pero puedes usar otros si el patrón contiene barras:
// Ruta de archivo Unix: el delimitador # evita escapar las barras
'ruta' => ['required', 'regex:#^/var/www/[\w/]+$#'],
// Case-insensitive con modificador i
'referencia' => ['required', 'regex:/^REF-[A-Z0-9]{8}$/i'],
// Unicode con modificador u
'nombre' => ['required', 'regex:/^[\p{L}\s]{2,50}$/u'],
Validar número de teléfono
Laravel no incluye una regla nativa para teléfonos. El formato varía tanto entre países que cualquier regex genérico o es demasiado permisivo o rechaza números válidos. Las dos opciones habituales:
Opción 1: regex para un formato específico
Si tu aplicación opera en un solo país o región, un regex ajustado funciona:
$request->validate([
// España: +34 seguido de 9 dígitos (fijo o móvil)
'telefono_es' => ['required', 'regex:/^\+34[6-9]\d{8}$/'],
// México: +52 seguido de 10 dígitos
'telefono_mx' => ['required', 'regex:/^\+52\d{10}$/'],
// Formato internacional genérico: + seguido de 7 a 15 dígitos
'telefono' => ['required', 'regex:/^\+\d{7,15}$/'],
]);
El regex genérico ^\+\d{7,15}$ acepta la mayoría de formatos E.164 pero no valida que el prefijo de país sea real ni que la longitud sea correcta para ese país en particular.
Opción 2: paquete dedicado
Para formularios internacionales, el paquete propaganistas/laravel-phone valida contra la librería libphonenumber de Google y maneja todos los formatos de todos los países:
// composer require propaganistas/laravel-phone
$request->validate([
// Acepta formatos de España, México o Argentina
'telefono' => 'required|phone:ES,MX,AR',
// Detecta el país automáticamente desde el número
'telefono' => 'required|phone:AUTO',
]);
El paquete maneja formatos con o sin código de país, extensiones, y distingue entre números móviles y fijos. Si la aplicación recibe teléfonos de varios países, esta opción ahorra mucho mantenimiento frente a mantener regexes propios.
Independientemente del método, es buena práctica normalizar el número a formato E.164 (+34612345678) antes de almacenarlo. prepareForValidation() puede limpiar espacios y guiones que el usuario inserte al teclear:
protected function prepareForValidation(): void
{
if ($this->has('telefono')) {
$this->merge([
'telefono' => preg_replace('/[\s\-\(\)]/', '', $this->telefono),
]);
}
}
Familia alpha: texto sin números
| Regla | Permite |
|---|---|
alpha | Solo letras Unicode (\p{L}, \p{M}) |
alpha_dash | Letras Unicode (\p{L}, \p{M}) + dígitos (\p{N}) + guion - + guion bajo _ |
alpha_num | Letras Unicode (\p{L}, \p{M}) + dígitos (\p{N}) |
Las tres reglas son Unicode-aware por defecto. Eso significa que Ñ, ü, ø o incluso caracteres cirílicos, árabes o chinos pasan la validación alpha. Si tu campo espera solo letras del alfabeto latino, necesitas el modificador ascii:
$request->validate([
// Solo letras ASCII (a-z, A-Z)
'codigo_pais' => 'required|alpha:ascii|size:2',
// Letras + dígitos + guion + guion bajo, todo ASCII
'nombre_usuario' => 'required|alpha_dash:ascii|min:3|max:30',
// Alfanumérico Unicode (permite Ñ, ü, etc.)
'nombre_display' => 'required|alpha_num|min:2|max:40',
]);
Ninguna de las reglas alpha* acepta espacios. Para validar un nombre completo que contenga letras con espacios (y opcionalmente apóstrofos o guiones, como “O’Brien” o “García-López”), un regex es la opción directa:
$request->validate([
// Letras Unicode, espacios, apóstrofos y guiones
'nombre_completo' => ['required', 'string', 'regex:/^[\p{L}\s\'\-]+$/u', 'min:2', 'max:100'],
]);
lowercase y uppercase
Validan que toda la cadena esté en minúsculas o mayúsculas respectivamente:
$request->validate([
'slug' => 'required|string|lowercase|alpha_dash:ascii',
'codigo_moneda' => 'required|string|uppercase|size:3',
'codigo_iso2' => 'required|string|uppercase|alpha:ascii|size:2',
]);
Estas reglas comprueban el formato pero no transforman el valor. Si necesitas normalizar la capitalización, hazlo en prepareForValidation() o en un mutador del modelo:
protected function prepareForValidation(): void
{
if ($this->has('codigo_moneda')) {
$this->merge(['codigo_moneda' => strtoupper($this->codigo_moneda)]);
}
}
Así el usuario puede escribir “eur” y el sistema lo convierte a “EUR” antes de validar con uppercase.
ascii
La regla ascii verifica que el campo contenga solo caracteres ASCII de 7 bits (rango 0-127). No es lo mismo que alpha:ascii – ascii acepta números, signos de puntuación, espacios y otros caracteres dentro del rango ASCII:
$request->validate([
// Clave API: solo ASCII, longitud fija
'api_key' => 'required|string|ascii|size:40',
// Nombre de host: ASCII, sin espacios
'hostname' => ['required', 'string', 'ascii', 'regex:/^[a-z0-9\.\-]+$/'],
]);
Buscar contenido en cadenas
Laravel incluye reglas para verificar prefijos y sufijos sin recurrir a regex:
$request->validate([
// El campo debe empezar por http:// o https://
'enlace' => 'required|starts_with:http://,https://',
// El archivo debe terminar en extensión permitida
'nombre_archivo' => 'required|ends_with:.pdf,.docx,.xlsx',
// No puede empezar por un punto (archivo oculto en Unix)
'nombre' => ['required', 'doesnt_start_with:.'],
// No puede terminar en .exe ni .bat
'adjunto' => ['required', 'doesnt_end_with:.exe,.bat'],
]);
Para buscar si una subcadena está dentro del valor, no existe una regla nativa en Laravel. Las alternativas:
// Regex: que contenga la palabra "laravel" (case-insensitive)
'bio' => ['required', 'string', 'regex:/laravel/i'],
// Closure para lógica más elaborada
'tags' => ['required', 'string', function (string $attr, mixed $value, Closure $fail) {
$requeridos = ['php', 'laravel'];
foreach ($requeridos as $tag) {
if (!str_contains(strtolower($value), $tag)) {
$fail("El campo {$attr} debe contener '{$tag}'.");
}
}
}],
Para validación más compleja de contenido en texto, una regla personalizada reutilizable es mejor opción que closures repetidas.
Direcciones IP y MAC
Reglas de formato que se usan en paneles de administración, configuraciones de red o whitelists:
$request->validate([
'servidor' => 'required|ip', // IPv4 o IPv6
'gateway' => 'required|ipv4', // Solo IPv4
'dns_primario' => 'required|ipv6', // Solo IPv6
'dispositivo' => 'required|mac_address',
]);
ip acepta tanto IPv4 como IPv6. Si tu sistema solo trabaja con IPv4 (la mayoría de redes internas), usa ipv4 para evitar aceptar direcciones IPv6 que la infraestructura no puede resolver. Estas reglas verifican el formato, no si la dirección es alcanzable o está en un rango permitido. Para restringir a rangos privados (10.x, 192.168.x) o excluir loopback, necesitas una regla personalizada o un closure.
Combinar reglas de tamaño y tipo
El orden de las reglas importa cuando se combinan tipo y tamaño. La regla de tipo define cómo se interpretan min/max/size/between para ese campo:
$request->validate([
// String: entre 8 y 255 caracteres
'contrasena' => 'required|string|between:8,255',
// Número: valor entre 0.01 y 9999.99
'monto' => 'required|numeric|between:0.01,9999.99',
// Entero: min 1, max configurable desde .env
'pagina' => 'required|integer|min:1|max:' . config('app.max_page', 500),
// String: longitud exacta de 10 caracteres
'referencia' => 'required|string|size:10',
]);
Para limitar tanto la longitud (cantidad de dígitos) como el valor numérico de un campo, combina ambas familias:
$request->validate([
// Hasta 6 dígitos y valor no mayor a 500000
'monto_centavos' => 'required|integer|min:0|max:500000|max_digits:6',
]);
multiple_of
Verifica que el valor sea múltiplo de otro. Práctico para cantidades que deben ser paquetes completos o pasos discretos:
$request->validate([
// Cantidad por caja: múltiplo de 12
'unidades' => 'required|integer|multiple_of:12|min:12',
// Descuento: múltiplo de 5%
'descuento' => 'required|integer|multiple_of:5|between:0,100',
]);
Errores frecuentes
min/max sin regla de tipo. Es la fuente de bugs más habitual con diferencia. Si escribes 'precio' => 'required|min:0' sin numeric, y llega el string "5", Laravel interpreta min:0 como “longitud mínima 0 caracteres”, no como “valor mínimo 0”. El campo pasa siempre porque "5" tiene 1 caracter >= 0. Agrega numeric o integer explícitamente en todo campo que represente un número.
email demasiado permisivo. La validación por defecto (rfc) acepta test@localhost y [email protected] porque técnicamente son válidas según RFC. Para un formulario de registro, email:rfc,dns descarta dominios sin registro MX. Pero recuerda que dns falla sin acceso a red (CI, Docker sin DNS externo).
regex con pipes en sintaxis de string. Si tienes 'campo' => 'required|regex:/^(A|B)$/', Laravel divide por | y ve tres trozos rotos en lugar de dos reglas. No hay error claro – la validación se comporta de forma inesperada. Siempre usa array para regex.
alpha acepta Unicode. alpha deja pasar cirílico, chino, árabe y cualquier carácter clasificado como letra Unicode. Si esperas solo letras latinas, necesitas alpha:ascii. Esto es particularmente relevante en campos como código de país o matrícula donde los caracteres Unicode son un error de entrada.
digits vs max_digits. digits:5 exige exactamente 5 dígitos. max_digits:5 permite hasta 5 dígitos. Si usas digits:5 para un campo donde el usuario puede escribir entre 4 y 6 dígitos, toda entrada con longitud distinta de 5 falla. Para rangos, usa digits_between o la combinación min_digits/max_digits.
numeric acepta notación científica. El valor "1e10" pasa la regla numeric porque is_numeric() lo reconoce como número. Si estás almacenando en una columna decimal(10,2), el valor desborda sin aviso. Combina numeric con max y decimal para acotar el rango y formato aceptados.
active_url en tests. Al escribir tests de feature que validan formularios con active_url, la prueba pasa en local (hay DNS) pero falla en CI (contenedor aislado). Si no puedes garantizar DNS en CI, sustituye active_url por url en la validación y verifica la accesibilidad del dominio en un paso posterior (job o evento).
string y campos numéricos de HTML. Un <input type="number"> envía el valor como string en la petición HTTP ("42", no 42). Si usas la regla string, el campo pasa, pero no es lo que probablemente quieres. Para campos numéricos del formulario, usa integer o numeric en lugar de string. La regla string es para campos de texto libre, nombres, descripciones – no para campos que representan valores numéricos.
Árbol de decisión: qué regla para cada caso
Elegir entre numeric, integer y digits depende de lo que represente el campo:
- Precio, porcentaje, tasa de interés →
numeric+decimal:N+min/max - Edad, cantidad, año →
integer+min/max - Código postal, PIN, DNI →
digits:Nodigits_between:N,M - Monto en centavos →
integer+min:0+max_digits:N - Coordenadas →
numeric+decimal:4,8+between:-180,180
Para cadenas con restricción de formato:
- Nombre de usuario →
alpha_dash:ascii+min+max - Nombre completo →
string+ regex con\p{L}\s - Slug →
string+lowercase+alpha_dash:ascii - Código ISO →
alpha:ascii+uppercase+size:2osize:3 - Email →
email:rfc,dns(registro) oemail(contacto) - Teléfono → regex por país o paquete
laravel-phone - URL →
url:http,https+max:2048
Ejemplo completo: formulario de registro
Un Form Request que combina varias reglas de esta guía:
use Illuminate\Validation\Rule;
$request->validate([
'nombre' => ['required', 'string', 'regex:/^[\p{L}\s\'\-]+$/u', 'min:2', 'max:80'],
'email' => ['required', 'email:rfc,dns', Rule::unique('users')],
'telefono' => ['nullable', 'regex:/^\+\d{7,15}$/'],
'edad' => 'required|integer|min:13|max:130',
'sitio' => 'nullable|url:http,https|max:2048',
'usuario' => [
'required',
Rule::string()
->min(3)
->max(30)
->alphaDash(ascii: true)
->lowercase(),
],
'referido_id' => 'nullable|uuid:4',
'monto_inicial' => 'required|numeric|decimal:2|min:0.01|max:99999.99',
'codigo_promo' => 'nullable|string|uppercase|alpha_num:ascii|size:8',
]);
Las guías sobre Form Requests, mensajes de error personalizados y reglas personalizadas cubren cómo organizar y extender esta lógica cuando el formulario crece.