2 September 2019

Multiple file uploads in Laravel with Dropzone and Laravel Media Library

There are a few examples online of using Dropzone JS with Laravel Media Library, however none of them quite worked how I'd like, and had various caveats.

Below is my solution, which supports retaining original file names, generating thumbnails, file previews, repopulating forms, and deleting/adding new items when updating an object.

I'll refrain from in-depth explanation and just post the code - if you need this code, you probably already know the basics and how to get to the point where you'll need it. From there it should be fairly self-explanatory.

app/Item.php

We'll attach uploaded files to an Item model

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;
use Spatie\MediaLibrary\HasMedia\HasMedia;
use Spatie\MediaLibrary\HasMedia\HasMediaTrait;
use Spatie\MediaLibrary\Models\Media;

class Item extends Model implements HasMedia
{
    use HasMediaTrait;

    /**
    * Set up Media Library file conversions that should occur when a media item is attached
    */
    public function registerMediaConversions(Media $media = null)
    {
        $this->addMediaConversion('thumb')
            ->width(settings()->get('media_thumb_w', 512))
            ->height(settings()->get('media_thumb_h', 512));

        $this->addMediaConversion('large')
            ->width(settings()->get('media_large_w', 1536))
            ->height(settings()->get('media_large_h', 1536));
    }

routes/api.php

Two routes are required for storing and retrieving files

//rest of file continues above

Route::post('/media', 'API\[email protected]')->name('api.management.media.store');
Route::get('/media/{mediaItem}/{size?}', 'API\[email protected]')->name('api.media.show');

//rest of file continues below

app/Http/Controllers/API/MediaController.php

This handles storing and retrieving files

<?php

namespace App\Http\Controllers\API;

use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use Spatie\MediaLibrary\Models\Media;
use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;

class MediaController extends Controller
{
    /**
    * Upload a file
    * Package laravel-directory-cleanup takes care of removing old, unused uploads - see the file of that name in the config dir
    *
    * @return \Illuminate\Http\Response
    */
    public function store(Request $request)
    {
        $request->validate([
            'file' => 'required|file|image|max:2048'
        ]);

        $path = $request->file('file')->store('uploads');
        $file = $request->file('file');

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

    /**
    * Show an uploaded file
    *
    * @return \Illuminate\Http\Response
    */
    public function show(Media $mediaItem, string $size = null)
    {
        try {
                if(in_array($size, ['thumb', 'large'])) return response()->download($mediaItem->getPath($size), $mediaItem->name);
                //Return the original image if no valid size supplied
                else return response()->download($mediaItem->getPath(), $mediaItem->name);
        } catch (FileNotFoundException $e) {
                //FileNotFoundExceptions thrown as 500, want to make them a 404
                abort(404);
        }
    }

}

app/Http/Controllers/API/ItemController.php

//rest of file continues above

public function show($id)
{
    return Item::findOrFail($id)->load('media'); //Include attached media with the response when viewing an item
}

public function store(Request $request)
{

    //This transaction means that if an addMedia operation, or anything else after the item is saved fails, the item is un-saved and everything rolls back
    return DB::transaction(function () use ($request) {

        //the rest of your store logic

        //Handle media - BIG NOTE - FRONTEND MUST PASS MEDIA AND MEDIA_ORIGINAL_NAME IN CORRECT ORDER SO THAT THEY CAN BE MATCHED
        $mediaData = $request->only('media.*', 'media_original_name.*');
        $mediaValidator = Validator::make($allTagData, [
            //Rules
            'media'  => 'array|max:10',
            "media.*"  => "required|string|max:255|min:1",
            'media_original_name'  => 'array|max:10',
            "media_original_name.*"  => "required|string|max:255|min:1|required_with:media.*",//required_with here /should/ make it validate that both arrays be the same length.  I think.
        ]);//Separate validation rules for media as they aren't stored directly in model, so the model doesn't validate them
        if ($mediaValidator->fails()) {
            return response()->json(['message' =>'Some media values submitted didn\'t pass validation', 'input' => $request->all(), 'errors' => $tagValidator->errors()], 422);
        }

        foreach ($request->input('media', []) as $index => $file) {
            $item->addMedia(storage_path( "app/" . $file))
                ->usingName($request->input('media_original_name', [])[$index])//get the media original name at the same index as the media item
                ->toMediaCollection();
        }

        //more of your store logic

    });

}

public function update(Request $request, $id)
{

    //This transaction means that if an addMedia operation, or anything else after the item is saved fails, the item is un-saved and everything rolls back
    return DB::transaction(function () use ($request, $id) {

        //the rest of your update logic

        //Handle media - BIG NOTE - FRONTEND MUST PASS MEDIA AND MEDIA_ORIGINAL_NAME IN CORRECT ORDER SO THAT THEY CAN BE MATCHED
        $mediaData = $request->only('media.*', 'media_original_name.*');
        $mediaValidator = Validator::make($allTagData, [
            //Rules
            'media'  => 'array|max:10',
            "media.*"  => "required|string",
            'media_original_name'  => 'array|max:10',
            "media_original_name.*"  => "required|string|required_with:media.*",//required_with here /should/ make it validate that both arrays be the same length.  I think.
        ]);//Separate validation rules for media as they aren't stored directly in model, so the model doesn't validate them
        if ($mediaValidator->fails()) {
            return response()->json(['message' =>'Some media values submitted didn\'t pass validation', 'input' => $request->all(), 'errors' => $tagValidator->errors()], 422);
        }

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

        //assign only those that are not in the media list yet
        $media = $item->media->pluck('file_name')->toArray();
        foreach ($request->input('media', []) as $index => $file) {
            if (count($media) === 0 || !in_array($file, $media)) {
                $item->addMedia(storage_path( "app/" . $file))
                    ->usingName($request->input('media_original_name', [])[$index])//get the media original name at the same index as the media item
                    ->toMediaCollection();
            }
        }

        //more of your update logic

    });

}

Blade template - display images

Items are loaded dynamically on the frontend, so the blade template has no idea of the ID of the item in view. To solve this, 'replaceMe' is used as a placeholder ID when generating the URLS and replaced later in JS.

document.getElementById('media').innerHTML = "";
item['media'].forEach(function(mediaItem){
    html += '<a href="' + "{{ route('api.media.show', ['mediaItem' => 'replaceMe', 'size' => 'large']) }}".replace('replaceMe', mediaItem.id) + '">';
    html += '<img src="' + "{{ route('api.media.show', ['mediaItem' => 'replaceMe', 'size' => 'thumb']) }}".replace('replaceMe', mediaItem.id) + '">';
    html += '</a>';
});
document.getElementById('media').innerHTML = html;

Blade template - upload form

When a file is uploaded successfully, hidden elements for it will be appended to #items-form, where it will be processed by the ItemController.php code on submit.

<!-- The rest of your HTML and forms -->

<div class="form-group">
    <label for="media">Media</label>
    <div class="needsclick dropzone" id="media-dropzone"></div>
</div>

