wppw.org
enrues
Laravel 13.x · Docs

Validar archivos e imágenes en Laravel 13

Actualizado 20 de mayo de 2026 Laravel 13.x 24 min lectura
En esta página 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 y un MIME type que el sniffer ve como texto plano. Una imagen puede pesar poco y 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, la seguridad del archivo ya almacenado y cómo escribir pruebas de subida con UploadedFile::fake().

Para los conceptos generales de validación, ver la guía de inicio rápido. La validación de arrays de archivos comparte sintaxis con la de arrays JSON. Las reglas condicionales para PATCH se cubren en validación condicional.

Reglas básicas: file, image, mimes, mimetypes, extensions

La regla file comprueba que el campo sea un archivo subido correctamente (una instancia de Illuminate\Http\UploadedFile con isValid() en 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 en la sintaxis string.

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',
]);

Si se necesita aceptar SVG, hay que pedirlo explícitamente con image:allow_svg o, con el builder, File::image(allowSvg: true). La aceptación de SVG conviene ir acompañada de sanitización (por ejemplo, con enshrined/svg-sanitize) antes de exponer el archivo al navegador.

mimes vs mimetypes

Las dos reglas causan confusión habitual. Ambas comprueban el tipo del archivo, pero por caminos distintos.

mimes recibe extensiones cortas 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 clave: ni mimes ni mimetypes verifican que la extensión asignada por el usuario coincida con el contenido. Un archivo llamado factura.txt cuyo contenido sea un PDF válido pasará mimes:pdf. Si se quiere 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 declarada por el cliente, sin leer el contenido. La documentación de Laravel advierte que extensions no debe usarse sola como única validación de tipo. Su lugar es complementar a mimes o mimetypes cuando interesa que ambos coincidan.

Cuándo usar cada una

Para la mayoría de los formatos comunes (PDF, imágenes, documentos de Office) mimes es suficiente porque recibe extensiones cortas y devuelve mensajes legibles. mimetypes es preferible cuando se necesita aceptar MIMEs concretos que mimes:ext no cubre: por ejemplo, un .mov que el guesser detecta como video/quicktime se acepta con mimetypes:video/quicktime aunque la lista canónica detrás de mimes:mov sea distinta. Si el guesser cae en un MIME totalmente genérico (un .avi antiguo que llega como application/octet-stream), ninguna de las dos reglas pasa sin listar application/octet-stream, lo que abriría la puerta a cualquier binario; en ese caso es mejor rechazar el archivo o pedir un formato más estándar. Un ejemplo combinado para una zona crítica:

$request->validate([
    'certificado' => [
        'required',
        'file',
        'mimes:pdf',
        'mimetypes:application/pdf',
        'extensions:pdf',
        'max:5120',
    ],
]);

En la práctica mimes:pdf + extensions:pdf cubre el 90 % de los casos. Apilar mimetypes:application/pdf encima no aporta detección adicional, porque mimes y mimetypes consultan el mismo guesser de Symfony y la lista detrás de mimes:pdf ya se reduce a application/pdf. Para defensa real en flujos críticos (firma electrónica, ingestión que abre el archivo), hay que abrir el archivo con un parser específico del formato.

MIME types problemáticos: Excel, CSV, vídeo, SVG

Algunos formatos tienen MIME types poco intuitivos que causan fallos inesperados:

FormatoExtensiónMIME type
Excel moderno (Open XML).xlsxapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheet
Excel antiguo.xlsapplication/vnd.ms-excel
Word moderno.docxapplication/vnd.openxmlformats-officedocument.wordprocessingml.document
Word antiguo.docapplication/msword
CSV.csvtext/csv o text/plain (variable por sistema)
SVG.svgimage/svg+xml

Excel y otros documentos de Office

Para .xlsx y .docx no hay que recordar los MIME types completos: la regla mimes se encarga del mapeo. Conviene aceptar tanto el formato moderno (OpenXML) como el antiguo:

