Tema
Subida de archivos y Storage
Fotos de perfil, PDFs de facturas, galerías de productos, documentos de clientes: casi cualquier proyecto real maneja archivos. En esta lección aprenderás a subir, guardar, mostrar, descargar y borrar archivos con el sistema Storage de Laravel, que funciona igual en tu disco local que en Amazon S3.
Concepto
Laravel separa dónde se guardan los archivos de cómo los manejas en el código. Cada lugar de almacenamiento es un disco (disk):
| Disco | Carpeta | ¿Accesible desde el navegador? | Uso |
|---|---|---|---|
local | storage/app/private (Laravel 11+) | No | Documentos privados (contratos, facturas) |
public | storage/app/public | Sí, vía enlace simbólico public/storage | Avatares, imágenes de productos |
s3 | Bucket de Amazon S3 (o compatible) | Según configuración | Producción con mucho tráfico |
Es como tener cajones distintos: uno con llave (privado) y uno en el escaparate (público). Tu código dice "guarda en el cajón público" y Laravel sabe dónde está ese cajón.
La configuración vive en config/filesystems.php y el disco por defecto se elige con FILESYSTEM_DISK en .env.
Paso 1: el enlace simbólico
Los archivos de storage/app/public no son accesibles por la web (la raíz pública es public/). Hay que crear un acceso directo:
bash
php artisan storage:linkEsto crea public/storage → storage/app/public. Un archivo guardado como avatars/foto.jpg en el disco public queda accesible en https://tusitio.com/storage/avatars/foto.jpg.
Error común
Subir imágenes y que salgan rotas (404). El 90% de las veces falta php artisan storage:link, o APP_URL en .env no coincide con la URL real (por ejemplo, http://localhost cuando usas http://miapp.test).
Ejemplo: avatar de usuario
Migración (columna para guardar la ruta, no el archivo):
php
Schema::table('users', function (Blueprint $table) {
$table->string('avatar_path')->nullable();
});Formulario (fíjate en enctype, sin él no se envía el archivo):
blade
<form method="POST" action="{{ route('avatar.update') }}" enctype="multipart/form-data">
@csrf
@method('PUT')
@if (auth()->user()->avatar_path)
<img src="{{ auth()->user()->avatar_url }}" alt="Avatar" width="96" height="96">
@endif
<input type="file" name="avatar" accept="image/png, image/jpeg, image/webp">
@error('avatar')
<p class="error">{{ $message }}</p>
@enderror
<button type="submit">Actualizar foto</button>
</form>Ruta:
php
Route::put('/perfil/avatar', [AvatarController::class, 'update'])
->middleware('auth')
->name('avatar.update');Controlador:
php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class AvatarController extends Controller
{
public function update(Request $request)
{
$request->validate([
// image: verifica que sea imagen real; max en KB (2048 = 2 MB)
'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048',
'dimensions:min_width=100,min_height=100'],
]);
$user = $request->user();
// Borra la foto anterior para no acumular basura
if ($user->avatar_path) {
Storage::disk('public')->delete($user->avatar_path);
}
// store() genera un nombre único aleatorio y devuelve la ruta relativa
// p. ej. "avatars/Xk9a8sd7f6...jpg"
$path = $request->file('avatar')->store('avatars', 'public');
$user->update(['avatar_path' => $path]);
return back()->with('status', 'Foto actualizada.');
}
}Accessor en el modelo para obtener la URL fácilmente:
php
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Support\Facades\Storage;
// app/Models/User.php
protected function avatarUrl(): Attribute
{
return Attribute::get(fn () => $this->avatar_path
? Storage::disk('public')->url($this->avatar_path)
: 'https://www.gravatar.com/avatar/' . md5(strtolower($this->email)) . '?d=mp');
}Recuerda añadir avatar_path a $fillable.
Buena práctica
Guarda en la base de datos la ruta relativa (avatars/abc.jpg), nunca la URL completa. Si mañana cambias de dominio o te mudas a S3, sólo cambias la configuración del disco.
Métodos esenciales de UploadedFile y Storage
php
$file = $request->file('documento');
$file->getClientOriginalName(); // "Contrato final.pdf" (¡viene del usuario, no confíes!)
$file->getClientOriginalExtension(); // "pdf" (también del usuario)
$file->extension(); // extensión deducida del contenido real (más fiable)
$file->getSize(); // bytes
$file->getMimeType(); // "application/pdf" (detectado)
// Guardar con nombre aleatorio
$path = $file->store('contratos'); // disco por defecto
$path = $file->store('contratos', 'public'); // disco public
// Guardar con nombre elegido por ti
$path = $file->storeAs('contratos', "contrato-{$id}.pdf", 'local');
// Operaciones con Storage
Storage::disk('public')->exists($path);
Storage::disk('public')->url($path); // URL pública
Storage::disk('local')->get($path); // contenido
Storage::disk('local')->put('notas/a.txt', 'Hola'); // escribir contenido
Storage::disk('public')->delete($path); // borrar (acepta array)
Storage::disk('local')->size($path);
Storage::disk('local')->files('contratos'); // listar
Storage::disk('local')->deleteDirectory('temp');Archivos privados: descargas protegidas
Una factura no debe estar en una URL pública que cualquiera pueda adivinar. Guárdala en el disco local y sírvela a través de un controlador que verifique permisos:
php
<?php
namespace App\Http\Controllers;
use App\Models\Invoice;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
class InvoiceFileController extends Controller
{
public function store(Request $request, Invoice $invoice)
{
Gate::authorize('update', $invoice);
$request->validate([
'pdf' => ['required', 'file', 'mimes:pdf', 'max:5120'], // 5 MB
]);
// Disco "local" = storage/app/private, NO accesible desde la web
$path = $request->file('pdf')->store("invoices/{$invoice->id}", 'local');
$invoice->update([
'pdf_path' => $path,
'pdf_name' => $request->file('pdf')->getClientOriginalName(), // sólo para mostrar
]);
return back()->with('status', 'Factura subida.');
}
public function download(Invoice $invoice)
{
// Sólo el dueño o un admin (lo decide InvoicePolicy::view)
Gate::authorize('view', $invoice);
abort_unless($invoice->pdf_path && Storage::disk('local')->exists($invoice->pdf_path), 404);
// download(ruta, nombre que verá el usuario)
return Storage::disk('local')->download($invoice->pdf_path, "factura-{$invoice->folio}.pdf");
}
public function show(Invoice $invoice)
{
Gate::authorize('view', $invoice);
// response() muestra el PDF en el navegador en lugar de descargarlo
return Storage::disk('local')->response($invoice->pdf_path);
}
}php
Route::middleware('auth')->group(function () {
Route::post('/invoices/{invoice}/pdf', [InvoiceFileController::class, 'store'])->name('invoices.pdf.store');
Route::get('/invoices/{invoice}/pdf', [InvoiceFileController::class, 'download'])->name('invoices.pdf.download');
});URLs temporales
Para compartir un archivo privado por un tiempo limitado (por ejemplo, en un correo):
php
use Illuminate\Support\Facades\URL;
// URL firmada que expira en 30 minutos
$url = URL::temporarySignedRoute('invoices.pdf.public', now()->addMinutes(30), ['invoice' => $invoice]);
// Ruta protegida por el middleware "signed"
Route::get('/f/{invoice}', [InvoiceFileController::class, 'download'])
->name('invoices.pdf.public')
->middleware('signed');Con S3 puedes usar directamente Storage::disk('s3')->temporaryUrl($path, now()->addMinutes(30)).
Varios archivos a la vez (galería)
blade
<input type="file" name="photos[]" multiple accept="image/*">php
public function store(Request $request, Product $product)
{
$request->validate([
'photos' => ['required', 'array', 'max:10'], // máximo 10 archivos
'photos.*' => ['image', 'mimes:jpg,jpeg,png,webp', 'max:3072'],
]);
foreach ($request->file('photos') as $photo) {
$product->photos()->create([
'path' => $photo->store("products/{$product->id}", 'public'),
]);
}
return back()->with('status', 'Fotos subidas.');
}Y al borrar el producto, borra también sus archivos:
php
public function destroy(Product $product)
{
Storage::disk('public')->deleteDirectory("products/{$product->id}");
$product->delete();
return redirect()->route('products.index');
}Amazon S3 (o compatibles)
bash
composer require league/flysystem-aws-s3-v3 "^3.0"env
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=tu-key
AWS_SECRET_ACCESS_KEY=tu-secreto
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=mi-bucket
# Para servicios compatibles (DigitalOcean Spaces, Cloudflare R2...):
AWS_ENDPOINT=https://nyc3.digitaloceanspaces.comTu código no cambia: $file->store('avatars', 's3'). Esa es la magia de los discos.
Límites del servidor
Si subes un archivo grande y la petición "desaparece" o da error 413, el límite está en PHP o en el servidor web, no en Laravel:
ini
; php.ini
upload_max_filesize = 20M
post_max_size = 25M ; debe ser mayor que upload_max_filesizenginx
# nginx: dentro de server { }
client_max_body_size 25M;Seguridad
Nunca uses el nombre original del archivo para guardarlo, nunca permitas subir .php a una carpeta pública y valida siempre con mimes y max. store() genera nombres aleatorios por ti. Profundiza en Subida de archivos segura.
Ejercicios
- Crea la subida de un logo para un modelo
Company(discopublic, máximo 1 MB, PNG/SVG/WEBP) que reemplace el anterior y lo muestre en la vista. - Crea un modelo
Document(columnasuser_id,name,path,size) con subida privada y una ruta de descarga que sólo permita al dueño.
Ver solución 1
php
public function updateLogo(Request $request, Company $company)
{
Gate::authorize('update', $company);
$request->validate([
// svg no es "image" en todas las versiones: usamos mimes
'logo' => ['required', 'file', 'mimes:png,svg,webp', 'max:1024'],
]);
if ($company->logo_path) {
Storage::disk('public')->delete($company->logo_path);
}
$company->update([
'logo_path' => $request->file('logo')->store('logos', 'public'),
]);
return back()->with('status', 'Logo actualizado.');
}blade
@if ($company->logo_path)
<img src="{{ Storage::url($company->logo_path) }}" alt="Logo de {{ $company->name }}">
@endifNota: los SVG pueden contener JavaScript. Si los usuarios no son de confianza, limita a PNG/WEBP.
Ver solución 2
php
// Subida
public function store(Request $request)
{
$data = $request->validate([
'file' => ['required', 'file', 'mimes:pdf,docx,xlsx', 'max:10240'],
]);
$file = $data['file'];
$request->user()->documents()->create([
'name' => $file->getClientOriginalName(),
'path' => $file->store('documents/' . $request->user()->id, 'local'),
'size' => $file->getSize(),
]);
return back()->with('status', 'Documento guardado.');
}
// Descarga
public function download(Request $request, Document $document)
{
abort_unless($document->user_id === $request->user()->id, 404);
return Storage::disk('local')->download($document->path, $document->name);
}Resumen
- Los discos (
local,public,s3) abstraen dónde se guardan los archivos. - Ejecuta
php artisan storage:linkpara servir el discopublic. - Formularios con
enctype="multipart/form-data"; valida conimage,mimes,max. store()genera nombres únicos; guarda en BD la ruta relativa.- Archivos privados: disco
local+ controlador con autorización +download(). - Borra archivos viejos al reemplazar o eliminar registros.
- Ajusta
upload_max_filesize,post_max_sizeyclient_max_body_sizepara archivos grandes.