Implementing the TUS Resumable Upload Protocol in Laravel
Language Mismatch Disclaimer: Please be aware that the language of this article may not match the language settings of your browser or device.
Do you want to read articles in English instead ?
Plain form uploads have one failure mode that quietly ruins large files. A user picks a 3 GB video, the browser streams it in a single request, and at 80 percent a VPN hop drops the connection. The whole transfer is gone. The next attempt starts again from zero, and on a genuinely flaky link the file may never land at all. No amount of raising upload_max_filesize fixes this, because the problem is not the size limit. It is that the transfer has no memory.
The TUS protocol gives it one. TUS is an open standard for resumable uploads built on plain HTTP. The idea is small: the server tracks how many bytes of a file it has already accepted, and a client that reconnects asks "how far did I get?" before sending anything. If the answer is 80 percent, the client sends the last 20 percent and nothing more.
I have shipped this pattern more than once, and it carried multi-gigabyte uploads on an enterprise content platform where offices uploaded large media assets over corporate proxies that timed out long requests. This post is the generic version of that mechanism: a working Laravel 12 implementation you can read end to end. You do not need a package to terminate TUS. It is three routes and one database row.
The protocol in one paragraph
TUS uses standard HTTP verbs with a few custom headers, all prefixed Upload- and Tus-. A client creates an upload with POST and gets back a URL. It reads the current server-side offset with HEAD. It sends the next chunk of bytes with PATCH, including the byte offset it believes it is writing at, and the server replies with the new offset. When the offset equals the declared total length, the upload is complete. That is the entire core. Resumption is not a bolt-on feature. It falls out of the fact that offset lives on the server.
Persisting the offset
Everything hinges on one record per upload. I keep it in its own table.
// database/migrations/xxxx_create_uploads_table.php
Schema::create('uploads', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->unsignedBigInteger('offset')->default(0);
$table->unsignedBigInteger('length'); // total size, declared up front
$table->string('filename');
$table->string('mime_type');
$table->string('storage_path')->nullable(); // set once assembled to S3
$table->timestamp('completed_at')->nullable();
$table->timestamps();
});
The offset column is the source of truth. Not the size of a temp file on disk, not a value the client sends. The server owns it, advances it under a transaction, and never trusts the client's word for it. Everything else is bookkeeping.
Terminating TUS: the routes
Three routes, one controller. TUS also expects an OPTIONS response advertising what the server supports, so I add a fourth for discovery.
// routes/web.php
Route::prefix('files')->group(function () {
Route::options('/', [TusController::class, 'options']);
Route::post('/', [TusController::class, 'create']);
Route::match(['HEAD'], '/{upload}', [TusController::class, 'head'])->name('files.head');
Route::patch('/{upload}', [TusController::class, 'patch'])->name('files.patch');
});
Two things about serving this in Laravel. First, PATCH bodies are raw bytes with the content type application/offset+octet-stream, so I read the request stream directly rather than going through form parsing. Second, TUS requires every response to carry Tus-Resumable: 1.0.0, which is cleanest as a middleware on the group. I have left that off the snippets to keep them focused, but it is one ->header() call in an after middleware.
POST: create the upload, and enforce limits early
Creation is where I refuse anything I am not willing to accept. This is the important design point: I validate size and type at creation, before a single byte of payload arrives. There is no reason to spend bandwidth streaming a 5 GB file only to reject it at the end.
public function create(Request $request)
{
$length = (int) $request->header('Upload-Length');
$metadata = $this->decodeMetadata($request->header('Upload-Metadata'));
if ($length <= 0 || $length > self::MAX_BYTES) {
return response('', 413); // Request Entity Too Large
}
if (! in_array($metadata['filetype'] ?? null, self::ALLOWED_MIME, true)) {
return response('', 415); // Unsupported Media Type
}
$upload = Upload::create([
'id' => (string) Str::uuid(),
'offset' => 0,
'length' => $length,
'filename' => $metadata['filename'] ?? 'upload.bin',
'mime_type' => $metadata['filetype'],
]);
return response('', 201)
->header('Location', route('files.patch', $upload)); // where to PATCH
}
TUS packs its metadata into a single header as comma-separated key base64value pairs, so decodeMetadata splits on commas and spaces and base64-decodes each value. The declared Upload-Length and declared filetype are hints from the client, and I treat them as claims to check, not facts. The size claim is enforced here with a hard ceiling. The type claim gets a second, real check at finalize, where I can sniff the assembled bytes.
HEAD: report the current offset
This is the route that makes resumption possible, and it is almost nothing.
public function head(Upload $upload)
{
return response('', 200)
->header('Upload-Offset', $upload->offset)
->header('Upload-Length', $upload->length)
->header('Cache-Control', 'no-store');
}
A client that dropped mid-transfer calls HEAD first, reads Upload-Offset: 2415919104, and knows it is at 80 percent of a 3 GB file. no-store matters. If a proxy caches this response, a reconnecting client reads a stale offset and corrupts the file by writing at the wrong position.
PATCH: append a chunk, advance the offset
Here is the core of the whole thing. The client sends a chunk of bytes and the offset it believes it is writing at. The server verifies that offset matches its own record, streams the bytes onto the end of a temp file, and advances the record.
public function patch(Request $request, Upload $upload)
{
$clientOffset = (int) $request->header('Upload-Offset');
// Guard against a client writing at the wrong position.
if ($clientOffset !== $upload->offset) {
return response('', 409); // Conflict: offsets disagree
}
$chunkPath = "tus/{$upload->id}.part";
$input = fopen('php://input', 'rb');
$out = Storage::disk('local')->path($chunkPath);
$handle = fopen($out, 'ab'); // append binary, never load the whole file
$written = 0;
while (! feof($input)) {
$buffer = fread($input, 8192);
fwrite($handle, $buffer);
$written += strlen($buffer);
}
fclose($handle);
fclose($input);
// Advance the offset atomically so concurrent PATCHes cannot race.
$newOffset = DB::transaction(function () use ($upload, $written) {
$locked = Upload::lockForUpdate()->find($upload->id);
$locked->offset += $written;
$locked->save();
return $locked->offset;
});
if ($newOffset >= $upload->length) {
$this->finalize($upload->fresh());
}
return response('', 204)->header('Upload-Offset', $newOffset);
}
Three details carry this method. The offset check turns a client that reconnects with stale state into a clean 409 instead of a corrupted file. The fread/fwrite loop with an 8 KB buffer keeps memory flat no matter how large the file is, so PHP never holds a multi-gigabyte payload in a variable. And the lockForUpdate inside a transaction means that if two requests somehow race on the same upload, the offset still advances correctly instead of one write clobbering the other.
Finalize: check the type for real, then stream to S3
When the offset reaches the declared length, the assembled file is sitting in local storage. Now I do the check I could not do at creation: sniff the actual bytes, not the client's claimed type. Then I stream the finished file to S3 for durable storage and clean up the temp file.
private function finalize(Upload $upload): void
{
$chunkPath = "tus/{$upload->id}.part";
$localPath = Storage::disk('local')->path($chunkPath);
// Sniff the real MIME from the bytes, not the client's claim.
$realMime = (new \finfo(FILEINFO_MIME_TYPE))->file($localPath);
if (! in_array($realMime, self::ALLOWED_MIME, true)) {
Storage::disk('local')->delete($chunkPath);
$upload->delete();
return;
}
// Stream to S3 without buffering the whole file in memory.
$stream = fopen($localPath, 'rb');
$s3Path = "uploads/{$upload->id}/{$upload->filename}";
Storage::disk('s3')->writeStream($s3Path, $stream);
fclose($stream);
Storage::disk('local')->delete($chunkPath);
$upload->update([
'storage_path' => $s3Path,
'completed_at' => now(),
]);
}
writeStream is the piece that keeps this honest at scale. It reads the local file in chunks and pushes them to S3 without ever holding the whole thing in a PHP variable, so the memory profile of finalizing a 3 GB upload looks the same as finalizing a 3 MB one. The bucket stays behind the application, which means every accepted file has already passed the size gate at creation and the type gate here before anything durable is written.
Resuming after a network failure
Put the routes together and resumption needs no special code. It is just the client using the protocol correctly:
- The connection drops during a
PATCH. The client does not know how many bytes the server actually committed before the socket died. - On reconnect, the client sends
HEADand readsUpload-Offset. That is the server's committed truth, which may be short of what the client thought it sent. - The client seeks to that offset in the local file and resumes
PATCHfrom there.
The client half is a solved problem. tus-js-client in the browser and Uppy on top of it speak this protocol out of the box, so on the front end you point the uploader at /files and it handles the chunking, the HEAD probe, and the retry with backoff. You own the server contract, they own the transport.
That division is the whole reason I like terminating TUS myself rather than reaching for a black-box package. The protocol is small enough to read in an afternoon, the offset logic is the only stateful part, and once you own it you can enforce exactly the size and type rules your platform needs, right at the boundary, before a durable byte is written. It is a good example of the kind of full-stack Laravel and Vue work where the interesting decisions live in the seams between the client, the server, and the store. Treat the network as adversarial, move the state to a server-side offset, and a dropped upload becomes a resumed one instead of a lost one.