$request->validate([
    'hoja' => 'required|file|mimes:xlsx,xls,csv|max:5120',
    'informe' => 'required|file|mimes:pdf,docx,doc|max:10240',
]);

Si se necesita más control con MIME types completos (por ejemplo, para rechazar .xls antiguos), usar mimetypes:

$request->validate([
    'hoja' => [
        'required',
        'file',
        'mimetypes:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
    ],
]);

CSV

CSV es texto plano sin firma binaria; el sniffer de PHP puede devolver text/plain, text/csv o application/csv según el sistema operativo y la librería detectora. mimes:csv admite las variantes habituales, pero si una subida válida está fallando, conviene mirar qué MIME devuelve realmente mime_content_type() o Symfony\Component\Mime\MimeTypes sobre el archivo, y abrir la lista con mimetypes:

$request->validate([
    'datos' => [
        'required',
        'file',
        'mimetypes:text/csv,text/plain,application/csv',
        'extensions:csv',
        'max:20480',
    ],
]);

Combinar con extensions:csv evita que se cuele cualquier archivo de texto con un nombre arbitrario.

Vídeo

La detección por contenido de archivos de vídeo es donde más se rompe mimes. Un .avi puede llegar como application/octet-stream, un .mov como video/quicktime, y el navegador a veces envía un MIME genérico. La regla mimetypes con una lista amplia es lo más robusto:

$request->validate([
    'video' => [
        'required',
        'file',
        'mimetypes:video/mp4,video/mpeg,video/quicktime,video/x-msvideo,video/webm',
        'max:204800', // 200 MB
    ],
]);

Para vídeos pesados conviene también revisar los límites de PHP y del proxy reverso (ver más abajo).

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 encadena todas las restricciones de un archivo en un objeto, lo que facilita la lectura cuando hay varias condiciones. La versión equivalente en string sería: 'required|image|max:10000|dimensions:max_width=4000,max_height=4000' (10000 KB porque, como se ve a continuación, File::max('10mb') usa multiplicadores decimales).

Hay un detalle de unidades. La regla string max:5000 significa 5000 KB (5 000 × 1024 bytes). El builder File::max('5mb') usa multiplicadores decimales: 1 mb = 1000 kb, así que File::max('5mb') equivale a max:5000. Para File::max('5120kb') el resultado es idéntico a max:5120. La diferencia entre 5000 y 5120 es de 120 KB, irrelevante en la práctica, pero conviene saberlo si se mezclan ambos formatos en un mismo proyecto y se comparan límites.

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 igual que la regla image.

Validar dimensiones de imagen

Además del peso en bytes, a menudo se necesita controlar el tamaño en píxeles. La regla dimensions permite restringir 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),
    ],
]);

Métodos disponibles en Rule::dimensions(): width(), height() (valores exactos), minWidth(), maxWidth(), minHeight(), maxHeight() y ratio().

El ratio se especifica como fracción o como float:

// Ratio 16:9
Rule::dimensions()->ratio(16 / 9)

// Equivalente en string
'dimensions:ratio=16/9'

En formato string, los parámetros van separados por comas dentro de la regla:

'avatar' => 'required|image|dimensions:min_width=100,min_height=100,ratio=1/1'

La regla dimensions se apoya en getimagesize(), una función nativa de PHP que lee la cabecera del archivo. No depende de GD ni Imagick: basta con PHP instalado. Si el archivo no es una imagen válida o la cabecera está corrupta, getimagesize() devuelve false y la validación falla.

Hay una excepción importante: para archivos con MIME image/svg+xml o image/svg la regla dimensions hace early-return y devuelve true sin comprobar nada. Es decir, si se acepta SVG (image:allow_svg o File::image(allowSvg: true)), dimensions no controla sus dimensiones: hay que parsear el SVG por separado o restringir el tamaño en bytes con max:N y aceptar el riesgo.

Un uso habitual de dimensions es exigir un formato cuadrado para avatares:

