Saltar al contenido

Dropzone es el plugin jQuery por excelencia para subir múltiples archivos al servidor utilizando Ajax. Lo que vamos a ver aquí es un proceso completo que te permitirá hacer la integración de forma sencilla en tus proyectos. Para ello, la lógica de nuestro Dropzone la vamos a dejar en un componente aislado. Preparar la base de datos Lo primero de todo es crear los modelos y migraciones necesarios, en este caso Attachment y Product.

php artisan make:model Product -mc
php artisan make:model Attachment -m

Ahora vamos a actualizar las migraciones.

2021_05_01_123337_create_products_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateProductsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('products', function (Blueprint $table) {
            $table->id();
            $table->string("name", 100)->unique();
            $table->timestamps();
            $table->softDeletes();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('products');
    }
}

2021_05_05_052513_create_attachments_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateAttachmentsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('attachments', function (Blueprint $table) {
            $table->id();
            $table->morphs("attachable");
            $table->string("s3_key", 150);
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('attachments');
    }
}

La tabla de Attachments la vamos a gestionar con relaciones polimórficas igual que hicimos en este curso. El resto es algo que hemos tratado demasiadas veces como para explicarlo.

Finalmente actualiza los modelos involucrados con el siguiente código.

Attachment.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;

class Attachment extends Model
{
    use HasFactory;

    protected $fillable = ["attachable_type", "attachable_id", "s3_key"];

    public function attachable(): MorphTo {
        return $this->morphTo();
    }
}

Product.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;

class Product extends Model
{
    use HasFactory;

    protected $fillable = ["name"];

    public function attachments(): MorphMany {
        return $this->morphMany(Attachment::class, 'attachable');
    }
}

Componente Dropzone Ahora vamos a crear el componente Dropzone, el cual nos permitirá pintar en pantalla un Dropzone que podremos utilizar en nuestro proyecto.

php artisan make:component DropzoneComponent

Ahora vamos a abrir el archivo .php.

<?php

namespace App\View\Components;

use Illuminate\Support\Collection;
use Illuminate\View\Component;

class DropzoneComponent extends Component
{
    public string $dropzoneClass;

    public string $route;

    public string $routeValidate;

    public string $routeOnComplete;

    public ?Collection $dropzoneFiles;

    /**
     * Create a new component instance.
     *
     * @return void
     */
    public function __construct(string $dropzoneClass, string $route, string $routeValidate, string $routeOnComplete, ?Collection $dropzoneFiles)
    {
        $this->dropzoneClass = $dropzoneClass;
        $this->route = $route;
        $this->routeValidate = $routeValidate;
        $this->dropzoneFiles = $dropzoneFiles;
        $this->routeOnComplete = $routeOnComplete;
    }

    /**
     * Get the view / contents that represent the component.
     *
     * @return \Illuminate\Contracts\View\View|string
     */
    public function render()
    {
        return view('components.dropzone');
    }
}

Inicializamos una serie de datos necesarios, la clase del contenedor HTML, ruta de procesamiento, ruta de validación, ruta para redirigir al finalizar la subida y finalmente archivos para el modo edición, fíjate que es opcional (?Collection).

Ahora abrimos el archivo .blade.php y actualizamos por lo siguiente.

<div class="{{ $dropzoneClass }} dropzone dropzone-lg-s"></div>

@push("styles")
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/dropzone/5.9.2/dropzone.min.css" integrity="sha512-jU/7UFiaW5UBGODEopEqnbIAHOI8fO6T99m7Tsmqs2gkdujByJfkCbbfPSN4Wlqlb9TGnsuC0YgUgWkRBK7B9A==" crossorigin="anonymous" referrerpolicy="no-referrer" />
    <style>
        .dz-preview .dz-image img{
            width: 100% !important;
            height: 100% !important;
            object-fit: cover;
        }
    </style>
@endpush

