Validar archivos e imágenes en Laravel 13
La subida de archivos es una de las áreas donde más errores silenciosos se producen. Un archivo demasiado grande puede ser cortado por PHP antes de que Laravel lo vea. Un CSV puede tener extensión .csv pero un MIME type de texto plano. Una imagen puede pesar poco pero medir 8000x8000 píxeles. Este artículo cubre las reglas de validación para archivos e imágenes, el builder fluent File::, los límites de php.ini y los errores más frecuentes.
Para los conceptos generales de validación, consulta la guía de inicio rápido. La validación de arrays de archivos se cubre en el artículo de arrays.
Reglas básicas: file, image, mimes, mimetypes, extensions
La regla file comprueba que el campo sea un archivo subido correctamente (una instancia de UploadedFile con isValid() true). Es la base sobre la que se construyen las demás reglas:
$request->validate([
'documento' => 'required|file|max:10240',
]);
max:10240 limita el tamaño a 10 MB (10240 KB). Para archivos, max, min y size siempre trabajan en kilobytes.
image
La regla image valida que el archivo sea una imagen de uno de estos formatos: jpg, jpeg, png, bmp, gif o webp. No incluye SVG por defecto debido al riesgo de XSS (un SVG puede contener JavaScript).
$request->validate([
'avatar' => 'required|image|max:2048',
]);
La regla image restringe el tipo de imagen aceptado a formatos rasterizados seguros. Si se necesita aceptar SVG, hay que usar la sintaxis image:allow_svg o el builder fluent File::image(allowSvg: true).
mimes vs mimetypes
Estas dos reglas causan confusión habitual. Ambas validan el tipo del archivo, pero de formas distintas.
mimes recibe extensiones y las mapea a MIME types internamente. A pesar de recibir extensiones como argumento, lee el contenido del archivo para determinar el tipo real:
// Acepta PDF reales, no solo archivos con extensión .pdf
$request->validate([
'contrato' => 'required|file|mimes:pdf',
]);
mimetypes recibe MIME types completos y también lee el contenido del archivo:
$request->validate([
'video' => 'required|file|mimetypes:video/mp4,video/mpeg,video/quicktime',
]);
La diferencia principal: mimes no verifica que la extensión del archivo coincida con su contenido. Un archivo llamado factura.txt cuyo contenido real sea un PDF válido pasará mimes:pdf. Si se necesita que la extensión también coincida, hay que añadir la regla extensions:
$request->validate([
'contrato' => 'required|file|mimes:pdf|extensions:pdf',
]);
extensions comprueba solo la extensión asignada por el usuario, sin leer el contenido. La documentación de Laravel advierte que no se debe usar extensions sola como única validación de tipo – siempre combinarla con mimes o mimetypes.
La diferencia entre mimes y mimetypes en la práctica: mimes es más cómoda porque recibe extensiones cortas (pdf, jpg), pero internamente las traduce a MIME types para comparar con el contenido. mimetypes recibe MIME types completos, lo que da más control para formatos con múltiples variantes. Para la mayoría de los formatos comunes (PDF, imágenes, documentos de Office), mimes es suficiente. Para vídeos y formatos menos estándar donde la extensión puede no reflejar el contenido real, mimetypes es más precisa.
Un ejemplo que combina las tres reglas para máxima seguridad:
$request->validate([
'certificado' => [
'required',
'file',
'mimes:pdf',
'mimetypes:application/pdf',
'extensions:pdf',
'max:5120',
],
]);
En la práctica, mimes:pdf + extensions:pdf cubre la mayoría de los casos. El triple control (mimes + mimetypes + extensions) solo tiene sentido en contextos donde la seguridad del tipo de archivo es crítica.
MIME types problemáticos
Algunos formatos tienen MIME types poco intuitivos que causan fallos inesperados:
| Formato | Extensión | MIME type |
|---|---|---|
| Excel (xlsx) | .xlsx | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet |
| Word (docx) | .docx | application/vnd.openxmlformats-officedocument.wordprocessingml.document |
| CSV | .csv | text/plain o text/csv (depende del sistema) |
| SVG | .svg | image/svg+xml |
Para CSV, la detección de MIME type puede devolver text/plain porque el contenido es texto puro. Usar mimetypes:text/plain,text/csv o combinar extensions:csv con una validación manual del contenido.
Para archivos de Office (xlsx, docx), es más práctico usar mimes con la extensión que recordar el MIME type completo:
// Más legible que escribir el MIME type completo
$request->validate([
'hoja' => 'required|file|mimes:xlsx,xls,csv',
'informe' => 'required|file|mimes:pdf,docx,doc',
]);
El builder fluent File::
El builder File:: es una alternativa más legible a la cadena de reglas en string. Acepta tamaños con sufijo (kb, mb, gb, tb) en vez de obligar a calcular en kilobytes:
use Illuminate\Validation\Rules\File;
$request->validate([
'curriculum' => [
'required',
File::types(['pdf', 'docx'])
->min('100kb')
->max('5mb'),
],
]);
File::types() funciona como mimes – recibe extensiones pero valida el contenido real del archivo. Para imágenes, File::image() combina la regla image con el builder:
use Illuminate\Validation\Rules\File;
use Illuminate\Validation\Rule;
$request->validate([
'foto' => [
'required',
File::image()
->max('10mb')
->dimensions(
Rule::dimensions()
->maxWidth(4000)
->maxHeight(4000)
),
],
]);
El builder permite encadenar todas las restricciones de un archivo en un solo objeto, lo que facilita la lectura cuando hay varias condiciones. La versión en string sería: 'required|image|max:10000|dimensions:max_width=4000,max_height=4000'.
La ventaja principal del builder no es solo la legibilidad. Los tamaños con sufijo evitan errores de conversión manual. Un detalle importante: el builder usa multiplicadores decimales (1 mb = 1000 KB, no 1024). Así, File::max('5mb') equivale a max:5000, no a max:5120. La diferencia es de 120 KB – irrelevante en la práctica, pero conviene saberlo si se mezclan ambos formatos en un mismo proyecto. Además, File::types() valida el contenido real, igual que mimes, lo que hace innecesario recordar la distinción.
Para aceptar SVG con el builder, pasar allowSvg: true al constructor de imagen:
File::image(allowSvg: true)
->max('2mb')
Sin este parámetro, File::image() rechaza SVG de la misma forma que la regla image.
Validar dimensiones de imagen
Además del tamaño de la imagen en bytes, a menudo se necesita controlar sus dimensiones en píxeles. La regla dimensions permite restringir el ancho, alto y ratio. Se puede usar en formato string o con el builder fluent Rule::dimensions():
use Illuminate\Validation\Rule;
$request->validate([
'banner' => [
'required',
'image',
Rule::dimensions()
->minWidth(800)
->minHeight(200)
->maxWidth(1920)
->maxHeight(600),
],
]);
Los métodos disponibles: width(), height() (valores exactos), minWidth(), maxWidth(), minHeight(), maxHeight() y ratio().
El ratio se puede especificar como fracción o como float:
// Ratio 16:9
Rule::dimensions()->ratio(16 / 9)
// Equivalente
'dimensions:ratio=16/9'
En formato string, todos los parámetros van separados por comas:
'avatar' => 'required|image|dimensions:min_width=100,min_height=100,ratio=1/1'
La regla dimensions usa getimagesize(), una función del núcleo de PHP que no depende de GD ni Imagick. Si el archivo no es una imagen válida o getimagesize() no puede leer las dimensiones, la validación falla – no pasa silenciosamente.
Un uso habitual de dimensions es forzar un formato cuadrado para avatares:
$request->validate([
'avatar' => [
'required',
'image',
'max:2048',
Rule::dimensions()
->minWidth(200)
->minHeight(200)
->ratio(1 / 1),
],
]);
La regla ratio(1/1) rechaza imágenes que no sean cuadradas. Si la imagen tiene 500x501 píxeles, la validación falla. Para aplicaciones donde un ratio exacto es demasiado estricto, conviene recortar la imagen en el servidor después de la validación en vez de exigir un ratio perfecto al usuario.
Tamaños máximos y los límites de php.ini
Las reglas max y size en Laravel operan a nivel de validación, pero PHP tiene sus propios límites que actúan antes. Si el archivo excede estos límites, PHP lo descarta antes de que Laravel lo procese, y la validación nunca se ejecuta.
Dos directivas en php.ini controlan esto:
; Tamaño máximo de un archivo individual
upload_max_filesize = 2M
; Tamaño máximo del body POST completo (debe ser >= upload_max_filesize)
post_max_size = 8M
Si un usuario intenta subir un archivo de 15 MB con upload_max_filesize = 2M, PHP descarta el archivo. Laravel recibe un request sin el campo del archivo, y la validación required falla con un mensaje genérico (“El campo archivo es obligatorio”) en vez de “El archivo es demasiado grande”. El usuario no entiende por qué se le dice que falta el archivo cuando acaba de seleccionarlo.
Para manejar esto con un mensaje claro:
$request->validate([
'archivo' => 'required|file|max:20480',
], [
'archivo.required' => 'No se recibió el archivo. Si pesa más de '
. ini_get('upload_max_filesize') . ', reduce su tamaño antes de subirlo.',
]);
La recomendación general: configurar upload_max_filesize y post_max_size en php.ini con valores superiores al máximo que la aplicación acepte. Si la regla de Laravel permite archivos de 20 MB, upload_max_filesize debe ser al menos 20M (o algo mayor para evitar problemas de redondeo). Así, el archivo siempre llega a Laravel y es la validación de la aplicación la que decide si es demasiado grande.
En servidores con Nginx, además hay que configurar client_max_body_size en la configuración del servidor. Sin este ajuste, Nginx devuelve un 413 antes de que PHP vea la petición.
Para verificar los límites activos de PHP desde la aplicación:
// Útil en tests o en un health-check
$maxUpload = ini_get('upload_max_filesize');
$maxPost = ini_get('post_max_size');
Estos valores se leen como strings ('2M', '128M'). Para convertirlos a bytes y compararlos con los límites de la aplicación, hay que parsear el sufijo (K, M, G). Laravel no incluye una utilidad para esto, pero la función wp_convert_hr_to_bytes() de WordPress o un helper propio resuelven el problema.
Validar arrays de archivos
Los formularios que permiten subir múltiples archivos usan la sintaxis de comodín *:
$request->validate([
'fotos' => 'required|array|min:1|max:5',
'fotos.*' => 'image|mimes:jpg,png,webp|max:5120',
]);
Sin el comodín, la regla image se aplicaría al array como un todo (y fallaría porque un array no es un archivo). El comodín aplica las reglas a cada elemento del array de forma individual.
Para archivos de distinto tipo (documentos adjuntos en un formulario):
$request->validate([
'adjuntos' => 'required|array|max:10',
'adjuntos.*' => [
'file',
File::types(['pdf', 'docx', 'xlsx', 'jpg', 'png'])
->max('10mb'),
],
]);
Un detalle con old(): cuando la validación falla en un formulario con archivos, Laravel no puede repoblar los campos de tipo file con old(). Los archivos no se flashean a la sesión como los campos de texto. El usuario tiene que volver a seleccionar los archivos. En formularios con muchos campos de texto y uno de archivo, considerar validar el archivo por separado o usar JavaScript para mantener la selección en el cliente.
El formulario HTML para subir múltiples archivos necesita enctype="multipart/form-data" y el atributo multiple en el input:
<form method="POST" action="/galeria" enctype="multipart/form-data">
@csrf
<input type="file" name="fotos[]" multiple accept="image/*">
@error('fotos.*')
<span class="texto-error">{{ $message }}</span>
@enderror
<button type="submit">Subir</button>
</form>
El atributo accept en el HTML filtra archivos en el selector del navegador, pero no sustituye la validación en el servidor – el usuario puede saltarlo con las herramientas del navegador.
El flujo completo: formulario, validación y almacenamiento
Un ejemplo de controlador que valida y almacena una imagen de perfil:
public function actualizarFoto(Request $request)
{
$datos = $request->validate([
'foto' => [
'required',
File::image()
->max('5mb')
->dimensions(
Rule::dimensions()
->minWidth(200)
->minHeight(200)
->maxWidth(2000)
->maxHeight(2000)
),
],
]);
$ruta = $datos['foto']->store('perfiles', 'public');
$request->user()->update(['foto_perfil' => $ruta]);
return back()->with('exito', 'Foto actualizada.');
}
El archivo validado es una instancia de UploadedFile, que tiene métodos como store(), storeAs(), getClientOriginalName() y getClientOriginalExtension(). Después de validate(), el archivo ya pasó todas las comprobaciones de tipo, tamaño y dimensiones.
Un punto sobre seguridad: no usar getClientOriginalName() directamente como nombre de archivo en el servidor. El nombre viene del cliente y puede contener caracteres peligrosos o colisionar con archivos existentes. El método store() genera un nombre aleatorio automáticamente, y storeAs() permite definir uno propio. Si se necesita conservar el nombre original, guardarlo en la base de datos como metadato, no como nombre en disco.
Para gestionar el reemplazo de un archivo existente (por ejemplo, actualizar una foto de perfil), eliminar el archivo anterior antes de guardar el nuevo:
use Illuminate\Support\Facades\Storage;
if ($request->user()->foto_perfil) {
Storage::disk('public')->delete($request->user()->foto_perfil);
}
$ruta = $datos['foto']->store('perfiles', 'public');
$request->user()->update(['foto_perfil' => $ruta]);
Para un Form Request que valide una subida de documentos:
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\File;
class SubirDocumentoRequest extends FormRequest
{
public function rules(): array
{
return [
'titulo' => 'required|string|max:200',
'descripcion' => 'nullable|string|max:1000',
'documento' => [
'required',
File::types(['pdf', 'docx', 'doc'])
->min('1kb')
->max('20mb'),
],
];
}
public function messages(): array
{
return [
'documento.required' => 'Adjunta un documento.',
'documento.max' => 'El documento no puede superar los 20 MB.',
];
}
}
Errores frecuentes
Archivo grande no se sube, pero uno pequeño sí – el límite de upload_max_filesize o post_max_size en php.ini es menor que el archivo. Laravel no recibe el archivo y required falla con un mensaje de “campo obligatorio” en vez de “archivo demasiado grande”. Ajustar php.ini y, en Nginx, client_max_body_size.
mimes no funciona con ciertos vídeos – mimes determina el tipo por el contenido, no por la extensión. Algunos vídeos tienen un MIME type que no coincide con lo esperado (por ejemplo, un .avi detectado como application/octet-stream). Para vídeos, mimetypes:video/avi,video/mpeg,video/mp4,video/quicktime suele ser más fiable.
CSV validado como text/plain – los archivos CSV son texto plano. mimes:csv puede fallar si la detección de contenido devuelve text/plain en vez de text/csv. Usar mimetypes:text/plain,text/csv combinado con extensions:csv.
dimensions rechaza archivos no legibles – la regla dimensions usa getimagesize() del núcleo de PHP. Si el archivo no es una imagen válida o está corrupto, getimagesize() devuelve false y la validación falla. No es necesario instalar GD ni Imagick para esta regla – getimagesize() es una función nativa de PHP.
image rechaza SVG – por defecto, la regla image no acepta SVG debido al riesgo de XSS. Usar image:allow_svg o File::image(allowSvg: true) cuando se necesite aceptar SVG, idealmente combinado con un proceso de sanitización del contenido SVG.
old() no repobla archivos – los campos de tipo file no se pueden repoblar con old() después de un error de validación. Los archivos no se almacenan en la sesión. El usuario tiene que seleccionar el archivo de nuevo. Para mejorar la experiencia, usar JavaScript en el frontend para mantener la referencia al archivo o validar el archivo de forma asíncrona antes de enviar el formulario.
Tamaño en max vs tamaño en File::max() – la regla string max:5000 trabaja en kilobytes. El builder File::max('5mb') también produce max:5000 (usa multiplicadores decimales: 1 mb = 1000 KB). Si se escribe max:5120 pensando en 5 MiB (5 × 1024), el límite no coincide con File::max('5mb'). Para evitar confusiones, elegir un estilo y mantenerlo en todo el proyecto.
Formulario sin enctype="multipart/form-data" – sin este atributo en el tag <form>, el navegador envía el nombre del archivo como texto plano en vez del contenido binario. Laravel recibe un string en lugar de un UploadedFile, y la regla file falla. Este error es común en formularios que se crean copiando de otro y olvidando cambiar el encoding.
Validación condicional en actualización – en un formulario de edición donde el archivo es opcional (el usuario solo lo cambia si quiere), usar nullable o sometimes:
$request->validate([
'foto' => 'nullable|image|max:2048',
]);
if ($request->hasFile('foto')) {
$ruta = $request->file('foto')->store('perfiles', 'public');
$usuario->update(['foto_perfil' => $ruta]);
}
Sin nullable, la regla image falla cuando el campo llega vacío en una actualización donde el usuario no cambió la foto.
Para la lista completa de reglas de validación, consulta la referencia de reglas. La personalización de mensajes de error se cubre en mensajes de error.