Multiple File Upload with Dropzone.js and Laravel MediaLibrary Package

File upload is one of the most popular features in modern web. And we have quite a few libraries that can help us to build upload form. Let’s take two of my favorites – Dropzone on the front-end, and Spatie MediaLibrary on the back-end, and build a great uploading experience, in this tutorial.

First, what we’re building here. A simple for to add Projects, where you can also upload multiple files for every project.

As you can see, file upload has a big block instead of just an input file field. That’s how Dropzone works. But let’s take it one step at a time.

Step 1. MediaLibrary Installation

Let’s prepare the back-end, where we will actually store the files. We install the package like this:

composer require spatie/laravel-medialibrary:^7.0.0

Next, we publish their migration files, and run migrations:

php artisan vendor:publish --provider="Spatie\MediaLibrary\MediaLibraryServiceProvider" --tag="migrations"
php artisan migrate

By this time, we should have media table in our database.

This table uses Polymorphic Relations, so in our case will store records with model_type field equals app\Project, which means that media file will be assigned to a project (not to a user, or anything else).


Step 2. Adding Dropzone.js code

In our Blade file, with the form, we need to add JavaScript code for Dropzone. There are multiple ways to do it, depending how you structure your whole Blade architecture, but here’s my version of resources/views/admin/projects/create.blade.php:

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

    {{-- Name/Description fields, irrelevant for this article --}}

    <div class="form-group">
        <label for="document">Documents</label>
        <div class="needsclick dropzone" id="document-dropzone">

        </div>
    </div>
    <div>
        <input class="btn btn-danger" type="submit">
    </div>
</form>

Ok, so what you can see here?

  • Full form is gonna be submitted to projects.store route – we will get to that later
  • Dropzone block is just a DIV with ID and some classes, how will it actually work?

Ok ok, let’s add the JavaScript to make it actually work. At the end of the Blade file, I have this section:

@section('scripts')
<script>
  var uploadedDocumentMap = {}
  Dropzone.options.documentDropzone = {
    url: '{{ route('projects.storeMedia') }}',
    maxFilesize: 2, // MB
    addRemoveLinks: true,
    headers: {
      'X-CSRF-TOKEN': "{{ csrf_token() }}"
    },
    success: function (file, response) {
      $('form').append('<input type="hidden" name="document[]" value="' + response.name + '">')
      uploadedDocumentMap[file.name] = response.name
    },
    removedfile: function (file) {
      file.previewElement.remove()
      var name = ''
      if (typeof file.file_name !== 'undefined') {
        name = file.file_name
      } else {
        name = uploadedDocumentMap[file.name]
      }
      $('form').find('input[name="document[]"][value="' + name + '"]').remove()
    },
    init: function () {
      @if(isset($project) && $project->document)
        var files =
          {!! json_encode($project->document) !!}
        for (var i in files) {
          var file = files[i]
          this.options.addedfile.call(this, file)
          file.previewElement.classList.add('dz-complete')
          $('form').append('<input type="hidden" name="document[]" value="' + file.file_name + '">')
        }
      @endif
    }
  }
</script>
@stop

Looks complicated, doesn’t it? No worries, I will point to the actual places you need to look at:

  • route(‘admin.projects.storeMedia’) – that would be the URL to process the file that has been dropped into the area, before the actual form is submitted;
  • $(‘form’).append() – after the URL above will do the job of uploading the file, we will take its filename and add a hidden input array field with that filename. And later on submitting the form we will process only that filename and assign it where appropriate;
  • There’s also function to remove file, then that hidden field is also being deleted;
  • A few more details like CSRF-token or 2 MB upload restriction, but I think you will figure it out.

Notice: this JavaScript code will also work without any changes for edit form, not only create.

Now, keep in mind that this section cannot come just like that. In the main “parent” Blade layout file you should load some more scripts and @yield(‘scripts’) command. Here are excerpts from my resources/views/layouts/admin.blade.php:

{{-- CSS assets in head section --}}
<link href="https://cdnjs.cloudflare.com/ajax/libs/dropzone/5.5.1/min/dropzone.min.css" rel="stylesheet" />

{{-- ... a lot of main HTML code ... --}}

{{-- JS assets at the bottom --}}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/js/bootstrap.js"></script>
{{-- ...Some more scripts... --}}
<script src="https://cdnjs.cloudflare.com/ajax/libs/dropzone/5.5.1/min/dropzone.min.js"></script>
@yield('scripts')

</html>

So, as you can see, we’re loading jQuery, Bootstrap theme and Dropzone CSS+JS from CDN.

Ok, at this point, we should be able to drop the files into our Dropzone block, but they are not being uploaded yet. We need to implement that route(‘projects.storeMedia’) part.


Step 3. Uploading the Files

First, our routes/web.php will have this line:

Route::post('projects/media', 'ProjectsController@storeMedia')
  ->name('projects.storeMedia');

Now, let’s go to app/Http/Controllers/ProjectsController.php:

public function storeMedia(Request $request)
{
    $path = storage_path('tmp/uploads');

    if (!file_exists($path)) {
        mkdir($path, 0777, true);
    }

    $file = $request->file('file');

    $name = uniqid() . '_' . trim($file->getClientOriginalName());

    $file->move($path, $name);

    return response()->json([
        'name'          => $name,
        'original_name' => $file->getClientOriginalName(),
    ]);
}

