Curso de Laravel
Subida de archivos en Laravel: imágenes y documentos
Por Víctor Peña · Publicado el
Hola, ¿cómo están? Casi todo sistema de gestión termina necesitando subir archivos: la foto del paciente, el PDF de un examen, el logotipo de la empresa.
Laravel lo resuelve bastante bien, pero hay tres o cuatro detalles que, si no se conocen, hacen perder una tarde entera. Vamos a verlos todos.
¡Empecemos!
El formulario
<form method="POST" action="{{ route('pacientes.store') }}" enctype="multipart/form-data">
@csrf
<x-campo nombre="nombre" etiqueta="Nombre" requerido />
<div class="campo">
<label for="foto">Fotografía</label>
<input type="file" name="foto" id="foto" accept="image/*">
@error('foto')
<span class="mensaje-error">{{ $message }}</span>
@enderror
</div>
<button type="submit">Guardar</button>
</form>
enctype="multipart/form-data" es lo primero que hay que retener. Sin ese atributo el archivo no se envía, y lo desconcertante es que no da ningún error: el formulario funciona, se guarda todo, y el archivo simplemente no llega. Es el fallo número uno de esta lección.
Validar el archivo
$datos = $request->validate([
'nombre' => ['required', 'string', 'max:100'],
'foto' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
]);
Tres reglas, y conviene entender qué hace cada una:
imagecomprueba que sea una imagen de verdad, no solo que la extensión lo parezca.mimeslimita los formatos aceptados.max:2048son kilobytes, no bytes ni megabytes. Ahí son 2 MB.
Ese max en kilobytes es la segunda confusión clásica. max:2 no son dos megas: son dos kilobytes, y rechaza absolutamente todo.
Para documentos:
'examen' => ['required', 'file', 'mimes:pdf,doc,docx', 'max:10240'],
Y para controlar las dimensiones de una imagen:
'foto' => ['image', 'dimensions:min_width=200,min_height=200,max_width=4000'],
'logo' => ['image', 'dimensions:ratio=1/1'],
El límite real lo pone PHP
Aunque pongas max:10240, si php.ini tiene upload_max_filesize = 2M, un archivo de 5 MB no llega siquiera a la validación: PHP lo descarta antes, y el campo aparece vacío como si el usuario no hubiera subido nada.
Los valores a revisar:
upload_max_filesize = 20M
post_max_size = 25M
max_execution_time = 120
post_max_size debe ser mayor que upload_max_filesize, porque incluye el resto del formulario.
Si usas Laragon o XAMPP, esos valores están en el php.ini de la instalación, y hay que reiniciar Apache después de cambiarlos.
Guardar el archivo
public function store(GuardarPacienteRequest $request)
{
$datos = $request->validated();
if ($request->hasFile('foto')) {
$datos['foto'] = $request->file('foto')->store('pacientes', 'public');
}
Paciente::create($datos);
return redirect()->route('pacientes.index')->with('exito', 'Paciente registrado');
}
Ese store() hace tres cosas de una vez: mueve el archivo a storage/app/public/pacientes/, le pone un nombre aleatorio único y devuelve la ruta relativa, algo como pacientes/kJ8sd92mNq.jpg.
Ese nombre aleatorio es una funcionalidad, no un inconveniente. Evita que dos archivos llamados foto.jpg se pisen, y evita que alguien suba un archivo con un nombre pensado para hacer daño.
En la base de datos guardamos la ruta, no el archivo:
$tabla->string('foto')->nullable();
Guardar imágenes dentro de la base de datos es posible y casi siempre una mala idea: infla los respaldos, complica la caché y ralentiza todas las consultas.
Con nombre propio
Si necesitas controlar el nombre:
$nombre = $paciente->cedula . '.' . $request->file('foto')->extension();
$ruta = $request->file('foto')->storeAs('pacientes', $nombre, 'public');
Nunca uses el nombre original del usuario sin limpiarlo. Un nombre como ../../../.env es un intento real de escapar de la carpeta.
$nombre = Str::slug(pathinfo($original, PATHINFO_FILENAME))
. '-' . Str::random(8) . '.' . $archivo->extension();
Los discos de almacenamiento
Laravel organiza el almacenamiento en «discos», definidos en config/filesystems.php:
local→storage/app/private/. No accesible desde el navegador. Para documentos que requieren permiso.public→storage/app/public/. Pensado para archivos que se muestran.s3y otros → almacenamiento en la nube.
Storage::disk('public')->put('carpeta/archivo.jpg', $contenido);
Storage::disk('local')->get('contratos/contrato.pdf');
Lo bueno es que el código no cambia al cambiar de disco. Un proyecto que guarda en local puede pasar a la nube modificando la configuración, sin tocar los controladores.
storage:link: el paso que todos olvidan
Aquí está el tercer tropiezo clásico.
Los archivos del disco public viven en storage/app/public/, pero el navegador solo puede ver lo que está dentro de public/. Sin un puente entre ambos, todas las imágenes salen rotas.
php artisan storage:link
Eso crea un enlace simbólico de public/storage a storage/app/public. Es un comando que se ejecuta una vez por instalación, y hay que recordarlo también al desplegar en el servidor: es la causa más frecuente de «en local se veían y en producción no».
Mostrar el archivo
<img src="{{ asset('storage/' . $paciente->foto) }}" alt="{{ $paciente->nombre }}">
O usando el helper del disco:
<img src="{{ Storage::url($paciente->foto) }}" alt="...">
Con una imagen por defecto cuando no hay foto:
<img src="{{ $paciente->foto
? Storage::url($paciente->foto)
: asset('img/sin-foto.png') }}" alt="{{ $paciente->nombre }}">
Y para no repetir eso en cada vista, un accesor en el modelo:
protected function urlFoto(): Attribute
{
return Attribute::get(fn () => $this->foto
? Storage::url($this->foto)
: asset('img/sin-foto.png'));
}
<img src="{{ $paciente->url_foto }}" alt="{{ $paciente->nombre }}">
Reemplazar un archivo al editar
public function update(ActualizarPacienteRequest $request, Paciente $paciente)
{
$datos = $request->validated();
if ($request->hasFile('foto')) {
if ($paciente->foto) {
Storage::disk('public')->delete($paciente->foto);
}
$datos['foto'] = $request->file('foto')->store('pacientes', 'public');
}
$paciente->update($datos);
return redirect()->route('pacientes.show', $paciente)->with('exito', 'Datos actualizados');
}
Ese borrado del anterior importa: sin él, cada edición deja un archivo huérfano y la carpeta crece sin control. En un sistema con años de uso, eso son gigabytes de basura.
Y fíjate en que solo entramos al if si vino un archivo nuevo. Sin esa comprobación, editar el teléfono de un paciente le borraría la foto.
Eliminar el archivo al eliminar el registro
La forma limpia es un evento del modelo:
protected static function booted(): void
{
static::deleting(function (Paciente $paciente) {
if ($paciente->foto && ! $paciente->isForceDeleting()) {
return; // borrado lógico: conservamos el archivo
}
if ($paciente->foto) {
Storage::disk('public')->delete($paciente->foto);
}
});
}
Ese detalle se pasa por alto: si usas SoftDeletes y borras el archivo, restaurar el registro deja una foto rota para siempre.
Varios archivos a la vez
<input type="file" name="documentos[]" multiple>
$request->validate([
'documentos' => ['required', 'array', 'max:5'],
'documentos.*' => ['file', 'mimes:pdf,jpg,png', 'max:5120'],
]);
foreach ($request->file('documentos') as $documento) {
$cita->documentos()->create([
'ruta' => $documento->store('citas/' . $cita->id, 'public'),
'nombre_original' => $documento->getClientOriginalName(),
'tamano' => $documento->getSize(),
'tipo' => $documento->getMimeType(),
]);
}
Guardar el nombre original en una columna aparte permite mostrárselo al usuario, mientras el archivo real conserva su nombre aleatorio seguro.
Archivos privados
Para documentos que no debe ver cualquiera —una historia clínica, un contrato— no uses el disco público. Cualquiera con la URL podría abrirlos, y esas URLs terminan compartidas.
Guárdalos en el disco privado y sírvelos desde un controlador que compruebe permisos:
$ruta = $request->file('examen')->store('examenes', 'local');
public function descargar(Examen $examen)
{
$this->authorize('view', $examen);
return Storage::disk('local')->download($examen->ruta, $examen->nombre_original);
}
Ese authorize() es todo el sentido de hacerlo así. Es la autorización de la lección anterior aplicada a un archivo.
Para mostrarlo en el navegador en lugar de descargarlo:
return Storage::disk('local')->response($examen->ruta);
Métodos útiles del archivo
$archivo = $request->file('foto');
$archivo->getClientOriginalName();
$archivo->getClientOriginalExtension();
$archivo->extension(); // deducida del contenido real
$archivo->getSize(); // bytes
$archivo->getMimeType();
$archivo->isValid();
extension() y getClientOriginalExtension() no son lo mismo. El primero mira el contenido del archivo; el segundo se fía del nombre que envió el usuario. Usa el primero.
Y del almacenamiento
Storage::disk('public')->exists('pacientes/foto.jpg');
Storage::disk('public')->size('pacientes/foto.jpg');
Storage::disk('public')->delete('pacientes/foto.jpg');
Storage::disk('public')->files('pacientes');
Storage::disk('public')->deleteDirectory('citas/15');
Comprimir imágenes
Una foto de celular pesa varios megas. Guardarla tal cual hace que el listado tarde una eternidad en cargar.
Con la biblioteca de manipulación de imágenes que prefieras:
$imagen = $manager->read($request->file('foto'))
->scaleDown(width: 800)
->toWebp(quality: 80);
Storage::disk('public')->put("pacientes/{$nombre}.webp", $imagen);
Vale mucho la pena. Reducir a 800 píxeles de ancho y convertir a WebP suele dejar un archivo de 3 MB en menos de 100 KB, sin diferencia visible en pantalla.
Y si el proceso tarda, es un candidato perfecto para las colas que veremos más adelante.
Errores comunes
- Olvidar
enctype="multipart/form-data"y no recibir nada, sin ningún error. - No ejecutar
storage:link, sobre todo al desplegar. - Interpretar
max:2048como bytes o megabytes. - No revisar
upload_max_filesizede PHP. - No borrar el archivo anterior al reemplazarlo.
- Borrar la foto al editar cualquier otro campo.
- Guardar documentos privados en el disco público.
- Confiar en el nombre original del archivo.
Para cerrar
Subir archivos son cuatro líneas de código y cuatro detalles que hay que conocer: el enctype, storage:link, max en kilobytes y el límite de PHP.
Y una decisión importante en cada caso: público o privado. Si el archivo no debe verlo cualquiera, no puede estar en el disco público, por mucho que la ruta sea difícil de adivinar.
En la siguiente lección veremos el envío de correos.
Saludos y éxitos.
Norvic Software
Desarrollamos el software que tu empresa necesita
Somos una fábrica de software en Bolivia. Construimos sistemas a medida y aplicaciones móviles, y llevamos Inteligencia Artificial a las empresas que ya tienen un sistema funcionando.
- Chatbots con Inteligencia Artificial
- Creación de agentes de IA
- Integración de IA en tus sistemas
- Desarrollo de software a medida
- Aplicaciones móviles iOS y Android
- Consultoría y asesoramiento técnico
Cotización sin costo · Respuesta directa por WhatsApp