Actualizado el miércoles, 1 mayo, 2024

Una de las cosas con las que veo que muchas personas luchan es con la carga de archivos. ¿Cómo subimos un archivo en Laravel? ¿Cuál es la mejor manera de subir un archivo? En este tutorial, pasaré de una versión básica usando blade y rutas, cada vez más avanzado, y luego cómo podríamos manejarlo en Livewire también.

Para empezar, veamos cómo podríamos hacer esto en Laravel y Blade estándar. Hay algunos paquetes que puede usar para esto, sin embargo, no soy fanático de instalar un paquete para algo tan simple como cargar un archivo. Sin embargo, supongamos que desea cargar un archivo y asociarlo con un modelo y tiene diferentes colecciones de medios para su modelo. En ese caso, Spatie tiene un gran paquete llamado MediaLibrary y MediaLibrary Pro, que elimina gran parte de la molestia de este proceso para usted.

Supongamos que queremos hacer esto nosotros mismos y no apoyarnos en un paquete para esto. Querremos crear un formulario que nos permita cargar un archivo y enviarlo, y un controlador aceptará este formulario, validará la entrada y manejará la carga.

Antes de eso, sin embargo, vamos a crear una tabla de base de datos en la que podemos almacenar nuestras cargas. Imagine un escenario en el que queremos cargar y adjuntar archivos a diferentes modelos. Deseamos tener una tabla centralizada para nuestros medios que podamos adjuntar en lugar de cargar múltiples versiones para cada modelo.

Vamos a crear esto primero usando el siguiente comando artisan:

php artisan make:model Media -m

Esto creará el modelo y la migración para que comencemos. Echemos un vistazo al método up en la migración para que podamos entender lo que querremos almacenar y entender:

Schema::create('media', function (Blueprint $table) {
    $table->id();
 
    $table->string('name');
    $table->string('file_name');
    $table->string('mime_type');
    $table->string('path');
    $table->string('disk')->default('local');
    $table->string('file_hash', 64)->unique();
    $table->string('collection')->nullable();
 
    $table->unsignedBigInteger('size');
 
    $table->timestamps();
});

Nuestros medios requerirán un nombre para que podamos extraer el nombre original del cliente de la carga. Entonces queremos un nombre de archivo, que será un nombre generado. Almacenar archivos cargados con el nombre de archivo original puede ser un problema importante con respecto a la seguridad, especialmente si no está validando lo suficientemente fuerte. El tipo mime es necesario para que podamos entender lo que se cargó, ya sea un CSV o una imagen. La ruta a la carga también es útil para almacenar, ya que nos permite referenciarla más fácilmente. Grabamos el disco en el que estamos almacenando esto para que podamos trabajar dinámicamente con él dentro de Laravel. Sin embargo, podríamos estar interactuando con nuestra aplicación. Almacenamos el hash del archivo como una columna única para asegurarnos de no cargar el mismo archivo más de una vez. Si el archivo cambia, esta sería una nueva variación y estaría bien volver a cargarla. Finalmente, tenemos colección y tamaño, donde podemos guardar un archivo en una colección como «entradas de blog», creando un directorio virtual / estructura de taxonomía. El tamaño está ahí principalmente con fines informativos, pero le permitirá asegurarse de que sus activos digitales no sean demasiado grandes.

Ahora que sabemos dónde queremos almacenar estas cargas, podemos ver cómo queremos subirlas. Comenzaremos con una implementación simple dentro de una ruta / controlador y expandiremos desde allí.

Vamos a crear nuestro primer controlador usando el siguiente comando artesanal:

php artisan make:controller UploadController --invokable

Aquí será donde enrutamos las cargas a, por ahora, un controlador invocable que manejará sincrónicamente la carga del archivo. Añade esto como ruta en tu web.php así:

Route::post('upload', App\Http\Controllers\UploadController::class)->name('upload');

Entonces podemos ver cómo queremos que funcione este proceso. Para empezar, como todos los demás puntos finales, queremos validar la entrada desde el principio. Me gusta hacer esto en una solicitud de formulario, ya que mantiene las cosas encapsuladas muy bien. Puedes hacer esta parte como creas apropiado; Te mostraré las reglas que utilizo a continuación:

use Illuminate\Validation\Rules\File;
 
return [
    'file' => [
        'required',
        File::types(['png', 'jpg'])
            ->max(5 * 1024),
    ]
];

Por lo tanto, debemos enviar un file en nuestra solicitud, y debe ser PNG o JPG y no ser más grande que 5Gb. Puede usar la configuración para almacenar sus reglas predeterminadas para esto si lo encuentra más accesible. Sin embargo, normalmente creo una clase Validator específica para cada caso de uso, por ejemplo:

class UserUploadValidator
{
    public function avatars(): array
    {
        return [
            'required',
           File::types(['png', 'jpg'])
               ->max(5 * 1024),
        ];
    }
}

Una vez que tenga su validación en su lugar, puede manejar esto en su controlador como lo necesite. Sin embargo, supongamos que estoy usando una solicitud de formulario e inyectando esto en mi controlador. Ahora que hemos validado, necesitamos procesar. Mi enfoque general para los controladores es:

  • Validar
  • Proceso
  • Responder

