Reusable upload component in Laravel with Dropzone.js

In this post we will create a blade component which we can reuse in any application where we want a file uploader, we will use Dropzone.js to add drag and drop uploading functionality, and laravel as a backend to store the uploaded file, It can upload an image or document which will be configurable using a config file. Let’s get started.

What is Laravel Blade Component

A component is laravel partials but they give you a clean way to pass data into partial view using slots. In fact, you can use any partial as a component, it was introduced in Laravel 5.4. Instead of @include('my-partial', ['title' => 'My Title']) you can use @component('my-partial') and you can pass data as array or you can use @slot('title') My Title @endslot() from inside component to pass the title. For a simple thing like title it’s better to stick with passing array but if you want to pass some HTML as the body of the component slot can help and keep it clean.

Now that you know whats a component lets build our uploader component.

Create a Laravel 5.5 App

Create a new Laravel 5.5 app and configure the database credentials in .env file. Once it’s installed run the php artisan make:auth to scaffold authentication, it’s optional but it will give you layout file with a home resources/views/home.blade.php  view which we will use to test our component later.

Now we need to fetch the dropzone, add "dropzone": "^5.1.1" in package.json as dependency and run npm install to complete the installation.

Let’s require it in resources/assets/js/bootstrap.js file by adding:

try {
    window.$ = window.jQuery = require('jquery');
    // require dropzone
    window.Dropzone = require('dropzone');
    require('bootstrap-sass');
} catch (e) {}

Once that is done, now copy the node_modules/dropzone/src/dropzone.scss into resources/assets/sass/ folder and import it in app.scss:

...
// Variables
@import "variables";

// Dropzone
@import "dropzone";
...

If you want you can customize the look of dropzone, you can do it by editing this dropzone.scss file, now run npm run watch to run the build script and watch for changes.

Build Dropzone uploader component

Let’s build our dropzone powered uploader, first create a file in resources/views/partials/uploader.blade.php, let’s add a minimum markup to add dropzone.

@php $dropzoneId = isset($dz_id) ? $dz_id : str_random(8); @endphp
<div id="{{$dropzoneId}}" class="dropzone">
    <div class="dz-default dz-message">
        <h3>{{ $title or  'Drop files here or click to upload.'}}</h3>
        <p class="text-muted">{{ $desc or 'Any related files you can upload' }} <br>
            <small>One file can be max {{ config('attachment.max_size', 0) / 1000 }} MB</small></p>
    </div>
</div>
<!-- Dropzone {{ $dropzoneId }} -->

@push('scripts')
<script>
    // Turn off auto discovery
    Dropzone.autoDiscover = false;

    $(function () {
        // Attach dropzone on element
        $("#{{ $dropzoneId }}").dropzone({
            url: "{{ route('attachments.store') }}",
            maxFilesize: {{ isset($maxFileSize) ? $maxFileSize : config('attachment.max_size', 1000) / 1000 }},
            acceptedFiles: "{!! isset($acceptedFiles) ? $acceptedFiles : config('attachment.allowed') !!}",
            headers: {'X-CSRF-TOKEN': "{{ csrf_token() }}"}
        });
    })
</script>
@endpush

We have added some configuration option like dz_id which will be unique dropzone container ID so we can have more than one uploader on a page. After that, we have dz-message container which we have given $title and $desc variable to customize with a default message.

Add @stack(‘scripts’) in resources/views/layouts/app.blade.php before closing body tag so we can push any scripts from blade view.

The first thing we have done in script block is turned off auto discovery, by default Dropzone will attach itself anything which has class dropzone applied to, we don’t want that so we set it to false.

In dropzone configuration, we have given options to pass the maxFilesizeacceptedFiles etc to further customize individual instance according to your need.

Config file for attachment

Above view file has used config('attachment.allowed') and max_size to customize the uploader, let’s create the config file and add some settings:

return [
    // Allowed file types with . prefix
    'allowed' => '.pdf,.doc,.xls,.docx,.xlsx,.jpg,.png,.gif,.jpeg',

    // Max file size in KB
    'max_size' => 5000
];

Now component is ready, let’s use it in resources/views/home.blade.php view.

Register and login to see the dashboard where we will show the uploader component.

We can use it both ways, using @include() or using @component() directive.

@extends('layouts.app')

@section('content')
<div class="container">
    @component('partials.uploader')
        @slot('title') 
            Upload only photos 
        @endslot
        @slot('acceptedFiles', '.jpg,.png')
    @endcomponent
    <!-- OR -- >
    @include('partials.uploader', [
        'title' => 'Upload only photos ',
        'acceptedFiles' => '.jpg,.png'
    ])
</div>
@endsection

Next, we need to set the attachment route. Since we already created a resource controller we can open the routes/web.php and register the route.

Route::apiResource('attachments', 'AttachmentController');