$request->validate([
    'avatar' => [
        'required',
        'image',
        'max:2048',
        Rule::dimensions()
            ->minWidth(200)
            ->minHeight(200)
            ->ratio(1 / 1),
    ],
]);

ratio aplica una tolerancia interna: precision = 1 / (max(ancho, alto) + 1), y la regla falla si la desviación entre ratio_pedido y ancho/alto supera ese valor. La tolerancia es tan pequeña que en la práctica ratio(1/1) se comporta como coincidencia exacta: una imagen de 500x501 ya excede 1/502 ≈ 0,00199 (su desviación es ≈ 0,00200) y se rechaza. La tolerancia sólo da margen perceptible con ratios fraccionarios irregulares (por ejemplo, Rule::dimensions()->ratio(16 / 9) acepta tanto 1920x1080 como 1280x720 aunque ambos tengan microerror de coma flotante). Para flujos donde exigir cuadrado exacto resulta hostil al usuario, conviene recortar la imagen en el servidor tras validar image + max:N y prescindir de ratio() en la regla.

Recipe: foto de perfil con validación, recorte y almacenamiento

Un controlador completo que valida, recorta a cuadrado y guarda en un disco separado:

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\File;
use Intervention\Image\Laravel\Facades\Image;

public function actualizarFoto(Request $request)
{
    $datos = $request->validate([
        'foto' => [
            'required',
            File::image()
                ->max('5mb')
                ->dimensions(
                    Rule::dimensions()
                        ->minWidth(200)
                        ->minHeight(200)
                        ->maxWidth(4000)
                        ->maxHeight(4000)
                ),
        ],
    ]);

    // Recorte cuadrado centrado antes de guardar evita servir imágenes
    // de 4000x4000 desde el avatar de 64px del header.
    $imagen = Image::read($datos['foto'])->cover(512, 512)->toJpeg(85);

    $ruta = 'avatares/'.$request->user()->id.'.jpg';
    Storage::disk('public')->put($ruta, (string) $imagen);

    $usuario = $request->user();
    if ($usuario->foto_perfil && $usuario->foto_perfil !== $ruta) {
        Storage::disk('public')->delete($usuario->foto_perfil);
    }
    $usuario->update(['foto_perfil' => $ruta]);

    return back()->with('exito', 'Foto actualizada.');
}

El archivo validado es una instancia de UploadedFile, que expone store(), storeAs(), getClientOriginalName(), getClientOriginalExtension() y getMimeType(). Después de validate(), el archivo ya pasó las comprobaciones de tipo, peso y dimensiones declaradas.

Un punto sobre seguridad de nombres: no usar getClientOriginalName() directamente como nombre en disco. El nombre viene del cliente y puede contener separadores de path, caracteres de control o colisionar con archivos existentes. store() genera un nombre aleatorio automáticamente y storeAs() permite definir uno propio (en el ejemplo de arriba se usa el ID del usuario, que está bajo control de la aplicación). Si interesa conservar el nombre original para mostrarlo, guardarlo en la base de datos como metadato, nunca como nombre real en disco.

Tamaños máximos y los límites de php.ini

Las reglas max y size operan a nivel de Laravel, pero PHP tiene sus propios límites que actúan antes. Si el archivo excede esos límites, PHP lo descarta antes de que Laravel lo procese y la validación de la aplicación no llega a ejecutarse.

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 escribe la entrada en $_FILES con el código de error UPLOAD_ERR_INI_SIZE y descarta el contenido. Laravel ve el campo como “no subido” (hasFile() devuelve false) y aplica las reglas: si la lista incluye required, falla con “el campo es obligatorio”; con sólo file o image, falla con esos mensajes. El usuario recibe un texto que no menciona el límite real y no entiende por qué se le pide un archivo que acaba de seleccionar.

Para detectar el caso y devolver un mensaje claro hay que mirar $_FILES antes de la validación, porque PHP escribe ahí los códigos de error aunque el contenido se haya descartado:

public function subir(Request $request)
{
    // UPLOAD_ERR_INI_SIZE = 1: el archivo excede upload_max_filesize.
    // UPLOAD_ERR_FORM_SIZE = 2: excede MAX_FILE_SIZE del form HTML.
    if (($_FILES['archivo']['error'] ?? UPLOAD_ERR_OK) === UPLOAD_ERR_INI_SIZE) {
        return back()->withErrors([
            'archivo' => 'El archivo supera el máximo permitido por el servidor ('
                .ini_get('upload_max_filesize').').',
        ]);
    }

    $request->validate([
        'archivo' => 'required|file|max:20480',
    ]);

    // ...
}

Recomendación general: poner upload_max_filesize un poco por encima del máximo que la aplicación acepta a nivel de Laravel, para que sea la regla max la que rechace con mensaje de aplicación y no PHP con un descarte silencioso. Si la regla permite hasta 20 MB, upload_max_filesize en 21M ya basta (la directiva se aplica al tamaño de cada archivo individual en el upload multipart).

post_max_size es lo que necesita un margen más generoso. Se aplica al body POST completo: la suma de todos los archivos del request más los campos de texto más los boundaries multipart. Con un formulario que permita hasta 10 archivos de 20 MB, post_max_size debe ser claramente mayor (por ejemplo, 220M).

En servidores con Nginx hay un tercer límite: client_max_body_size en la configuración del servidor o location. Sin ese ajuste, Nginx devuelve un 413 antes de que la petición llegue a PHP. En Apache con mod_security, comprobar también SecRequestBodyLimit.

Para verificar los límites activos desde la aplicación (útil en un health-check):

$maxUpload = ini_get('upload_max_filesize');
$maxPost = ini_get('post_max_size');
$maxFiles = ini_get('max_file_uploads');

Estos valores se leen como strings con sufijo ('2M', '128M', '1G'). Para compararlos con los límites de la aplicación hay que parsearlos a bytes primero; Laravel no incluye una utilidad para ese camino, así que un helper propio resuelve el caso:

function ini_a_bytes(string $val): int {
    $num = (int) $val;
    return match (strtoupper(substr($val, -1))) {
        'G' => $num * 1024 ** 3,
        'M' => $num * 1024 ** 2,
        'K' => $num * 1024,
        default => $num,
    };
}

Para el camino inverso (formatear un número en bytes como 2 MB legible en un dashboard), Illuminate\Support\Number::fileSize() ya hace el trabajo.

Seguridad de los archivos subidos

Pasar la validación es solo el primer paso. Lo que se hace después con el archivo importa tanto como las reglas.

No guardar archivos subidos con nombre o extensión que controle el usuario. Storage de Laravel separa storage/app (no público) y storage/app/public (expuesto vía symlink en public/storage). Si el handler de PHP del servidor está configurado para ejecutar cualquier .php bajo la raíz pública (configuración FastCGI común aunque no universal, especialmente en hostings compartidos), un .php dejado por un usuario en storage/app/public se ejecuta. La configuración oficial recomendada por Laravel para Nginx restringe PHP a index.php con try_files, lo que mitiga este caso, pero conviene verificar la propia. Independientemente del servidor, la regla práctica es generar el nombre y la extensión en la aplicación, no aceptar los del cliente: store() lo hace por defecto (hash aleatorio + extensión inferida del MIME); storeAs($directorio, $nombreCliente) o $request->file('x')->move(public_path('uploads'), $request->file('x')->getClientOriginalName()) rompen esa garantía.

// Bien: store() genera nombre y extensión seguros
$ruta = $datos['foto']->store('avatares', 'public');

// Mal: el cliente decide nombre y extensión; un .php se ejecuta
move_uploaded_file($_FILES['foto']['tmp_name'], public_path('uploads/'.$nombre));