En una API, proceso en segundo plano, lo que generalmente significa enviar un trabajo, pero en la web, eso no siempre es conveniente. Veamos cómo podríamos procesar la carga de un archivo.

class UploadController
{
    public function __invoke(UploadRequest $request)
    {
        Gate::authorize('upload-files');
 
        $file = $request->file('file');
        $name = $file->hashName();
 
        $upload = Storage::put("avatars/{$name}", $file);
 
        Media::query()->create(
            attributes: [
                'name' => "{$name}",
                'file_name' => $file->getClientOriginalName(),
                'mime_type' => $file->getClientMimeType(),
                'path' => "avatars/{$name}"
,
                'disk' => config('app.uploads.disk'),
                'file_hash' => hash_file(
                    config('app.uploads.hash'),
                    storage_path(
                        path: "avatars/{$name}",
                    ),
                ),
                'collection' => $request->get('collection'),
                'size' => $file->getSize(),
            ],
        );
 
        return redirect()->back();
    }
}

Entonces, lo que estamos haciendo aquí es primero asegurarnos de que el usuario que ha iniciado sesión esté autorizado para cargar archivos. Luego queremos cargar el archivo y el nombre hash para almacenar. Luego cargamos el archivo y almacenamos el registro en la base de datos, obteniendo la información que necesitamos para el modelo del propio archivo.

Yo llamaría a esto el enfoque predeterminado para cargar archivos, y admitiré que no hay nada de malo en este enfoque. Si su código ya se ve así, está haciendo un buen trabajo. Sin embargo, por supuesto, podemos llevar esto más lejos, de diferentes maneras.

La primera forma en que podríamos lograr esto es extrayendo la lógica de carga a un UploadService donde genera todo lo que necesitamos y devuelve un Objeto de Transferencia de Dominio (que yo llamo Objetos de Datos) para que podamos usar las propiedades del objeto para crear el modelo. Primero, hagamos el objeto que queremos devolver.

class File
{
    public function __construct(
        public readonly string $name,
        public readonly string $originalName,
        public readonly string $mime,
        public readonly string $path,
        public readonly string $disk,
        public readonly string $hash,
        public readonly null|string $collection = null,
    ) {}
 
    public function toArray(): array
    {
        return [
            'name' => $this->name,
            'file_name' => $this->originalName,
            'mime_type' => $this->mime,
            'path' => $this->path,
            'disk' => $this->disk,
            'file_hash' => $this->hash,
            'collection' => $this->collection,
        ];
    }
}

Ahora podemos mirar el servicio de carga y averiguar cómo queremos que funcione. Si observamos la lógica dentro del controlador, sabemos que querremos generar un nuevo nombre para el archivo y obtener el nombre original de la carga. Luego queremos poner el archivo en almacenamiento y devolver el objeto de datos. Al igual que con la mayoría del código que escribo, el servicio debe implementar una interfaz que luego podamos vincular al contenedor.

class UploadService implements UploadServiceContract
{
    public function avatar(UploadedFile $file): File
    {
        $name = $file->hashName();
 
        $upload = Storage::put("{$name}", $file);
 
        return new File(
            name: "{$name}",
            originalName: $file->getClientOriginalName(),
            mime: $file->getClientMimeType(),
            path: $upload->path(),
            disk: config('app.uploads.disk'),
            hash: file_hash(
                    config('app.uploads.hash'),
                    storage_path(
                        path: "avatars/{$name}",
                    ),
            ),
            collection: 'avatars',
        );
    }
}

Refactoricemos nuestro UploadController ahora para que esté usando este nuevo servicio:

class UploadController
{
    public function __construct(
        private readonly UploadServiceContract $service,
    ) {}
 
    public function __invoke(UploadRequest $request)
    {
        Gate::authorize('upload-files');
 
        $file = $this->service->avatar(
            file: $request->file('file'),
        );
 
        Media::query()->create(
            attributes: $file->toArray(),
        );
 
        return redirect()->back();
    }
}

De repente, nuestro controlador es mucho más limpio y nuestra lógica se ha extraído a nuestro nuevo servicio, por lo que es repetible sin importar dónde necesitemos cargar un archivo. Podemos, por supuesto, escribir pruebas para esto, también, porque ¿por qué hacer algo que no se puede probar?

 it('can upload an avatar', function () {
    Storage::fake('avatars');
 
    $file = UploadedFile::fake()->image('avatar.jpg');
 
    post(
        action(UploadController::class),
        [
             'file' => $file,
        ],
    )->assertRedirect();
 
    Storage::disk('avatars')->assertExists($file->hashName());
});

Estamos falsificando la fachada de almacenamiento, creando un archivo falso para cargar, y luego golpeando nuestro punto final y enviando el archivo. Luego afirmamos que todo salió bien, y fuimos redirigidos. Finalmente, queremos afirmar que el archivo ahora existe en nuestro disco.