If everything went well you should see an uploader like this:

dropzone uploader

Currently its not actually uploading anything to the server lets setup laravel backend to save files.

Note: I have done some styling in resources/assets/sass/dropzone.scss which you can get from repo to make it look better.

Model & Migration to store uploaded files

We want it to be reusable so I am going to create Attachment model since any upload is most likely be to related (attached) to some other entity, like a post can have many images as upload. Let’s create the model with migration and a resource controller by running php artisan mak:model Attachment -mr and update the migration like this.

Schema::create('attachments', function (Blueprint $table) {
    $table->increments('id');
    $table->string('filename');
    $table->string('uid');
    $table->integer('size');
    $table->string('mime', 100);
    $table->morphs('attachable');
    $table->timestamps();
});

We are storing filename which will be client original filename and the uid which is going to be returned by laravel filesystem upon file save. One thing to note I am using polymorphic relation so we can accept upload on different types of Model.

Attachment Model

This model will be responsible to store and keep the file system in sync with rows, if a user deletes an attachment row, the related file will be also deleted by hooking into eloquent model event.

class Attachment extends Model
{
    protected $guarded = [];

    protected $appends = ['url'];

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

    public function getUrlAttribute()
    {
        return Storage::url($this->uid);
    }

    public static function boot()
    {
        parent::boot();

        static::deleting(function($attachment){
            // delete associated file from storage
            Storage::disk('public')->delete($attachment->uid);
        });
    }
}

Let’s create the Post model as well with migration using php artisan make:model Post -m so we can associate the uploaded image to it.

protected $fillable = ['title', 'body', 'user_id'];

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

Upload file in Laravel

Enough with the model setup let’s open the AttachmentController and implement the store method.

public function store(Request $request)
{
    $request->validate([
        'file' => 'required|file|max:5000|mimes:' . $this->getAllowedFileTypes(),
        'attachable_id' => 'required|integer',
        'attachable_type' => 'required',
    ]);

    // save the file
    if ( $fileUid = $request->file->store('/upload', 'public') ) {
        return Attachment::create([
            'filename' => $request->file->getClientOriginalName(),
            'uid' => $fileUid,
            'size' => $request->file->getClientSize(),
            'mime' => $request->file->getMimeType(),
            'attachable_id' => $request->get('attachable_id'),
            'attachable_type' => $request->get('attachable_type'),
        ]);
    }

    return response(['msg' => 'Unable to upload your file.'], 400);
}

/**
 * Remove . prefix so laravel validator can use allowed files
 * 
 * @return string
 */
private function getAllowedFileTypes()
{
    return str_replace('.', '', config('attachment.allowed', ''));
}

We validate the request for a file and check the mime type is in our configuration allowed list. We are also asking for attachable_id and attachable_type which will be post id and App\Post in this case to associate an upload with the Post.

Next, we call store method on file to move the uploaded file to laravel default public disk, which is configured in config/filesystems.php, you can change that to some other disk if you like.

Run php artisan storage:link to create symlink for public disk

Once the file is uploaded it will give the unique file id/path which we then store in DB along with filename, size, and mime type and attachable info.

Send Additional field with dropzone upload request

It’s most likely you will need to give some option to pass additional data along with file upload, luckily dropzone has params: {} option which we can add fields that will be sent with the file. Open the uploader.blade.php and add this below headers.

...
headers: {'X-CSRF-TOKEN': "{{ csrf_token() }}"},
params: {!! isset($params) ? json_encode($params) : '{}'  !!},
...

To send additional data using component we pass it ask key value pair.

@component('partials.uploader', [
    'title' => 'Upload Post Images',
    'params' => [
        'attachable_id' => 1,
        'attachable_type' => 'App\Post'
    ],
    'acceptedFiles' => '.jpg,.png'
])
@endcomponent

Finally, if you try to upload, it should upload the file with correct relation and file. Make sure you ran php artisan migrate to setup posts table beforehand.

Show files already stored on server

Dropzone gives tons of option and you can customize it on every step using its event system. Since we have uploaded file for a Post, we can get the files using $post->attachments relation. Now to show them in dropzone UI we need to hook into init event call and add already uploaded files.

...
params: {!! isset($params) ? json_encode($params) : '{}'  !!},
init: function () {
    // uploaded files
    var uploadedFiles = [];
    
    @if(isset($uploadedFiles) && count($uploadedFiles))

    // show already uploaded files
    uploadedFiles = {!! json_encode($uploadedFiles) !!};
    var self = this;

    uploadedFiles.forEach(function (file) {
        // Create a mock uploaded file:
        var uploadedFile = {
            name: file.filename,
            size: file.size,
            type: file.mime,
            dataURL: file.url
        };

        // Call the default addedfile event
        self.emit("addedfile", uploadedFile);

        // Image? lets make thumbnail
        if( file.mime.indexOf('image') !== -1) {

            self.createThumbnailFromUrl(
                uploadedFile,
                self.options.thumbnailWidth,
                self.options.thumbnailHeight,
                self.options.thumbnailMethod,
                true, function(thumbnail) {
                    self.emit('thumbnail', uploadedFile, thumbnail);
            });

        } else {
            // we can get the icon for file type
            self.emit("thumbnail", uploadedFile, getIconFromFilename(uploadedFile));
        }

        // fire complete event to get rid of progress bar etc
        self.emit("complete", uploadedFile);
    })

    @endif
}