Forzar el Content-Type al servir. Los navegadores aplican content-sniffing sobre respuestas y en algunos casos pueden ignorar el Content-Type declarado, lo que puede convertir un archivo subido por el usuario en algo ejecutable en el contexto del navegador. La cabecera X-Content-Type-Options: nosniff desactiva ese comportamiento y obliga al navegador a respetar el Content-Type enviado. Configurarla globalmente vía middleware o web server.

SVG: sanitizar o rechazar. Si la aplicación acepta SVG (image:allow_svg), pasar el contenido por un sanitizador antes de guardarlo. Un SVG puede contener <script>, onload, y referencias a recursos externos. Para un avatar, prohibir SVG; para un editor de iconos, sanitizar.

No confiar en getClientOriginalName() ni en getMimeType() para decidir lógica sensible. getMimeType() lee el contenido (más fiable que el cliente), pero la detección es heurística. Tanto mimes como mimetypes consultan el mismo guesser de Symfony por debajo, así que combinar las dos reglas no añade una segunda fuente independiente; sí ayuda añadir extensions (chequea la extensión declarada) y, para flujos críticos como firma electrónica o ingestión de PDF, abrir el archivo con un parser real (un lector de PDF, un decodificador de imagen) antes de aceptarlo.

Disco separado por confianza. Para uploads de usuarios anónimos, conviene un disco S3 distinto al de assets internos, con permisos que prohíban listar el bucket y con un CDN delante. Eso aísla un eventual leak de URLs y permite cuotas por usuario.

Validar arrays de archivos

Los formularios que permiten subir múltiples archivos usan el 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. Los mensajes de error vienen indexados (fotos.0, fotos.1), lo que permite señalar exactamente cuál falló.

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 file con old(). Los archivos no se flashean a la sesión. El usuario tiene que volver a seleccionar los archivos. En formularios mixtos conviene validar el archivo en una petición AJAX previa o usar JavaScript para mantener la selección en el cliente entre intentos.

El formulario HTML para múltiples archivos necesita enctype="multipart/form-data" y 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 filtra archivos en el selector del navegador, pero no sustituye la validación en el servidor: el usuario puede saltarlo con las herramientas de desarrollo o subiendo desde curl.

Validación condicional: opcional en PATCH, required_without

En un formulario de edición donde el archivo solo se sube si el usuario quiere cambiarlo, nullable permite que el campo llegue vacío sin que image o file fallen:

$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, las reglas tipadas fallan cuando el campo llega vacío porque null no es un archivo. La alternativa sometimes solo aplica las reglas si el campo está presente en el request, lo que es útil para PATCH donde el cliente envía solo los campos que cambia:

$request->validate([
    'foto' => 'sometimes|required|image|max:2048',
]);

sometimes|required se traduce como: “si llega, debe estar presente y ser una imagen”. Si no llega, no se valida.

Para flujos donde el archivo es opcional pero al menos uno de varios campos debe estar presente, required_without cubre el caso:

// Al editar un producto, se exige al menos una imagen nueva
// o conservar la existente (que el frontend envía como url_existente).
$request->validate([
    // nullable es necesario: si el usuario solo envía url_existente,
    // imagen llega como null y la regla image fallaría sin nullable,
    // porque required_without solo decide presencia, no exime de las
    // reglas tipadas posteriores.
    'imagen' => 'nullable|required_without:url_existente|image|max:5120',
    'url_existente' => 'nullable|required_without:imagen|string|url',
]);

Más patrones condicionales en validación condicional.

Form Request completo para una subida

Para mantener el controlador limpio, mover las reglas y los mensajes a un Form Request:

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\File;

class SubirDocumentoRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()?->can('subir-documentos') ?? false;
    }

    public function rules(): array
    {
        return [
            'titulo' => 'required|string|max:200',
            'descripcion' => 'nullable|string|max:1000',
            'categoria' => 'required|in:contrato,factura,otro',
            'documento' => [
                'required',
                File::types(['pdf', 'docx', 'doc'])
                    ->min('1kb')
                    ->max('20mb'),
                'extensions:pdf,docx,doc',
            ],
        ];
    }

    public function messages(): array
    {
        return [
            'documento.required' => 'Adjunta un documento.',
            'documento.max' => 'El documento no puede superar los 20 MB.',
            'documento.mimes' => 'Solo se aceptan PDF y Word.',
        ];
    }

    public function attributes(): array
    {
        return [
            'documento' => 'archivo',
        ];
    }
}

