Lección 26 de 45 15 min de lectura

Subida de archivos

Subir archivos es una funcionalidad común en aplicaciones web: imágenes de perfil, documentos PDF, hojas de cálculo. Laravel simplifica este proceso con el facade Storage y validaciones específicas para archivos.

El formulario HTML

Para subir archivos, el formulario debe tener el atributo enctype="multipart/form-data". Sin él, los archivos no se enviarán:

blade
<form action="{{ route('photos.store') }}" method="POST" enctype="multipart/form-data">
    @csrf

    <label for="photo">Selecciona una imagen:</label>
    <input type="file" name="photo" id="photo">

    @error('photo')
        <span class="error">{{ $message }}</span>
    @enderror

    <button type="submit">Subir imagen</button>
</form>
Importante

Sin enctype="multipart/form-data", el archivo no se enviará y $request->file('photo') devolverá null.

Recibir el archivo en el controlador

Usa el método file() del request para obtener el archivo subido:

php
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

class PhotoController extends Controller
{
    public function store(Request $request): RedirectResponse
    {
        // Verificar si se subió un archivo
        if ($request->hasFile('photo')) {
            $file = $request->file('photo');

            // Información del archivo
            $name = $file->getClientOriginalName(); // foto.jpg
            $extension = $file->extension();         // jpg
            $size = $file->getSize();                // bytes
            $mimeType = $file->getMimeType();        // image/jpeg
        }

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

Validar archivos

Laravel incluye reglas de validación específicas para archivos:

php
<?php

$request->validate([
    // Archivo obligatorio, imagen, máximo 2MB
    'photo' => 'required|image|max:2048',

    // Archivo obligatorio con extensiones específicas
    'document' => 'required|file|mimes:pdf,doc,docx|max:5120',

    // Imagen con dimensiones mínimas
    'avatar' => 'required|image|dimensions:min_width=100,min_height=100',
]);

Las reglas más comunes para archivos:

  • file: debe ser un archivo subido
  • image: debe ser una imagen (jpeg, png, bmp, gif, svg, webp)
  • mimes:jpg,png,pdf: extensiones permitidas
  • mimetypes:image/jpeg,image/png: tipos MIME permitidos
  • max:2048: tamaño máximo en KB (2048 = 2MB)
  • dimensions:min_width=100,min_height=100: dimensiones de imagen

Almacenar archivos con Storage

Laravel usa el facade Storage para gestionar archivos. El método más simple es store():

php
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Models\Photo;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

class PhotoController extends Controller
{
    public function store(Request $request): RedirectResponse
    {
        $request->validate([
            'photo' => 'required|image|max:2048',
        ]);

        // Guardar en storage/app/photos con nombre único
        $path = $request->file('photo')->store('photos');
        // Resultado: photos/aB3xY9kL2mN4pQ7r.jpg

        // Guardar la ruta en la base de datos
        Photo::create([
            'path' => $path,
            'user_id' => $request->user()->id,
        ]);

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

El método store() genera automáticamente un nombre único para evitar colisiones. Si quieres especificar el nombre:

php
<?php

// Guardar con nombre específico
$path = $request->file('photo')->storeAs(
    'photos',
    'perfil-' . $user->id . '.jpg'
);
// Resultado: photos/perfil-42.jpg

Discos de almacenamiento

Laravel soporta múltiples "discos" configurados en config/filesystems.php. Los más comunes:

  • local: almacena en storage/app (privado)
  • public: almacena en storage/app/public (accesible vía web)
  • s3: Amazon S3 u otros servicios compatibles
php
<?php

// Guardar en el disco público (accesible vía web)
$path = $request->file('photo')->store('photos', 'public');

// O con storeAs
$path = $request->file('photo')->storeAs('photos', 'avatar.jpg', 'public');
Enlace simbólico

Para que el disco public sea accesible desde el navegador, ejecuta php artisan storage:link. Esto crea un enlace de public/storage a storage/app/public.

Mostrar archivos públicos

Para archivos en el disco público, usa el helper asset() con la ruta del storage:

blade
{{-- Si guardaste en disco 'public' con ruta 'photos/avatar.jpg' --}}
<img src="{{ asset('storage/' . $photo->path) }}" alt="Foto">

{{-- O usando el helper Storage::url() --}}
<img src="{{ Storage::url($photo->path) }}" alt="Foto">

Eliminar archivos

Usa el facade Storage para eliminar archivos:

php
<?php

use Illuminate\Support\Facades\Storage;

// Eliminar del disco por defecto
Storage::delete('photos/avatar.jpg');

// Eliminar del disco público
Storage::disk('public')->delete('photos/avatar.jpg');

// Eliminar múltiples archivos
Storage::delete(['file1.jpg', 'file2.jpg']);

Ejemplo completo: Avatar de usuario

Veamos un ejemplo práctico de subida de avatar de usuario:

php
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class ProfileController extends Controller
{
    public function updateAvatar(Request $request): RedirectResponse
    {
        $request->validate([
            'avatar' => 'required|image|mimes:jpg,png,webp|max:1024',
        ]);

        $user = $request->user();

        // Eliminar avatar anterior si existe
        if ($user->avatar) {
            Storage::disk('public')->delete($user->avatar);
        }

        // Guardar nuevo avatar
        $path = $request->file('avatar')->store('avatars', 'public');

        // Actualizar usuario
        $user->update(['avatar' => $path]);

        return redirect()->route('profile.edit');
    }
}
blade
{{-- resources/views/profile/edit.blade.php --}}

{{-- Mostrar avatar actual --}}
@if($user->avatar)
    <img src="{{ asset('storage/' . $user->avatar) }}" alt="Avatar" width="100">
@else
    <img src="{{ asset('images/default-avatar.png') }}" alt="Avatar" width="100">
@endif

{{-- Formulario para cambiar avatar --}}
<form action="{{ route('profile.avatar') }}" method="POST" enctype="multipart/form-data">
    @csrf
    @method('PATCH')

    <label for="avatar">Cambiar avatar:</label>
    <input type="file" name="avatar" id="avatar" accept="image/*">

    @error('avatar')
        <span class="error">{{ $message }}</span>
    @enderror

    <button type="submit">Actualizar</button>
</form>

Subir múltiples archivos

Para subir varios archivos a la vez, usa un array en el campo:

blade
<form action="{{ route('gallery.store') }}" method="POST" enctype="multipart/form-data">
    @csrf

    <label for="photos">Selecciona varias imágenes:</label>
    <input type="file" name="photos[]" id="photos" multiple>

    @error('photos.*')
        <span class="error">{{ $message }}</span>
    @enderror

    <button type="submit">Subir imágenes</button>
</form>
php
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Models\Photo;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

class GalleryController extends Controller
{
    public function store(Request $request): RedirectResponse
    {
        $request->validate([
            'photos' => 'required|array|max:10',
            'photos.*' => 'image|mimes:jpg,png,webp|max:2048',
        ]);

        foreach ($request->file('photos') as $photo) {
            $path = $photo->store('gallery', 'public');

            Photo::create([
                'path' => $path,
                'user_id' => $request->user()->id,
            ]);
        }

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

Validación con Form Request

Para validaciones complejas, usa un Form Request:

php
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreDocumentRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'title' => 'required|string|max:255',
            'document' => 'required|file|mimes:pdf,doc,docx|max:10240',
        ];
    }

    public function messages(): array
    {
        return [
            'document.required' => 'Debes seleccionar un documento.',
            'document.mimes' => 'Solo se permiten archivos PDF, DOC o DOCX.',
            'document.max' => 'El documento no puede superar los 10MB.',
        ];
    }
}

Resumen

  • Usa enctype="multipart/form-data" en el formulario
  • $request->file('campo') obtiene el archivo subido
  • $request->hasFile('campo') verifica si existe
  • Valida con image, mimes, max
  • $file->store('carpeta') guarda con nombre único
  • $file->storeAs('carpeta', 'nombre.ext') guarda con nombre específico
  • Usa disco public para archivos accesibles vía web
  • php artisan storage:link crea el enlace simbólico
  • Storage::delete($path) elimina archivos

Ejercicios

Ejercicio 1: Subir imagen de producto

Crea un controlador ProductController con un método store que valide y guarde una imagen de producto. La imagen debe ser obligatoria, máximo 2MB, y solo aceptar formatos jpg, png y webp. Guárdala en el disco público en la carpeta products.

Ver solución
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Models\Product;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

class ProductController extends Controller
{
    public function store(Request $request): RedirectResponse
    {
        $request->validate([
            'name' => 'required|string|max:255',
            'price' => 'required|numeric|min:0.01',
            'image' => 'required|image|mimes:jpg,png,webp|max:2048',
        ]);

        $path = $request->file('image')->store('products', 'public');

        Product::create([
            'name' => $request->name,
            'price' => $request->price,
            'image' => $path,
        ]);

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

Ejercicio 2: Actualizar documento con eliminación del anterior

Crea un método updateDocument que permita actualizar un documento asociado a un modelo Report. El documento puede ser PDF o DOCX (máximo 5MB). Antes de guardar el nuevo documento, elimina el anterior si existe.

Ver solución
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Models\Report;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class ReportController extends Controller
{
    public function updateDocument(Request $request, Report $report): RedirectResponse
    {
        $request->validate([
            'document' => 'required|file|mimes:pdf,docx|max:5120',
        ]);

        // Eliminar documento anterior si existe
        if ($report->document) {
            Storage::disk('public')->delete($report->document);
        }

        // Guardar nuevo documento
        $path = $request->file('document')->store('reports', 'public');

        $report->update(['document' => $path]);

        return redirect()->route('reports.show', $report);
    }
}

Ejercicio 3: Galería con múltiples imágenes

Crea un Form Request StoreGalleryRequest para subir una galería con título (obligatorio, máximo 100 caracteres) y hasta 5 imágenes. Cada imagen debe ser JPG o PNG y máximo 1MB. Incluye mensajes personalizados en español.

Ver solución
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreGalleryRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'title' => 'required|string|max:100',
            'images' => 'required|array|max:5',
            'images.*' => 'image|mimes:jpg,png|max:1024',
        ];
    }

    public function messages(): array
    {
        return [
            'title.required' => 'El título de la galería es obligatorio.',
            'title.max' => 'El título no puede superar los 100 caracteres.',
            'images.required' => 'Debes seleccionar al menos una imagen.',
            'images.max' => 'Solo puedes subir un máximo de 5 imágenes.',
            'images.*.image' => 'Todos los archivos deben ser imágenes.',
            'images.*.mimes' => 'Solo se permiten imágenes JPG y PNG.',
            'images.*.max' => 'Cada imagen no puede superar 1MB.',
        ];
    }
}

¿Te está gustando el curso?

Tenemos cursos premium con proyectos reales, soporte personalizado y certificado.

Descubrir cursos premium