Skip to content

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):

DiscoCarpeta¿Accesible desde el navegador?Uso
localstorage/app/private (Laravel 11+)NoDocumentos privados (contratos, facturas)
publicstorage/app/publicSí, vía enlace simbólico public/storageAvatares, imágenes de productos
s3Bucket de Amazon S3 (o compatible)Según configuraciónProducció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:link

Esto 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.com

Tu 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_filesize
nginx
# 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 ​

  1. Crea la subida de un logo para un modelo Company (disco public, máximo 1 MB, PNG/SVG/WEBP) que reemplace el anterior y lo muestre en la vista.
  2. Crea un modelo Document (columnas user_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 }}">
@endif

Nota: 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:link para servir el disco public.
  • Formularios con enctype="multipart/form-data"; valida con image, 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_size y client_max_body_size para archivos grandes.

Academia Web — aprende, copia y construye.