El controlador queda mínimo:

public function store(SubirDocumentoRequest $request)
{
    $datos = $request->validated();

    $ruta = $datos['documento']->store('documentos', 'private');

    Documento::create([
        'titulo' => $datos['titulo'],
        'descripcion' => $datos['descripcion'],
        'categoria' => $datos['categoria'],
        'ruta' => $ruta,
        'user_id' => $request->user()->id,
    ]);

    return redirect()->route('documentos.index');
}

Más sobre authorize(), prepareForValidation() y métodos after en Form Requests. La personalización avanzada de los mensajes está en mensajes de error.

Personalizar mensajes para reglas de archivo

Los mensajes por defecto de las reglas de archivo viven en lang/<locale>/validation.php. La clave especial file agrupa los mensajes según el tipo del valor bajo validación, porque reglas como max, min y size aceptan tanto archivos como strings, números o arrays:

// lang/es/validation.php
'max' => [
    'array' => 'El campo :attribute no puede tener más de :max elementos.',
    'file' => 'El campo :attribute no puede pesar más de :max kilobytes.',
    'numeric' => 'El campo :attribute no puede ser mayor que :max.',
    'string' => 'El campo :attribute no puede tener más de :max caracteres.',
],

Laravel elige la variante file cuando el valor es un UploadedFile. Para mensajes específicos a un campo concreto, usar el array custom:

'custom' => [
    'documento' => [
        'mimes' => 'El documento debe ser PDF o Word.',
        'max' => 'El documento no puede superar los 20 MB.',
    ],
],

Estos sobrescriben al mensaje genérico. Para subir nombres de campo en mensajes con :attribute, llenar attributes:

'attributes' => [
    'documento' => 'archivo del contrato',
    'foto' => 'foto de perfil',
],

Pruebas: simular subidas con UploadedFile::fake()

Las pruebas de subida no necesitan archivos reales en disco. Illuminate\Http\UploadedFile::fake() genera archivos en memoria que se comportan como uploads reales para la validación y el almacenamiento.

use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;

public function test_un_usuario_puede_actualizar_su_foto_de_perfil(): void
{
    Storage::fake('public');

    $usuario = User::factory()->create();
    $foto = UploadedFile::fake()->image('avatar.jpg', 800, 800);

    $this->actingAs($usuario)
        ->post('/perfil/foto', ['foto' => $foto])
        ->assertRedirect()
        ->assertSessionHasNoErrors();

    Storage::disk('public')->assertExists('avatares/'.$usuario->id.'.jpg');
}

UploadedFile::fake()->image('avatar.jpg', 800, 800) crea una imagen JPG válida de 800x800 en memoria. El método usa GD por debajo, así que el entorno de tests (incluido el contenedor de CI) necesita la extensión gd habilitada; sin ella, fake()->image() lanza LogicException con mensaje GD extension is not installed.. La validación con image y dimensions la trata como una imagen real. Para forzar un tamaño concreto (en KB) está ->size():

$grande = UploadedFile::fake()->image('foto.jpg')->size(6000); // 6 MB

$this->post('/perfil/foto', ['foto' => $grande])
    ->assertSessionHasErrors(['foto']);

Para tipos no-imagen está create(), que recibe nombre, tamaño en KB y opcionalmente MIME type:

$pdf = UploadedFile::fake()->create('contrato.pdf', 100, 'application/pdf');
$csv = UploadedFile::fake()->create('datos.csv', 50, 'text/csv');

$this->post('/documentos', ['documento' => $pdf])
    ->assertSessionHasNoErrors();