Nothing magical here, just using standard Laravel/PHP functions to upload file, forming its unique filename, and returning it along with original name, as JSON result, so that Dropzone script could continue its work.

Notice: I store files temporarily in storage/tmp/uploads, you may choose other location.

Ok, we’re getting close. Now we have files in our server, but no entry in the database, because Project form isn’t submitted yet. It looks something like this:

Now, let’s hit Submit and see how to tie it all together.


Step 4. Submitting the Form

After we click Submit, we land on the method ProjectsController@store(), which is typical for Laravel resource controller. Here’s the code:

public function store(StoreProjectRequest $request)
{
    $project = Project::create($request->all());

    foreach ($request->input('document', []) as $file) {
        $project->addMedia(storage_path('tmp/uploads/' . $file))->toMediaCollection('document');
    }

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

Looks simple, doesn’t it? Typical creating of Project record, and then going through each hidden document field (remember, we create them after each file upload), and adding them into Media Library.

At this point, you should have records in media database table, related to the Project’s ID that you have just saved.


Step 5. Edit/Update Form

If you want to have the same functionality in Edit form, the front-end part (Blade/JavaScript) remains almost unchanged, the important part is how to save the update files with record. So we’re looking at ProjectController again:

public function update(UpdateProjectRequest $request, Project $project)
{
    $project->update($request->all());

    if (count($project->document) > 0) {
        foreach ($project->document as $media) {
            if (!in_array($media->file_name, $request->input('document', []))) {
                $media->delete();
            }
        }
    }

    $media = $project->document->pluck('file_name')->toArray();

    foreach ($request->input('document', []) as $file) {
        if (count($media) === 0 || !in_array($file, $media)) {
            $project->addMedia(storage_path('tmp/uploads/' . $file))->toMediaCollection('document');
        }
    }

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

In other words, first we delete unused files, and then assign only those that are not in the media list yet.


Step Homework: Clean-up

I didn’t put it in this article, but you may want to handle the situation when people upload the files on the form but then don’t hit final Submit. It means that the files are still stored on the server.

It’s up to you how to deal with them – save in user’s “memory” somewhere for future use, or maybe create a separate cron-based Artisan command to cleanup all those files that have not been used.


That’s it! For more information, visit official documentations of both packages and Laravel Filesystem.

Like our articles?
Check out our Laravel online courses!

11 COMMENTS

  1. Hi,

    Have implemented your approach! Thank you. This was really hard to find a solution online, despite close to a million downloads of Media Library.

    The MediaLibrary creates folders with the uploads, e.g. media_id = 1 has a folder with it’s files, conversions, etc.. However, this approach puts all images in a single folder. Do you have a solution to maintain that functionality?

    Thank you,
    Trevor

    • Hi Trevor
      Great that my article helped.

      What exactly do you mean about all images in a single folder? So how exactly you want to save them, in what folder structure?

        • I don’t have any issues with images storing in their own subfolders. At first it seemed unnecessary, but then it doesn’t cause any problems, and sometimes help with search.

          • Hi Povilas,

            So your demo created folders?

            I’ll take a look again, for some reason the installation I have just dumps all files into storage/tmp/uploads per the tutorial.

            Can you think of anything I missed?

            I’ll remove the count block (your below comment). A few posts on stack talked about php7.2 not being able to handle the count.

            Thank you`

          • Well spotted, in that particular tutorial indeed I see I missed the part of moving files to their folder appropriately. Will check both of those – this miss, and the previous one with count() and will update the article. But only on Monday, sorry.

          • Hi again Trevor, I double-checked the code again and seems that it’s all working, maybe you missed something.

            1. Storing from tmp folder to “normal” storage is done by ->addMedia() function.
            2. That count($project->document) comes from JS init method, and it does set project document variable.

            So, works for me, but maybe I missed some edge case while testing.

  2. Hi Povilas,

    “`count(): Parameter must be an array or an object that implements Countable“`

    Have you received that error on the update method?
    here’s my: PHP 7.2.14 (cli) (built: Jan 12 2019 05:23:00) ( NTS )

    • Probably error comes from count($project->document) and I see that in the article I forgot to include that relationship from the model. Well, just remove all that block of code (it is for deleting old documents) and it will start working. I don’t have the code of that demo project anymore, so I can’t reproduce that relationship, sorry.

  3. Povilas!

    Ok, finally tracked down why the folders were not being created.
    https://docs.spatie.be/laravel-medialibrary/v7/advanced-usage/working-with-multiple-filesystems

    In the Step 4 above, the line:
    “`
    input(‘document’, []) as $file) {
    $project->addMedia(storage_path(‘tmp/uploads/’ . $file))->toMediaCollection(‘document’);
    }
    “`
    should add the ‘disks’ so it is stored in ‘tmp/uploads/’ as a file (no folder), and also to the disk media_id folder. (in my case I used ‘media’ disk, and defined it to go to a public folder)

    As a beginner, I watched videos and saw folders being created to my amazed eyes – but my demo here did not create them. It was just maddening because without the disks second parameter, obviously now, they were not created.

    For anyone else, the disks is in /config/filesystems.php

    “`
    >input(‘document’, []) as $file) {
    $project->addMedia(storage_path(‘tmp/uploads/’ . $file))->toMediaCollection(‘document’, ‘media’);
    “`

    thank you`

LEAVE A REPLY

Please enter your comment!
Please enter your name here