@push("scripts")
    <script src="https://cdnjs.cloudflare.com/ajax/libs/dropzone/5.9.2/min/dropzone.min.js" integrity="sha512-VQQXLthlZQO00P+uEu4mJ4G4OAgqTtKG1hri56kQY1DtdLeIqhKUp9W/lllDDu3uN3SnUNawpW7lBda8+dSi7w==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script>
        Dropzone.autoDiscover = false;
        let removedFiles = [];

        jQuery(document).ready(() => {
            const CSRF_TOKEN = document.querySelector('meta[name="csrf-token"]').getAttribute("content");
            const myDropzone = new Dropzone(".{{ $dropzoneClass }}", {
                paramName: "images",
                url: '{{ $route }}', // ruta para procesar el dropzone
                maxFilesize: 20,  // 20MB
                acceptedFiles: ".jpeg,.jpg,.png",
                autoProcessQueue: false,
                uploadMultiple: true,
                parallelUploads: 6,
                addRemoveLinks: true,
                // al inicializar el dropzone
                init: function () {
                    @if($dropzoneFiles)
                        @foreach($dropzoneFiles as $attachment)
                            var mockFile = @json($attachment);
                            this.emit("addedfile", mockFile);
                            this.createThumbnailFromUrl(mockFile, mockFile.url);
                            this.emit("thumbnail", mockFile, mockFile.url);
                            this.emit("success", mockFile);
                            this.emit("complete", mockFile);
                            this.files.push(mockFile);
                        @endforeach
                    @endisset
                },
                // al eliminar un archivo del dropzone
                removedfile: function (file) {
                    removedFiles.push(file.id)
                    $("input[name=removed_files]").val(removedFiles);
                    file.previewElement.remove();
                },
            });

            $("#my-form").submit(function (e) {
                // si no hay archivos pendientes procesamos el formulario
                if (myDropzone.getQueuedFiles().length === 0) {
                    $(this).submit();
                    return;
                }
                
                e.preventDefault();
                $.ajax({
                    method: 'POST',
                    url: '{{ $routeValidate }}',
                    data: $('#my-form').serializeArray(),
                    cache: false,
                    before: function () {
                        $('#form-errors').html('');
                    },
                    success: function (res) {
                        myDropzone.processQueue(); // procesamos la cola de archivos pendientes en el dropzone
                    },
                    error: function (xhr) {
                        $('#form-errors').html('');
                        $.each(xhr.responseJSON.errors, function (key, value) {
                            $('#form-errors').append('<div class="alert alert-danger">' + value + '</div');
                        });
                    },
                });
            });

            // al enviar el dropzone
            myDropzone.on('sending', function (file, xhr, formData) {
                var data = $('#my-form').serializeArray();
                formData.append("_token", CSRF_TOKEN);
                $.each(data, function (key, el) {
                    formData.append(el.name, el.value);
                });
            });

            // al completar el dropzone
            myDropzone.on("complete", function (file) {
                if (myDropzone.getUploadingFiles().length === 0 && myDropzone.getQueuedFiles().length === 0) {
                    window.location.href = '{{ $routeOnComplete }}'
                }
            });
        });
    </script>
@endpush

Algo importante es fijarse que nuestro Dropzone siempre estará dentro de un formulario, en este caso con ID #my-form. Nuestra idea es primero hacer una petición Ajax para validar nuestro formulario, y, en caso de que la validación sea satisfactoria, procesar los archivos en cola de Dropzone, pero no antes. Servicio para procesar archivos Ahora que ya tenemos nuestro componente Dropzone listo, necesitamos cierta lógica para procesar los archivos que estaremos subiendo. Estos archivos pueden subirse a nuestro sistema local de storage o a S3, lo que más nos convenga, el código será exactamente el mismo.

Crea un archivo app/Services/DropzoneService.php y actualiza su código con el siguiente.

<?php
namespace App\Services;

use App\Models\Attachment as AttachmentModel;
use Exception;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use League\Flysystem\FileNotFoundException;

class DropzoneService {
    /**
     * @var string
     */
    protected string $disk = "s3";

    /**
     * @param UploadedFile $file
     * @param array $options
     * @return AttachmentModel
     * @throws Exception
     */
    public function upload(UploadedFile $file, array $options = []): AttachmentModel {
        $fileName = $this->generateFileName($file, $options["attachable_id"]);
        $path = $file->storeAs($options["path"], $fileName, ["disk" => $this->disk]);
        return $this->save([
            "attachable_type" => $options["attachable_type"],
            "attachable_id" => $options["attachable_id"],
            "s3_key" => $path,
            "created_at" => now(),
        ]);
    }

    /**
     * @param array $attachment
     * @return AttachmentModel
     * @throws Exception
     */
    public function save(array $attachment): AttachmentModel {
        try {
            return AttachmentModel::create($attachment);
        } catch (Exception $exception) {
            $this->deleteByKey($attachment['s3_key']);
            throw new Exception($exception->getMessage());
        }
    }

    /**
     * @param Collection $attachments
     */
    public function deleteCollection(Collection $attachments): void {
        foreach ($attachments as $attachment) {
            $this->deleteByKey($attachment->s3_key);
        }
    }

    /**
     * @param string $s3_key
     * @param bool $deleteOnDb
     */
    public function deleteByKey(string $s3_key): void {
        Storage::disk($this->disk)->delete($s3_key);
        AttachmentModel::where("s3_key", $s3_key)->delete();
    }