Importante: UploadedFile::fake()->create('foo.pdf', 100, 'application/pdf') no escribe un PDF real, solo un archivo de 100 KB vacío con el MIME type que se le pasa. Tanto mimes como mimetypes consultan el mismo guesser de Symfony, así que ninguna de las dos es fiable sobre contenido vacío: el guesser puede caer en su fallback de extensión o devolver application/x-empty según la implementación. Para reglas que dependen del contenido (mimes:pdf, mimetypes:application/pdf) la única forma robusta en tests es usar un fixture real:

$pdf = new UploadedFile(
    base_path('tests/fixtures/contrato.pdf'),
    'contrato.pdf',
    'application/pdf',
    null,
    true // test mode
);

Storage::fake('public') redirecciona el disco public a un directorio temporal que se limpia al final del test. Storage::disk('public')->assertExists() y assertMissing() verifican que los métodos store()/storeAs() se ejecutaron como se esperaba.

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 “campo obligatorio” en vez de “archivo demasiado grande”. Ajustar php.ini y, en Nginx, client_max_body_size.

mimes rechaza vídeos válidos. mimes determina el tipo por el contenido. Algunos contenedores de vídeo se detectan como application/octet-stream. Para vídeos, mimetypes con una lista amplia (video/mp4,video/mpeg,video/quicktime,video/x-msvideo,video/webm) es más fiable que mimes.

CSV validado como text/plain. Los CSV son texto plano y el sniffer puede devolver text/plain en lugar de text/csv. Usar mimetypes:text/csv,text/plain,application/csv combinado con extensions:csv.

dimensions rechaza archivos no legibles. La regla dimensions se apoya en getimagesize(), función nativa de PHP. No necesita GD ni Imagick. Si el archivo no es una imagen válida o está corrupto, getimagesize() devuelve false y la validación falla con mensaje claro.

image rechaza SVG. Por defecto, image no acepta SVG por el riesgo de XSS. Usar image:allow_svg o File::image(allowSvg: true), idealmente combinado con un sanitizador que elimine <script>, onload y referencias externas antes de guardar.

old() no repobla archivos. Los campos file no se almacenan en la sesión flash. El usuario tiene que reseleccionar el archivo tras un error de validación. Para suavizar el flujo, validar el archivo asíncronamente antes de enviar el formulario completo o mantener el blob en el cliente.

Tamaño en max vs tamaño en File::max(). La regla string max:5000 significa 5000 KB. El builder File::max('5mb') también produce el equivalente a max:5000 (1 mb = 1000 kb decimal). Si se escribe max:5120 esperando 5 MiB, el límite no coincide con File::max('5mb'). Elegir un estilo y mantenerlo.

Formulario sin enctype="multipart/form-data". Sin ese atributo, el navegador envía el nombre del archivo como texto plano. Laravel recibe un string en lugar de un UploadedFile y la regla file falla. Error común en formularios copiados de otro.

hasFile() devuelve false con un archivo presente. Suele ser el mismo encoding del form, o que el archivo excedió el límite de PHP y se descartó. Comprobar $_FILES['campo']['error'] para distinguir.

Validar archivos dentro de Livewire. Livewire envuelve el upload en una TemporaryUploadedFile que extiende UploadedFile y conserva el contenido en el disco temporal de Livewire (por defecto livewire-tmp). Las reglas file, mimes, mimetypes y dimensions funcionan igual sobre este objeto que sobre un upload directo. Si el archivo se está validando como propiedad del componente, comprobar la propia documentación de Livewire para la directiva WithFileUploads, porque el ciclo de vida (upload temporal, validación, finalización con storeAs()) cambia el momento en que se aplican las reglas.

Para la lista completa de reglas de validación, ver la referencia de reglas. Si la regla de archivo necesaria no existe, conviene escribirla como regla personalizada en vez de hackear mimetypes.

¿Te ha resultado útil este artículo?

Reportar un error