// add following in the Global scope //

// Get Icon for file type
function getIconFromFilename(file) {
    // get the extension
    var ext = file.name.split('.').pop().toLowerCase();

    // if its not an image
    if( file.type.indexOf('image') === -1 ) {

        // handle the alias for extensions
        if(ext === 'docx') {
            ext = 'doc'
        } else if (ext === 'xlsx') {
            ext = 'xls'
        }

        return "/images/icon/"+ext+".svg";
    }

    // return a placeholder for other files
    return '/images/icon/txt.svg';
}

Now we need to pass the uploaded files in the component. Above javascript code is well commented you can understand what it does. Before testing open the HomeConroller@index and pass the $post into view like return view('home')->with('post', Post::find(1)); and now we can access all attachment for a post like this:

@component('partials.uploader', [
    ....
    'acceptedFiles' => '.jpg,.png',
    'uploadedFiles' => $post->attachments->toArray()
])
@endcomponent

Show Icons for documents in dropzone

By default, dropzone will show a thumbnail for images but any none image file will be presented as gray boxes, let’s hook into addedfile event inside dropzone’s init function to show the icon, you have already see the implementation of getIconFromFilename(file) above.

// Handle added file
this.on('addedfile', function(file) {
    var thumb = getIconFromFilename(file);
    $(file.previewElement).find(".dz-image img").attr("src", thumb);
})

You will need some icons for each file type, I have used svg icon since they are scalable and light, but any square image will work preferably 128px in size. Icons are named after the file extension, like doc.svg, xls.svg, pdf.svg etc. Place icons in public/images folder. If you want to change the path please tweak the getIconFromFilename(file) function to point to the new location for icons.

Now since we can list uploaded files, let’s try to add an option to remove a file. Once a user clicks it, the file will be deleted from the dropzone UI and from the server.

Remove a uploaded file

Removing a file is very easy, dropzone gives an option to add remove file functionality, just add addRemoveLinks: true in configuration after the url:

$("#{{ $dropzoneId }}").dropzone({
    url: "{{ route('attachments.store') }}",
    addRemoveLinks: true,
    ...

Next thing is to delete the removed file from the server. In init function add an event listener for removefile event.

// handle remove file to delete on server
this.on("removedfile", function (file) {
    // try to find in uploadedFiles
    var found = uploadedFiles.find(function (item) {
        // check if filename and size matched
        return (item.filename === file.name) && (item.size === file.size);
    })

    // If got the file lets make a delete request by id
    if( found ) {
        $.ajax({
            url: "/attachments/" + found.id,
            type: 'DELETE',
            headers: {
                'X-CSRF-TOKEN': "{{ csrf_token() }}"
            },
            success: function(response) {
                console.log('deleted');
            }
        });
    }
});

In above handler we try to find the file in uploaded files array, if we found it we make an ajax call to attachments/{id} endpoint and delete it. Let’s implement the destroy method on AttachmentController.

public function destroy(Attachment $attachment)
{
    return (string) $attachment->delete();
}

I have kept it very simple for brevity, but in real world app it must authorized any action which changes your data in any way.

Error Handling

Things can go wrong on the server, if validation failed currently our API response will not be in a format which dropzone can render properly, we need to again hook into ‘error‘ event from init function to handle error properly.

// Handle errors
this.on('error', function(file, response) {
    var errMsg = response;
    if( response.message ) errMsg = response.message;
    if( response.file ) errMsg = response.file[0];

    $(file.previewElement).find('.dz-error-message').text(errMsg);
});

With that, we have a reusable component/partial to upload any document and images and list all the uploaded files with lots of configuration option. All the source code is available on GitHub, please check and don’t forget to share and comment if you find it useful.

Source Code

  • Hi,

    I try make it work for my products page instead of posts and I changed `attachable_type’ => ‘AppPost’` to `attachable_type’ => ‘AppProduct’` but I get `Undefined variable: product` error, I also want to use public_path storage instead of app storage where should i setting that?

    Thanks.

    • Saqueib

      hi Robert, it seems you have forgot to pass $product variable into view, I can help if you can share the code over github. also by default its going to upload directly into public storage, you just need to run php artisan storage:link and all the uploaded files will be accessible publicly.