 <!-- More of your HTML and forms -->

<script>

    //More of your javascript above

    //Repopulate dropzone upload with media
    function repopulateMedia(item){
        let dz = document.getElementById('media-dropzone').dropzone;

        item.media.forEach(function(media){

            dz.options.addedfile.call(dz, media);
            media.previewElement.classList.add('dz-complete');
            $('#items-form').append(
                '<div id="' + media.file_name + '">' +
                    '<input type="hidden" name="media[]" value="' + media.file_name + '">' +
                    '<input type="hidden" name="media_original_name[]" value="' + media.name + '">' +
                '</div>'
            );
            //Do not create a thumbnail, just show the one generated server-side
            dz.emit('thumbnail', media, '{{ route('api.media.show', ['mediaItem' => 'replaceMe', 'size' => 'thumb']) }}'.replace('replaceMe', media.id));
        });
    }

    //File uploads
    $("#media-dropzone").dropzone(buildAjaxRequest({//currently, the attributes filled by buildAjaxRequest are compatible with dropzones config attributes, so we'll use that to populate the headers.  If something breaks because additional fields are added that are only compatible with jquery AJAX or dropzone, it's own request builder helper might be needed
        url: '{{ route('api.management.media.store') }}',
        maxFilesize: 2, // MB
        maxFiles: 10,
        acceptedFiles: 'image/*',
        addRemoveLinks: true,
        success: function (file, response) {
            $('#items-form').append(
                '<div id="' + response.name + '">' +
                    '<input type="hidden" name="media[]" value="' + response.name + '">' +
                    '<input type="hidden" name="media_original_name[]" value="' + response.original_name + '">' +
                '</div>'
            );
            file.file_name = response.name;//set this so that removedFile can find the element
        },
        thumbnail:function(file, thumb){
            //Thumbnail callback will prevent thumbnail from being applied so the element
            // let thumbElement = $(file.previewElement).find('.dz-image').find('img[data-dz-thumbnail]:first');
            // thumbElement.attr("src", thumb);
            //instead we set the background of the element to the thumbnail image so we can resize it properly to fill the preview area
            $(file.previewElement).find('.dz-image:first').css({
                "background-size": "cover",
                'background-image': 'url(' + thumb + ')',
                });
        },
        error: function(file, message, xhr){
            if(xhr) handleHttpError(xhr);//If there was an upload error, http error handle it
            else {
                Swal.fire({
                    title: "Error Uploading Media",
                    text: message,
                    type: 'error',
                });
            }
            file.previewElement.remove();
        },
        removedfile: function (file) {
            file.previewElement.remove();
            //Form elements for media are grouped in divs with the file name as the ID so they can be removed at once
            if (typeof file.file_name !== 'undefined') $('#items-form').find('div[id="' + file.file_name + '"]').remove();
        },
        init: function () {
            //
        }
    }));

    //Probably more of your javascript below

</script>

app/Console/Kernel.php

If a file is uploaded but the user clicks out before saving the Item, it's not attached to the item and is left orphaned in the uploads folder, wasting space. This task removes them periodically using Laravel Directory Cleanup.

protected function schedule(Schedule $schedule)
{
    // More of your code

    $schedule->command('clean:directories')->daily();

    //More of your code
}

config/laravel-directory-cleanup.php

//More code

'directories' => [
    storage_path('app/uploads') => [
        'deleteAllOlderThanMinutes' => 60 * 24,
    ],
]

//Code continues

This code was lifted directly from a project in progress, so there's a bit of colour commentary included. Good luck!