Laravel Upload Large File (10 GB+) with Resumablejs and Laravel Chunk Upload

Efficiently handling file uploads, particularly for large files, is crucial as it is an essential aspect of nearly all web applications. It is a common issue for us.

We occasionally make changes to the php.ini configuration, but it does not function properly. An example:

upload_max_filesize = 5G
post_max_size = 3G
max_input_time = 3000
max_execution_time = 3000

In this article, I'm going to share an efficient way to upload large file with resumable.js and laravel-chunk-upload. As a result, I can now upload files as large as 10GB or more without encountering any problems.

Note: Last tested on Laravel 10.6.2

Table of Contents

  1. Install Laravel Chunk Upload
  2. Setup ResumableJs in Blade
  3. Create Controller and Setup Routes
  4. Setup Controller
  5. Run the App

Install Laravel Chunk Upload

We will configure our Laravel backend to receive and merge chunks using laravel-chunk-upload. Install it using composer:

composer require pion/laravel-chunk-upload

Then publish the config (optional)

php artisan vendor:publish --provider="Pion\Laravel\ChunkUpload\Providers\ChunkUploadServiceProvider"

Setup ResumableJs in Blade

Resumable.js is a JavaScript library providing multiple simultaneous, stable, and resumable uploads via the HTML5 File API. The library is designed to introduce fault tolerance into the upload of large files through HTTP. This is done by splitting each file into small chunks.

Let's create a upload.blade.php blade file. For this article, we wll use the Bootstrap 5 starter template. Write HTML code to make the file upload UI:

<div class="container pt-4">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <div class="card">
                <div class="card-header text-center">
                    <h5>Upload File</h5>
                </div>

                <div class="card-body">
                    <div id="upload-container" class="text-center">
                        <button id="browseFile" class="btn btn-primary">Browse File</button>
                    </div>
                    <div style="display: none" class="progress mt-3" style="height: 25px">
                        <div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" aria-valuenow="75" aria-valuemin="0" aria-valuemax="100" style="width: 75%; height: 100%">75%</div>
                    </div>
                </div>

                <div class="card-footer p-4" style="display: none">
                    <img id="imagePreview" src="" style="width: 100%; height: auto; display: none" alt="img"/>
                    <video id="videoPreview" src="" controls style="width: 100%; height: auto; display: none"></video>
                </div>
            </div>
        </div>
    </div>
</div>

Now, we need to import resumablejs CDN and jQuery. jQuery is optional. You can replace jQuery code with vanilla JS.

<script src="https://cdnjs.cloudflare.com/ajax/libs/resumable.js/1.1.0/resumable.min.js"></script>
<script src="https://code.jquery.com/jquery-3.6.4.min.js"></script>

Write JavaScript code to enable uploading of large files in chunks via a resumablejs.

<script type="text/javascript">
    let browseFile = $('#browseFile');
    let resumable = new Resumable({
        target: '{{ route('upload.store') }}',
        query: {_token: '{{ csrf_token() }}'},
        fileType: ['png', 'jpg', 'jpeg', 'mp4'],
        chunkSize: 2 * 1024 * 1024, // default is 1*1024*1024, this should be less than your maximum limit in php.ini
        headers: {
            'Accept': 'application/json'
        },
        testChunks: false,
        throttleProgressCallbacks: 1,
    });

    resumable.assignBrowse(browseFile[0]);

    resumable.on('fileAdded', function (file) { // trigger when file picked
        showProgress();
        resumable.upload() // to actually start uploading.
    });

    resumable.on('fileProgress', function (file) { // trigger when file progress update
        updateProgress(Math.floor(file.progress() * 100));
    });

    resumable.on('fileSuccess', function (file, response) { // trigger when file upload complete
        response = JSON.parse(response)

        if (response.mime_type.includes("image")) {
            $('#imagePreview').attr('src', response.path + '/' + response.name).show();
        }

        if (response.mime_type.includes("video")) {
            $('#videoPreview').attr('src', response.path + '/' + response.name).show();
        }

        $('.card-footer').show();
    });

    resumable.on('fileError', function (file, response) { // trigger when there is any error
        alert('file uploading error.')
    });

    let progress = $('.progress');

    function showProgress() {
        progress.find('.progress-bar').css('width', '0%');
        progress.find('.progress-bar').html('0%');
        progress.find('.progress-bar').removeClass('bg-success');
        progress.show();
    }

    function updateProgress(value) {
        progress.find('.progress-bar').css('width', `${value}%`)
        progress.find('.progress-bar').html(`${value}%`)

        if (value === 100) {
            progress.find('.progress-bar').addClass('bg-success');
        }
    }

    function hideProgress() {
        progress.hide();
    }
</script>

So, the final upload.blade.php file looks like this:

upload.blade.php
<!doctype html>
<html lang="en">
<head>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <!-- Bootstrap CSS -->
    <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">

    <title>Resumablejs + Laravel Chunk Upload</title>

    <script src="https://cdnjs.cloudflare.com/ajax/libs/resumable.js/1.1.0/resumable.min.js"></script>
    <script src="https://code.jquery.com/jquery-3.6.4.min.js"></script>
</head>
<body>

<div class="container pt-4">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <div class="card">
                <div class="card-header text-center">
                    <h5>Upload File</h5>
                </div>

                <div class="card-body">
                    <div id="upload-container" class="text-center">
                        <button id="browseFile" class="btn btn-primary">Browse File</button>
                    </div>
                    <div style="display: none" class="progress mt-3" style="height: 25px">
                        <div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" aria-valuenow="75" aria-valuemin="0" aria-valuemax="100" style="width: 75%; height: 100%">75%</div>
                    </div>
                </div>

                <div class="card-footer p-4" style="display: none">
                    <img id="imagePreview" src="" style="width: 100%; height: auto; display: none" alt="img"/>
                    <video id="videoPreview" src="" controls style="width: 100%; height: auto; display: none"></video>
                </div>
            </div>
        </div>
    </div>
</div>

<script type="text/javascript">
    let browseFile = $('#browseFile');
    let resumable = new Resumable({
        target: '{{ route('upload.store') }}',
        query: {_token: '{{ csrf_token() }}'},
        fileType: ['png', 'jpg', 'jpeg', 'mp4'],
        chunkSize: 2 * 1024 * 1024, // default is 1*1024*1024, this should be less than your maximum limit in php.ini
        headers: {
            'Accept': 'application/json'
        },
        testChunks: false,
        throttleProgressCallbacks: 1,
    });

    resumable.assignBrowse(browseFile[0]);

    resumable.on('fileAdded', function (file) { // trigger when file picked
        showProgress();
        resumable.upload() // to actually start uploading.
    });

    resumable.on('fileProgress', function (file) { // trigger when file progress update
        updateProgress(Math.floor(file.progress() * 100));
    });

    resumable.on('fileSuccess', function (file, response) { // trigger when file upload complete
        response = JSON.parse(response)

        if (response.mime_type.includes("image")) {
            $('#imagePreview').attr('src', response.path + '/' + response.name).show();
        }

        if (response.mime_type.includes("video")) {
            $('#videoPreview').attr('src', response.path + '/' + response.name).show();
        }

        $('.card-footer').show();
    });

    resumable.on('fileError', function (file, response) { // trigger when there is any error
        alert('file uploading error.')
    });

    let progress = $('.progress');

    function showProgress() {
        progress.find('.progress-bar').css('width', '0%');
        progress.find('.progress-bar').html('0%');
        progress.find('.progress-bar').removeClass('bg-success');
        progress.show();
    }

    function updateProgress(value) {
        progress.find('.progress-bar').css('width', `${value}%`)
        progress.find('.progress-bar').html(`${value}%`)

        if (value === 100) {
            progress.find('.progress-bar').addClass('bg-success');
        }
    }

    function hideProgress() {
        progress.hide();
    }
</script>

</body>
</html>

Create Controller and Setup Routes

Create a controller named UploadController and define the routes in routes/web.php file.

web.php
use App\Http\Controllers\UploadController;
use Illuminate\Support\Facades\Route;

Route::get('upload', [UploadController::class, 'index'])->name('upload.index');
Route::post('upload', [UploadController::class, 'store'])->name('upload.store');

Setup Controller

Create the index function to show the view:

public function index()
{
    return view('upload');
}

Create a store function to accept the requests from resumablejs.

public function store(Request $request)
{
    // create the file receiver
    $receiver = new FileReceiver("file", $request, HandlerFactory::classFromRequest($request));

    // check if the upload is success, throw exception or return response you need
    if ($receiver->isUploaded() === false) {
        throw new UploadMissingFileException();
    }

    // receive the file
    $save = $receiver->receive();

    // check if the upload has finished (in chunk mode it will send smaller files)
    if ($save->isFinished()) {
        // save the file and return any response you need, current example uses `move` function. If you are
        // not using move, you need to manually delete the file by unlink($save->getFile()->getPathname())
        return $this->saveFile($save->getFile());
    }

    // we are in chunk mode, lets send the current progress
    $handler = $save->handler();

    return response()->json([
        "done" => $handler->getPercentageDone(),
        'status' => true
    ]);
}

To create a unique filename for the uploaded file, create a function called createFilename:

protected function createFilename(UploadedFile $file)
{
    $extension = $file->getClientOriginalExtension();
    $filename = str_replace("." . $extension, "", $file->getClientOriginalName()); // Filename without extension

    // Add timestamp hash to name of the file
    $filename .= "_" . md5(time()) . "." . $extension;

    return $filename;
}

To save files in local storage, create a method:

protected function saveFile(UploadedFile $file)
{
    $fileName = $this->createFilename($file);

    // Group files by mime type
    $mime = str_replace('/', '-', $file->getMimeType());

    // Group files by the date (week
    $dateFolder = date("Y-m-W");

    // Build the file path
    $filePath = "upload/{$mime}/{$dateFolder}";
    $finalPath = storage_path("app/public/" . $filePath);

    // move the file name
    $file->move($finalPath, $fileName);

    return response()->json([
        'path' => asset('storage/' . $filePath),
        'name' => $fileName,
        'mime_type' => $mime
    ]);
}

Now, if you want to save files directly to Amazon S3 storage, create saveFileToS3 function:

protected function saveFileToS3($file)
{
    $fileName = $this->createFilename($file);
    $disk = Storage::disk('s3');
    
    // It's better to use streaming (laravel 5.4+)
    $disk->putFileAs('photos', $file, $fileName);

    // for older laravel
    // $disk->put($fileName, file_get_contents($file), 'public');
    
    $mime = str_replace('/', '-', $file->getMimeType());

    // We need to delete the file when uploaded to s3
    unlink($file->getPathname());

    return response()->json([
        'path' => $disk->url($fileName),
        'name' => $fileName,
        'mime_type' => $mime
    ]);
}

So, the final controller looks like this:

UploadController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;

use Pion\Laravel\ChunkUpload\Handler\HandlerFactory;
use Pion\Laravel\ChunkUpload\Receiver\FileReceiver;
use Pion\Laravel\ChunkUpload\Exceptions\UploadMissingFileException;
use Pion\Laravel\ChunkUpload\Exceptions\UploadFailedException;

class UploadController extends Controller
{
    public function index()
    {
        return view('upload');
    }

    /**
     * Handles the file upload
     *
     * @param Request $request
     *
     * @return JsonResponse
     *
     * @throws UploadMissingFileException
     * @throws UploadFailedException
     */
    public function store(Request $request)
    {
        // create the file receiver
        $receiver = new FileReceiver("file", $request, HandlerFactory::classFromRequest($request));

        // check if the upload is success, throw exception or return response you need
        if ($receiver->isUploaded() === false) {
            throw new UploadMissingFileException();
        }

        // receive the file
        $save = $receiver->receive();

        // check if the upload has finished (in chunk mode it will send smaller files)
        if ($save->isFinished()) {
            // save the file and return any response you need, current example uses `move` function. If you are
            // not using move, you need to manually delete the file by unlink($save->getFile()->getPathname())
            return $this->saveFile($save->getFile());
        }

        // we are in chunk mode, lets send the current progress
        $handler = $save->handler();

        return response()->json([
            "done" => $handler->getPercentageDone(),
            'status' => true
        ]);
    }

    /**
     * Saves the file to S3 server
     *
     * @param UploadedFile $file
     *
     * @return JsonResponse
     */
    protected function saveFileToS3($file)
    {
        $fileName = $this->createFilename($file);
        $disk = Storage::disk('s3');

        // It's better to use streaming (laravel 5.4+)
        $disk->putFileAs('photos', $file, $fileName);

        // for older laravel
        // $disk->put($fileName, file_get_contents($file), 'public');

        $mime = str_replace('/', '-', $file->getMimeType());

        // We need to delete the file when uploaded to s3
        unlink($file->getPathname());

        return response()->json([
            'path' => $disk->url($fileName),
            'name' => $fileName,
            'mime_type' => $mime
        ]);
    }

    /**
     * Saves the file
     *
     * @param UploadedFile $file
     *
     * @return JsonResponse
     */
    protected function saveFile(UploadedFile $file)
    {
        $fileName = $this->createFilename($file);

        // Group files by mime type
        $mime = str_replace('/', '-', $file->getMimeType());

        // Group files by the date (week
        $dateFolder = date("Y-m-W");

        // Build the file path
        $filePath = "upload/{$mime}/{$dateFolder}";
        $finalPath = storage_path("app/public/" . $filePath);

        // move the file name
        $file->move($finalPath, $fileName);

        return response()->json([
            'path' => asset('storage/' . $filePath),
            'name' => $fileName,
            'mime_type' => $mime
        ]);
    }

    /**
     * Create unique filename for uploaded file
     * @param UploadedFile $file
     * @return string
     */
    protected function createFilename(UploadedFile $file)
    {
        $extension = $file->getClientOriginalExtension();
        $filename = str_replace("." . $extension, "", $file->getClientOriginalName()); // Filename without extension

        // Add timestamp hash to name of the file
        $filename .= "_" . md5(time()) . "." . $extension;

        return $filename;
    }
}

Run the App

Run the app, go to localhost/upload route and test:

My wish is that this article will assist you in uploading sizable files in Laravel. I've shared the code on GitHub. You may take a look. Thank you.