    /**
     *
     * Generates attachment data for Dropzone
     *
     * @param Collection $attachments
     * @return Collection
     * @throws FileNotFoundException
     */
    public function generateAttachmentDataForDropzone(Collection $attachments): Collection {
        $attachmentsData = new Collection;
        foreach($attachments as $attachment) {
            $attachmentsData->push([
                "id" => $attachment->id,
                "url" => Storage::temporaryUrl($attachment->s3_key, now()->addMinutes(5)),
                "size" => Storage::disk($this->disk)->size($attachment->s3_key),
                "type" => Storage::disk($this->disk)->getMimeType($attachment->s3_key),
                "name" => $attachment->s3_key,
            ]);
        }
        return $attachmentsData;
    }

    /**
     * @param UploadedFile $file
     * @return string
     */
    protected function generateFileName(UploadedFile $file, int $id): string {
        $extension = $file->getClientOriginalExtension();
        $fullName = $file->getClientOriginalName();
        $pathFileName = trim(pathinfo($fullName, PATHINFO_FILENAME));
        $secureMaxName = substr(Str::slug($pathFileName), 0, 200);
        return sprintf('%s-%s-%s.%s', $id, $secureMaxName, now()->timestamp, $extension);
    }
}
Algo interesante que podrías hacer en nuestro servicio sería hacer que el disco pueda ser dinámico, ya que nuestra clase no es concreta para S3, sería un buen cambio.

Rutas Ahora vamos a crear las rutas necesarias para que nuestro sistema pueda mostrar y procesar información utilizando Dropzone.

<?php

use App\Http\Controllers\Backend\ProductController;
use Illuminate\Support\Facades\Route;

Route::match(["POST", "PUT"], "products/validate", [ProductController::class, "validateForm"])->name("products.validateForm");
Route::resource("products", ProductController::class)->except("show", "destroy");

Validación con FormRequest Una vez hecho, vamos a crear un FormRequest para validar nuestro formulario.

php artisan make:request ProductRequest
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class ProductRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        switch ($this->method()) {
            case "POST":
            {
                return [
                    "name" => "unique:products|min:2|max:100|string",
                ];
            }
            case "PUT": {
                return [
                    "name" => "min:2|max:100|string|unique:products,name," . $this->route('product'),
                ];
            }
        }
    }
}

Controlador Ahora abre el controlador ProductController y actualiza el código por el siguiente.

<?php

namespace App\Http\Controllers\Backend;

use App\Http\Controllers\Controller;
use App\Http\Requests\ProductRequest;
use App\Models\Product;
use App\Services\AttachmentService;
use Illuminate\Http\RedirectResponse;
use App\Repositories\RepositoryInterface;
use App\Repositories\Product\ProductRepositoryInterface;

class ProductController extends Controller
{
     /**
     * @var RepositoryInterface
     */
    protected RepositoryInterface $repository;

    /**
     * @var AttachmentService
     */
    protected AttachmentService $attachmentService;

    /**
     * ProductController constructor.
     * @param ProductRepositoryInterface $repository
     */
    public function __construct(ProductRepositoryInterface $repository, AttachmentService $attachmentService) {
        $this->repository = $repository;
        $this->attachmentService = $attachmentService;
    }

    /**
     * Show the form for creating a new resource.
     *
     */
    public function create() {
        return view("backend.products.form", [
            "model" => new Product,
            "routeValidate" => route("backend.products.validateForm"),
            "route" => route("backend.products.store"),
            "textButton" => __("Create :model", ["model" => __("Ptoduct")]),
        ]);
    }

    public function validateForm(ProductRequest $request) {
        return response()->json(["success" => true, "errors" => null]);
    }

    /**
     * @return RedirectResponse
     */
    public function store(): RedirectResponse
    {
        $this->repository->create();

        $notification = ['type' => 'success', 'icon' => 'check-circle', 'title' => __(":model created", ["model" => __("Product")]), 'message' => __('The :model has been created successfully!', ["model" => __("Product")])];

        return redirect()->route('backend.products.index')->with('notification', $notification);
    }

    /**
     * @param int $id
     * @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\View\Factory|\Illuminate\Contracts\View\View
     * @throws \League\Flysystem\FileNotFoundException
     */
    public function edit(int $id)
    {
        $model = $this->repository->find($id)->load("attachments");
        
        $attachments = $model->attachments;
        $dropzoneFiles = null;
        if ($attachments->count()) {
            $dropzoneFiles = $this->attachmentService->generateAttachmentDataForDropzone($attachments);
        }

        return view("backend.products.form", [
            "model" => $model,
            "dropzoneFiles" => $dropzoneFiles,
            "updating" => true,
            "route" => route("backend.products.update", $id),
            "routeValidate" => route("backend.products.validateForm"),
            "textButton" => __("Update :model", ["model" => __("Product")]),
        ]);
    }

    /**
     * @param int $id
     * @return RedirectResponse
     */
    public function update(int $id): RedirectResponse
    {
        $this->repository->update($id);
        return redirect()->route('backend.products.index')
            ->with('notification', ['type' => 'success', 'icon' => 'check-circle', 'title' => __(":model updated", ["model" => __("Store")]), 'message' => __('The :model has been updated successfully!', ["model" => __("Store")])]);
    }
}
  • En nuestro controlador hacemos varias cosas. Inyectamos nuestro servicio para gestionar archivos y un repositorio para gestionar la lógica de Eloquent a través de la interfaz ProductRepositoryInterface.
  • Definimos métodos para crear y actualizar datos.
  • Validamos nuestro formulario utilizando nuestro ProductRequest.
Si tienes dudas acerca del patrón repositorio no te pierdas este curso.

Repositorio para Products Ahora crea el repositorio EloquentProductRepository y actualiza su código por el siguiente.

<?php
namespace App\Repositories\Product;

use App\Models\Attachment;
use App\Models\Product;
use App\Services\AttachmentService;
use Illuminate\Database\Eloquent\ModelNotFoundException;

class EloquentProductRepository implements ProductRepositoryInterface
{
    public function create(): Product {
        $model = Product::create(request()->only($this->fillableInput()));
        if(request()->hasfile('images')) {
            $attachmentService = new AttachmentService;
            foreach(request()->file('images') as $file) {
                $attachmentService->upload($file, $model->attachableData());
            }
        }
        return $model;
    }

    public function update(int $id): Product {
        $model = $this->find($id);
        $model->update(request()->only($this->fillableInput()));
        $attachmentService = new AttachmentService;
        if(request()->hasfile('images')) {
            foreach(request()->file('images') as $file) {
                $attachmentService->upload($file, $model->attachableData());
            }
        }
        if (request("removed_files")) {
            $attachments = Attachment::whereIn("id", explode(",", request("removed_files")))->get();
            $attachmentService->deleteCollection($attachments);
        }
        return $model;
    }

    public function find(int $id): Product {
        if (null == $model = Product::find($id)) {
            throw new ModelNotFoundException(__("Product not found!"));
        }
        return $model;
    }

    protected function fillableInput(): array {
        return ["name"];
    }
}

Formulario final Para finalizar nuestro ejemplo, simplemente debemos crear nuestro formulario, en este caso el archivo lo hemos llamado form.blade.php, así que créalo y modifica su código por el siguiente.

El formulario utiliza Laravel Collective, en caso de dudas revisa su documentación o los cursos de la plataforma, lo utilizamos de forma intensiva.
{!! FormFacade::model($model, ['id' => 'my-form', 'url' => $route, 'method' => 'Post', 'files' => true]) !!}

    @isset($updating)
        @method("PUT")
        <input type="hidden" name="removed_files" />
    @endif
    
    <div class="form-row">
        <div class="col-md-12 mb-3 px-15">
            <label for="name">{{ __("Name") }}</label>
            {!! Form::text(
                'name',
                old('name'),
                [
                    'class' => 'form-control' . ($errors->has('name') ? ' is-invalid' : null),
                    'id' => 'name',
                    'placeholder' => __("Product name"),
                    'required' => true
                ])
            !!}
            @error("name")<div class="invalid-feedback">{{ $errors->first("name") }}</div>@enderror
        </div>

        <div class="col-md-12 col-sm-6 mb-3 px-15">
            <x-dropzone-component
                dropzone-class="product-dropzone"
                :route="$route"
                :route-validate="$routeValidate"
                :dropzone-files="$dropzoneFiles ?? null"
                :route-on-complete="route('backend.products.index')"
            />
        </div>
    </div>
    <button type="submit" class="btn btn-primary">{{ __($textButton) }}</button>
{{ Form::close() }}

A partir de aquí simplemente debes ajustar toda la lógica a tus necesidades, el ejemplo es extenso pero 100% funcional para llevar a cabo complejos desarrollos con Dropzone.

school Curso completo

Curso Laravel 12
Completo 2026

El único curso 100% actualizado que incluye Laravel 12, Livewire 3, Vue 3, React 19 e Inertia 2. Aprende con proyectos reales y las últimas funcionalidades.

access_time 8 horas de contenido
layers 4 tecnologías en 1
update 100% actualizado
code Proyectos prácticos
Ver Curso Laravel 12 arrow_forward

star Incluido en cualquier suscripción

Rutas de aprendizaje