¿Cómo podríamos llevar esto más lejos? Aquí es donde estamos entrando en el meollo de la cuestión, dependiendo de su aplicación. Digamos, por ejemplo, que en su aplicación, hay muchos tipos diferentes de cargas que es posible que deba hacer. Queremos que nuestro servicio de carga refleje eso sin complicarse demasiado, ¿verdad? Aquí es donde uso un patrón que llamo el «patrón de acción de servicio», donde nuestro servicio llama a una acción en lugar de manejar la lógica. Este patrón le permite inyectar un solo servicio pero llamar a múltiples acciones a través de él, manteniendo su código limpio y enfocado, y su servicio es solo un proxy útil.

Primero vamos a crear la acción:

class UploadAvatar implements UploadContract
{
    public function handle(UploadedFile $file): File
    {
        $name = $file->hashName();
 
        Storage::put("{$name}", $file);
 
        return new File(
            name: "{$name}",
            originalName: $file->getClientOriginalName(),
            mime: $file->getClientMimeType(),
            path: $upload->path(),
            disk: config('app.uploads.disk'),
            hash: hash_file(
                config('app.uploads.hash'),
                storage_path(
                    path: "avatars/{$name}",
                ),
            ),
            collection: 'avatars',
            size: $file->getSize(),
        );
    }
}

Ahora podemos refactorizar nuestro servicio para llamar a la acción, actuando como un proxy útil.

class UploadService implements UploadServiceContract
{
    public function __construct(
        private readonly UploadContract $avatar,
    ) {}
 
    public function avatar(UploadedFile $file): File
    {
        return $this->avatar->handle(
            file: $file,
        );
    }
}

Esto se siente como una sobreingeniería para una aplicación menor. Aún así, para aplicaciones más extensas centradas en los medios, esto le permitirá manejar las cargas a través de un servicio que puede estar bien documentado en lugar de fragmentar el conocimiento en todo su equipo.

¿Dónde podemos llevarlo desde aquí? Entremos en la tierra del usuario por un momento y supongamos que estamos usando la pila TALL (porque ¿por qué no lo harías?). Con Livewire, tenemos un enfoque ligeramente diferente en el que Livewire manejará la carga por usted y lo almacenará como un archivo temporal, lo que le brinda una API algo diferente para trabajar cuando se trata de almacenar el archivo.

En primer lugar, necesitamos crear un nuevo componente Livewire que podamos usar para nuestra carga de archivos. Puede crear esto usando el siguiente comando artisan:

php artisan livewire:make UploadForm --test

Ahora podemos agregar algunas propiedades a nuestro componente y agregar un rasgo para que el componente sepa que maneja las cargas de archivos.

 final class UploadForm extends Component
{
    use WithFileUploads;
 
    public null|string|TemporaryUploadedFile $file = null;
 
    public function upload()
    {
        $this->validate();
    }
 
    public function render(): View
    {
        return view('livewire.upload-form');
    }
}

Livewire viene con un rasgo útil que nos permite trabajar con cargas de archivos directamente. Tenemos una propiedad de archivo que podría ser null, una cadena para una ruta de acceso o un archivo temporal que se ha cargado. Esta es quizás la única parte sobre las cargas de archivos en Livewire que no me gusta.

Ahora que tenemos un componente básico disponible, podemos ver cómo mover la lógica de nuestro controlador al componente. Una cosa que haríamos aquí es mover la verificación de Gate del controlador a la interfaz de usuario para que no mostremos el formulario si el usuario no puede cargar archivos. Esto simplifica muy bien la lógica de nuestros componentes.

Nuestro siguiente paso es inyectar el UploadService en nuestro método de carga, que Livewire puede resolver por nosotros. Junto a esto, querremos manejar nuestra validación de inmediato. Nuestro componente no debe tener el siguiente aspecto:

final class UploadForm extends Component
{
    use WithFileUploads;
 
    public null|string|TemporaryUploadedFile $file;
 
    public function upload(UploadServiceContract $service)
    {
        $this->validate();
    }
 
    public function rules(): array
    {
        return (new UserUploadValidator())->avatars();
    }
 
    public function render(): View
    {
        return view('livewire.upload-form');
    }
}

Nuestro método de reglas de validación devuelve nuestras rules de validación de avatar de nuestra clase de validación y hemos inyectado el servicio desde el contenedor. A continuación, podemos agregar nuestra lógica para cargar realmente el archivo.

final class UploadForm extends Component
{
    use WithFileUploads;
 
    public null|string|TemporaryUploadedFile $file;
 
    public function upload(UploadServiceContract $service)
    {
        $this->validate();
 
        try {
            $file = $service->avatar(
                file: $this->file,
            );
        } catch (Throwable $exception) {
            throw $exception;
        }
 
        Media::query()->create(
            attributes: $file->toArray(),
        );
    }
 
    public function rules(): array
    {
        return (new UserUploadValidator())->avatars();
    }
 
    public function render(): View
    {
        return view('livewire.upload-form');
    }
}

Necesitamos cambios mínimos en cómo funciona nuestra lógica: podemos moverla casi directamente a su lugar y funcionará.