pion/laravel-chunk-upload
Laravel Chunk Upload simplifies large file uploads by splitting files into chunks. It supports resumable uploads, multiple JavaScript frontends such as Dropzone and FilePond, and integrates smoothly with Laravel storage, validation, and cleanup.
Install and publish config
composer require pion/laravel-chunk-upload
php artisan vendor:publish --provider="Pion\Laravel\ChunkUpload\Providers\ChunkUploadServiceProvider" --tag=config
Open config/chunk-upload.php first — it holds the disk/path used for in-flight chunks and the handler defaults. Read it before writing any code; the key names have drifted between releases, so trust the published file over blog posts.
Where to look first (in this order)
config/chunk-upload.php — chunk storage disk, path, cleanup settings.src/Receiver/FileReceiver.php — the object you actually instantiate.src/Handler/ — one class per JS provider (DropZoneUploadHandler, jQueryFileUploadHandler, ResumableJSHandler, PUploadHandler, SingleUploadHandler). Skim the one matching your frontend; it tells you which request parameters the package reads.src/Handler/HandlerFactory.php — how the handler is auto-detected from the request.Minimal working endpoint
// routes/web.php
Route::match(['get', 'post'], '/upload', [UploadController::class, 'upload'])->name('upload');
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Pion\Laravel\ChunkUpload\Exceptions\UploadMissingFileException;
use Pion\Laravel\ChunkUpload\Handler\HandlerFactory;
use Pion\Laravel\ChunkUpload\Receiver\FileReceiver;
class UploadController extends Controller
{
public function upload(Request $request)
{
$receiver = new FileReceiver('file', $request, HandlerFactory::classFromRequest($request));
if (! $receiver->isUploaded()) {
throw new UploadMissingFileException();
}
$fileReceived = $receiver->receive();
// Intermediate chunk: report progress and bail out.
if (! $fileReceived->isFinished()) {
$handler = $fileReceived->handler();
return response()->json([
'done' => $handler->getPercentageDone(),
'status' => true,
'_chunkSize' => $handler->getChunkSize(),
]);
}
// Final chunk: the full file has been assembled on disk.
$file = $fileReceived->getFile();
$name = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME)
. '-' . uniqid()
. '.' . $file->getClientOriginalExtension();
$path = $file->storeAs('uploads', $name, 'local');
$fileReceived->deleteChunkedFile(); // only AFTER the move succeeds
return response()->json(['path' => $path, 'name' => $name]);
}
}
First real use case: a 1–2 GB video upload to private storage from a DropZone widget. DropZone with chunking: true posts N requests to your endpoint; the first N‑1 return a percentage, the last one returns the stored path. Add X-CSRF-TOKEN to the DropZone headers or add the route to VerifyCsrfToken::$except.
One endpoint, many frontends. HandlerFactory::classFromRequest($request) sniffs the payload (dzchunkindex, resumableChunkNumber, chunk/chunks, etc.) and picks the right handler. That means a single controller can serve DropZone today and resumable.js tomorrow without changing the server. If sniffing is ambiguous, pin it:
$receiver = new FileReceiver('file', $request, DropZoneUploadHandler::class);
// or: HandlerFactory::classFromName('dropzone')
Prefer pinning when you control the frontend — it turns a silent misdetection into an obvious failure.
Keep the request thin, offload the rest. The final chunk triggers an assembly + storeAs, which is disk-bound. Don't also generate thumbnails, probe video duration, or scan for viruses inline. Dispatch a job:
if ($fileReceived->isFinished()) {
$path = $fileReceived->getFile()->storeAs('uploads', $name, 'local');
$fileReceived->deleteChunkedFile();
ProcessUpload::dispatch($path, $request->user()->id);
return response()->json(['path' => $path, 'name' => $name]);
}
Validation before the last byte. Reject bad uploads early rather than after 800 MB of transfer:
$handler = $fileReceived->handler();
abort_if($handler->getTotalSize() > 2 * 1024 ** 3, 422, 'File too large');
$ext = strtolower($fileReceived->getFile()->getClientOriginalExtension());
abort_unless(in_array($ext, ['mp4', 'mov', 'mkv']), 422, 'Unsupported type');
Run this on the first chunk ($handler->isFirstChunk()) so the client stops immediately.
Tie the upload to a domain object. The package knows nothing about your models. Put a client-generated upload token in the request (resumable.js gives you resumableIdentifier; for DropZone send a custom params value), stash it in the session or a cache key, and read it back when isFinished() is true to attach the resulting path to a Media/Document row.
Resumability contract. For resumable.js, the route must answer GET on the same URL so the client can ask "do you already have chunk 7?". Register Route::match(['get','post'], ...) and let the handler respond — this is what makes reload-and-continue work. For DropZone, resuming means configuring chunking plus a stable uploadId; verify the behaviour rather than assuming it.
Testing. Fake the disk and drive chunks manually; it's the only reliable way to test the isFinished() branch:
Storage::fake('local');
foreach (range(1, 3) as $i) {
$this->post('/upload', [
'file' => UploadedFile::fake()->create('clip.mp4', 100),
'dzchunkindex' => $i - 1,
'dztotalchunkcount' => 3,
]);
}
Assert the final file exists in Storage::disk('local') and that the chunk directory is empty. Note the fake uploaded file's contents repeat across chunks — fine for exercising the plumbing, not for asserting byte-exact assembly.
upload_max_filesize, post_max_size, client_max_body_size, and the proxy read timeout all constrain a single chunk, not the total. If chunks 413, the problem is server config, not the package. Chunk sizes of 5–10 MB are a sane default for large media.max_execution_time. Appending a 2 GB file to disk inside a web request is slow on network-backed storage. Either raise the timeout on that route only, or keep the chunk storage local and the destination disk separate.s3 unless you've measured it.deleteChunkedFile() after the move, never before. It's the only copy of the assembled data until storeAs returns. Wrap the move in a try/catch and only delete on success — otherwise a failed move silently loses the upload.ls on the chunks path; otherwise schedule your own Storage::disk('local')->deleteDirectory(...) for anything older than 24 h.UploadedFile-shaped object with the client's original name. Hash or UUID it — two users uploading video.mp4 will otherwise overwrite each other.$request->all() behind a debug flag on first integration; the parameter names tell you instantly whether DropZone/resumable/jQuery was detected.headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content }, or exclude the route. Same for pupload/resumable.storeAs twice. The move consumes/relocates the file; a second call on the same object fails. Compute the path once and reuse the string.AbstractHandler (or a concrete handler) to add custom progress fields in the JSON response, and wrap the whole controller body to add per-user quota checks / rate limiting — the package deliberately does no authorization, so auth middleware and a Gate check on the upload route are required, not optional.How can I help you explore Laravel packages today?