DEV3 Technologies home

Chunked upload endpoint

Accept large files in small indexed parts, store them in a temp folder and assemble them on completion, so uploads survive flaky connections.

LaravelUploadsuploadchunkslarge-filesresumable

Code

<?php

namespace App\Http\Controllers;

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

class ChunkedUploadController extends Controller
{
    public function store(Request $request, string $id): JsonResponse
    {
        abort_unless(Str::isUuid($id), 404); // keeps the path safe

        $data = $request->validate([
            'index' => ['required', 'integer', 'min:0', 'max:9999'],
            'chunk' => ['required', 'file', 'max:10240'], // 10 MB per part
        ]);

        // Same index twice simply overwrites: retries are harmless.
        Storage::disk('private')->putFileAs("chunks/{$id}", $data['chunk'], (string) $data['index']);

        return response()->json(['stored' => $data['index']]);
    }

    public function complete(Request $request, string $id): JsonResponse
    {
        abort_unless(Str::isUuid($id), 404);

        $data = $request->validate(['total' => ['required', 'integer', 'min:1', 'max:10000'], 'name' => ['required', 'string', 'max:200']]);
        $disk = Storage::disk('private');

        for ($i = 0; $i < $data['total']; $i++) {
            abort_unless($disk->exists("chunks/{$id}/{$i}"), 422, "Part {$i} is missing.");
        }

        // Stitch the parts together as streams so memory use stays flat.
        $target = fopen('php://temp', 'w+b');
        for ($i = 0; $i < $data['total']; $i++) {
            stream_copy_to_stream($disk->readStream("chunks/{$id}/{$i}"), $target);
        }
        rewind($target);

        $path = 'files/'.Str::random(12).'-'.Str::slug(pathinfo($data['name'], PATHINFO_FILENAME)).'.'.pathinfo($data['name'], PATHINFO_EXTENSION);
        $disk->writeStream($path, $target);
        fclose($target);
        $disk->deleteDirectory("chunks/{$id}");

        return response()->json(['path' => $path], 201);
    }
}

How it works

What it solves

A single multipart request for a 4 GB file hits PHP's size limits, proxy timeouts and the user's patience. Splitting the file into small parts lets the browser retry only the failed part and resume after a dropped connection, while the server never needs a large upload_max_filesize.

How it works

The client picks an upload id (a UUID) and posts each part to store() with its zero-based index and the total part count. Parts are written to chunks/<id>/<index> on a private disk. Writing the same index twice just overwrites it, which makes retries idempotent.

When all parts have arrived the client calls complete(). The controller checks that every index from zero to the total exists, then appends the parts in order into one temp stream and moves the result into permanent storage. Reading and writing via streams keeps memory flat regardless of file size. Finally the chunk folder is deleted.

The upload id is validated as a UUID before it is ever used in a path, so it cannot be used for directory traversal.

Adapting it

Associate the upload id with the logged in user in a table so one person cannot complete another's upload. Add a size ceiling and a scheduled command that deletes chunk folders older than a day.

Pitfalls

  • Never trust the client's total; compare against what is on disk.
  • Keep chunks small enough for your proxy, 5 to 10 MB is typical.
  • Verify a checksum on completion if integrity matters.

How to use it

  1. Define a private disk in config/filesystems.php (local or S3).
  2. Copy the controller and route POST /uploads/{id}/chunks to store, POST /uploads/{id}/complete to complete.
  3. In the browser slice the file with file.slice(start, end) and post each part with index and total.
  4. Schedule cleanup of chunks/* folders older than 24 hours.
  5. After complete returns a path, create your database record for the file.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets