Contents
- 1 Resumable uploads: when “just upload the file” stops being enough
- 2 What resumable really means (for both you and your users)
- 3 PHP’s upload limits and why chunking is our way out
- 4 Architecture: a simple mental model for resumable uploads in PHP
- 5 Approach 1: roll your own resumable upload in native PHP
- 6 Approach 2: leaning on tus and existing PHP libraries
- 7 Resumable uploads and S3, CDNs, and reality
- 8 Where resumable uploads intersect with hiring and being hired
- 9 Quiet reflections from late‑night uploads
Resumable uploads: when “just upload the file” stops being enough
There’s a particular kind of silence that every backend dev knows.
It’s 2 AM, the office is dark, the coffee is not great but it’s warm, and your logs are full of failed uploads. Users trying to push 4 GB videos through a form that was written when “5 MB max” was still considered generous. Someone hits 87%, their Wi‑Fi blinks, and everything collapses. The browser shows a vague error, the user sighs, your application shrugs.
In that silence, you realize: simple file uploads don’t cut it anymore.
Friends, that’s where resumable file uploads in PHP stop being a “nice to have” and become the difference between “this feels professional” and “this feels broken”.
On a platform like Find PHP, where people hire PHP developers and post resumes, this is not just theory. Some of you build video platforms. Some of you handle medical imaging. Some of you manage multi‑gigabyte backups. At that scale, “retry from zero” is cruel.
Let’s talk, quietly and honestly, about how to build resumable uploads in PHP in a way that’s robust, understandable, and feels like code you’d be proud to show another developer.
We’ll walk through three things:
- What “resumable upload” really means (beyond the buzzword)
- A mental model and architecture for chunked/resumable uploads in PHP
- Two concrete approaches: roll your own and use a protocol like tus
No corporate fluff. Just code, trade‑offs, and a bit of philosophy about respecting your user’s time.
What resumable really means (for both you and your users)
“Resumable uploads” sound fancy, but the core idea is beautifully simple:
Instead of sending a huge file in one request, you split it into chunks, upload those chunks individually, and remember how far you got so you can continue later.
That gives you:
- Reliability: a dropped connection means you redo a few megabytes, not 3 GB.
- Control: you can throttle, validate, and log per chunk.
- Flexibility: you can store chunks on disk, S3, or whatever backend you use.
At a high level, most implementations follow the same three‑step flow, whether you use tus, Flow.js, Plupload, or your own JavaScript:
-
Start an upload session
The client asks the server: “I want to upload a file of size X, type Y; give me an ID.”
Server responds with an upload ID and maybe information like maximum chunk size or allowed methods. -
Upload chunks
The browser reads the file in slices (say 5–20 MB each), and for each slice sends a request like:PUT /upload/{id}/chunk/{index}with the raw bytes of that chunk.
If something breaks, later the client can ask: “How many chunks did you get?” and continue from there. -
Finalize and assemble
When all chunks are uploaded, the client calls something like:POST /upload/{id}/complete
The server verifies everything, concatenates all chunks in order, and moves the final file to its long‑term storage.
Under the hood, a decent PHP backend will keep:
- A table or key‑value store for upload sessions:
upload_id,user_id,total_size,chunk_count,received_chunks,status. - A temporary directory for raw chunks.
- A final storage path where the assembled file will live.
So if you strip all the technical ornamentation, resumable upload is really about one thing:
Treating a file as a sequence of small promises, not one big bet.
Your user doesn’t have to gamble a 2‑hour upload on the quality of a single connection.
And you, as the PHP developer, don’t have to pray your upload_max_filesize and max_execution_time survive the storm.
PHP’s upload limits and why chunking is our way out
Before we talk about actual code, it’s worth remembering the constraints we’re fighting:
upload_max_filesizeandpost_max_sizeinphp.inican silently kill big uploads.max_execution_timeand web server timeouts don’t care about your dreams of 4 GB files.- Shared hosting and many managed environments resist large body sizes like a stubborn config demon.
Classic PHP upload is: one HTTP request, one big body, one shot. No retries, no progress, no partial state.
Chunking breaks that model. Instead of one monolithic upload, we get:
- Many small requests, each under comfortable limits.
- Clean points to validate, log, and debug.
- The ability to resume: “We have chunks 0, 1, 2, 3. You only need to send 4 and 5.”
In other words, chunked uploads are how you cheat PHP’s limits without actually cheating them. You play inside the rules, but you change the game.
Now let’s get concrete.
Architecture: a simple mental model for resumable uploads in PHP
I like to describe resumable uploads as a little state machine plus some disk files.
Imagine you have three endpoints:
POST /upload/init– creates a new upload session.PUT /upload/{id}/chunk/{index}– stores one chunk.POST /upload/{id}/complete– assembles the file.
And behind those endpoints, you maintain an uploads table like:
CREATE TABLE uploads (
id CHAR(36) PRIMARY KEY,
user_id INT NOT NULL,
filename VARCHAR(255) NOT NULL,
total_size BIGINT NOT NULL,
chunk_size INT NOT NULL,
total_chunks INT NOT NULL,
received_chunks INT NOT NULL DEFAULT 0,
status ENUM('initiated', 'in_progress', 'completed', 'failed') NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
Then you pair that with a directory layout:
/tmp/uploads/{id}/chunk_0.part/tmp/uploads/{id}/chunk_1.part- …
/final/uploads/{id}.binor some meaningful name.
The state machine is simple:
initiated→in_progresswhen the first chunk arrivesin_progress→ stays there while chunks accumulatecompletedwhen you’ve successfully assembledfailedwhen something goes wrong (missing chunks, mismatched sizes, etc.)
With that in mind, every piece of code you write has a clear purpose:
- Init handler: create session, allocate temp space, respond with ID.
- Chunk handler: validate index and length, write bytes, update count.
- Complete handler: verify sessions and chunks, stream assemble, move file, clean temp.
And you always know how to debug:
- Missing chunk? Look at
received_chunks. - Mismatch in sizes? Compare chunk sizes and
total_size. - Stuck uploads? Check sessions where
status = 'in_progress'butupdated_atis old.
This structure is framework‑agnostic. You can implement it in native PHP, Laravel, Symfony, or even a tiny custom router.
Once you understand this mental model, every library and protocol suddenly makes more sense. They’re all building on the same idea, just with different abstractions.
Approach 1: roll your own resumable upload in native PHP
Sometimes you don’t want new dependencies. Or you’re on a legacy system. Or you just want to understand the mechanics before pulling in a protocol.
So let’s sketch a simplified native PHP implementation.
We’ll assume you have a frontend that can send chunked uploads and includes:
upload_id– unique ID for the file.chunk_index– 0‑based index.total_chunks– how many chunks in total.filename/total_size– optional metadata you send at init time.
1. Init endpoint
A basic upload_init.php could look like:
<?php
// Assume user is authenticated and $userId is known
$userId = 123;
$filename = $_POST['filename'] ?? 'upload.bin';
$totalSize = (int)($_POST['total_size'] ?? 0);
$chunkSize = (int)($_POST['chunk_size'] ?? 0);
$totalChunks = (int)ceil($totalSize / $chunkSize);
// Generate an upload ID (UUID or random string)
$uploadId = bin2hex(random_bytes(16));
// Insert into database (simplified)
$pdo = new PDO(/* DSN */);
$stmt = $pdo->prepare("
INSERT INTO uploads (id, user_id, filename, total_size, chunk_size, total_chunks, status, created_at, updated_at)
VALUES (:id, :user_id, :filename, :total_size, :chunk_size, :total_chunks, 'initiated', NOW(), NOW())
");
$stmt->execute([
':id' => $uploadId,
':user_id' => $userId,
':filename' => $filename,
':total_size' => $totalSize,
':chunk_size' => $chunkSize,
':total_chunks' => $totalChunks,
]);
// Create temp folder for this upload
$baseDir = __DIR__ . '/tmp/uploads/' . $uploadId;
if (!is_dir($baseDir)) {
mkdir($baseDir, 0770, true);
}
header('Content-Type: application/json');
echo json_encode(['upload_id' => $uploadId]);
It’s simple: we store metadata, create a temp directory, and send the upload_id back.
The client will keep that ID and attach it to every chunk.
2. Chunk endpoint
Now the core: upload_chunk.php to receive and save each chunk.
<?php
$uploadId = $_POST['upload_id'] ?? null;
$chunkIndex = (int)($_POST['chunk_index'] ?? -1);
if (!$uploadId || $chunkIndex < 0) {
http_response_code(400);
exit('Invalid upload id or chunk index');
}
// Fetch session
$pdo = new PDO(/* DSN */);
$stmt = $pdo->prepare("SELECT * FROM uploads WHERE id = :id");
$stmt->execute([':id' => $uploadId]);
$session = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$session) {
http_response_code(404);
exit('Upload session not found');
}
if ($session['status'] === 'completed' || $session['status'] === 'failed') {
http_response_code(409);
exit('Upload is already finalized');
}
// Ensure base dir exists
$baseDir = __DIR__ . '/tmp/uploads/' . $uploadId;
if (!is_dir($baseDir)) {
mkdir($baseDir, 0770, true);
}
// Read the raw chunk (assuming it's sent as the request body)
$chunkPath = $baseDir . '/chunk_' . $chunkIndex . '.part';
$input = fopen('php://input', 'rb');
$output = fopen($chunkPath, 'wb');
stream_copy_to_stream($input, $output);
fclose($input);
fclose($output);
// Update session state
$receivedChunks = (int)$session['received_chunks'] + 1;
$status = 'in_progress';
$stmt = $pdo->prepare("
UPDATE uploads SET received_chunks = :received_chunks, status = :status, updated_at = NOW()
WHERE id = :id
");
$stmt->execute([
':received_chunks' => $receivedChunks,
':status' => $status,
':id' => $uploadId,
]);
header('Content-Type: application/json');
echo json_encode([
'upload_id' => $uploadId,
'chunk_index' => $chunkIndex,
'received_chunks' => $receivedChunks,
'total_chunks' => (int)$session['total_chunks'],
]);
In practice, you’ll want to:
- Verify the chunk size matches
chunk_size(except maybe the last chunk). - Protect against overwriting existing chunks.
- Handle concurrency if clients retry aggressively.
But the core is this: read from php://input, stream to file, update progress.
3. Complete endpoint
Finally, upload_complete.php assembles the file:
<?php
$uploadId = $_POST['upload_id'] ?? null;
if (!$uploadId) {
http_response_code(400);
exit('Missing upload id');
}
$pdo = new PDO(/* DSN */);
$stmt = $pdo->prepare("SELECT * FROM uploads WHERE id = :id");
$stmt->execute([':id' => $uploadId]);
$session = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$session) {
http_response_code(404);
exit('Upload session not found');
}
if ((int)$session['received_chunks'] !== (int)$session['total_chunks']) {
http_response_code(409);
exit('Not all chunks received yet');
}
$baseDir = __DIR__ . '/tmp/uploads/' . $uploadId;
$finalDir = __DIR__ . '/final/uploads';
if (!is_dir($finalDir)) {
mkdir($finalDir, 0770, true);
}
$finalPath = $finalDir . '/' . $session['filename'];
// Stream assemble
$output = fopen($finalPath, 'wb');
for ($i = 0; $i < (int)$session['total_chunks']; $i++) {
$chunkPath = $baseDir . '/chunk_' . $i . '.part';
if (!file_exists($chunkPath)) {
fclose($output);
http_response_code(500);
exit('Missing chunk ' . $i);
}
$input = fopen($chunkPath, 'rb');
stream_copy_to_stream($input, $output);
fclose($input);
}
fclose($output);
// Mark as completed and clean up
$stmt = $pdo->prepare("
UPDATE uploads SET status = 'completed', updated_at = NOW()
WHERE id = :id
");
$stmt->execute([':id' => $uploadId]);
// Remove temp chunks
array_map('unlink', glob($baseDir . '/chunk_*.part'));
rmdir($baseDir);
header('Content-Type: application/json');
echo json_encode([
'upload_id' => $uploadId,
'path' => $finalPath,
'status' => 'completed',
]);
Notice a subtle but crucial detail: we stream everything. No file_get_contents into RAM for 4 GB. Just an open file handle and a loop.
This is one of those moments where PHP feels surprisingly elegant: stream_copy_to_stream quietly does exactly what you need, without drama.
Is this production‑ready? Not yet. You’d want:
- Authentication and authorization per upload.
- Better error handling.
- A way for clients to query current progress (
GET /upload/{id}). - Background jobs for post‑processing (e.g., encoding video after upload).
But as a mental model and starting point, this gets you 80% of the way.
And importantly: you now understand what libraries are doing under the hood.
Approach 2: leaning on tus and existing PHP libraries
If rolling your own feels too low‑level, there is a beautifully solid protocol sitting there waiting for you: tus.
tus is an open HTTP‑based protocol for resumable uploads. It’s used by Vimeo and others, and it has a pure PHP implementation in libraries like ankitpokhrel/tus-php.
The selling points:
- It standardizes the way clients and servers talk about resumable uploads.
- It handles chunking, offsets, and resume logic for you.
- It works nicely with frontend libraries like Uppy, which has a Tus plugin.
If you’re building something that should be robust and future‑proof, tus is worth serious consideration.
tus in PHP: a quick overview
The tus-php library gives you both a server and a client implementation in PHP. You can:
- Run a tus server that handles resumable uploads.
- Talk to that server from PHP code (e.g., to re‑upload files elsewhere).
A typical setup looks like:
-
Install via Composer:
composer require ankitpokhrel/tus-php -
Configure a server endpoint (for example,
/tus) that handles tus requests. -
Use hooks/events to react when an upload completes (rename file, move it, process it).
In a minimal setup, the tus server manages:
- Storage backend (file system, S3, etc.).
- Tracking upload offsets.
- Handling
PATCHrequests with chunks. - Answering
HEADrequests so the client knows how far the upload has progressed.
From the client side, a JavaScript library like Uppy can handle:
- Splitting files into chunks.
- Retrying failed chunks.
- Resuming uploads after reconnect.
- Displaying progress, speed, and estimated time.
Suddenly, all those delicate “if connection dies at 73%…” scenarios become someone else’s problem. You wire the pieces together and focus on what happens once the file is actually stored.
A small taste of tus‑php usage
On the PHP side, your server code might look something like:
<?php
require 'vendor/autoload.php';
use TusPhp\Tus\Server;
$server = new Server('file');
$server->setUploadDir(__DIR__ . '/uploads');
$response = $server->serve();
$response->send();
Behind that minimalist script, tus-php takes care of:
- Parsing tus protocol headers.
- Managing upload keys.
- Maintaining offsets so uploads can resume.
On completion, you can attach an event listener:
<?php
$server->event()->addListener(
'tus-server.upload.complete',
function (\TusPhp\Events\TusEvent $event) {
$file = $event->getFile();
// Perform post upload operations: move file, rename, validate, etc.
}
);
As someone who has stared at home‑rolled chunking code at 3 AM, there’s something deeply comforting about delegating this protocol logic to a mature library.
You still need to think about:
- Where to store uploaded data.
- How to tie uploads to users or jobs.
- What to do after upload (queues, workers, downstream systems).
But you’re operating at a higher level of abstraction.
Laravel ecosystem: Uppy + tus + Redis
If you’re in the Laravel world, there are nice integration stories where:
- Uppy in the browser handles the UI and chunking.
- Tus‑PHP runs as a server (or inside Laravel via a service provider).
- Redis helps with cache management for uploads and offsets.
The flow feels smooth:
- User hits an upload button in a Blade view.
- JavaScript initializes Uppy with Tus plugin pointing at your
/tusendpoint. - Laravel routes
/tus/{any?}to tus‑php’s server. - On
upload.complete, you trigger your own logic: store metadata, kick off a job, notify the user.
For teams that care about developer experience, this setup is hard to beat. It’s like moving from hand‑rolled HTTP parsing to a proper framework—same idea, different layer.
Resumable uploads and S3, CDNs, and reality
Of course, nobody builds resumable upload in a vacuum. Files eventually land in:
- S3 or other object storage.
- Local disk mounted via NFS.
- Some CDN or media pipeline.
The same principles apply:
- You never want to load entire files into memory if you can avoid it.
- You stream from chunk storage to final destination.
- You verify sizes and integrity before marking uploads as complete.
If your final destination is S3, there are two common patterns:
- Assemble the file locally from
.partchunks, then upload the final file to S3 and delete local parts. - Use S3’s own multipart upload API but still track your resumable state in PHP so the frontend knows what’s going on.
The choreography becomes important once you have a queue: backend processes that pick up “completed uploads” and do transcoding, analysis, or other heavy work.
Resumable upload is just the first act. But it’s an act you want to get right.
Because nothing kills a nice S3 pipeline like a user who cannot get their file there in the first place.
Where resumable uploads intersect with hiring and being hired
This is a blog for Find PHP, which means I’m not just talking to people casually Googling “chunked uploads”. I’m talking to:
- PHP developers who want their resumes to show real skills.
- Teams who read code samples and silently ask: “Would you trust this person with your production stack?”
Here’s the thing: resumable uploads make excellent portfolio material.
They are technical, but human. They sit at the intersection of:
- User experience: “I value your time, I’ll never ask you to start over from zero.”
- System design: endpoints, state machines, storage layers, and protocols.
- Pragmatism: handling large files within PHP’s limits.
If you’re building your profile:
- Include a small write‑up about how you implemented chunked or resumable uploads.
- Mention the constraints you addressed: large files, flaky networks, mobile users.
- Show code that demonstrates streaming, state tracking, and error handling.
If you’re hiring:
- Ask candidates how they would handle a 4 GB upload from a café Wi‑Fi connection.
- Listen for mention of chunking, resumable protocols, upload sessions, and streaming.
- Watch how they think about user frustration and reliability, not just function signatures.
In our world, a good file upload system is not glamorous. No one brags at a party “I wrote a deeply resilient upload handler,” but deep down, you and I know:
This is the kind of code that quietly defines whether an application feels trustworthy.
Quiet reflections from late‑night uploads
Let me end with something personal.
I still remember the first time a client sent me a screenshot: 45 minutes into a huge upload, just before the finish line, the page froze and refreshed to an empty form. No progress bar, no retry, no explanation. Just a small white void where their time used to be.
There was no big argument. Just a simple question: “Can we make it so this doesn’t happen?”
That question stayed with me. Because at its core, resumable uploads are less about bytes and more about respect. Respect for time, connection quality, hardware, and patience.
You don’t build resumable uploads to impress other developers. You build them so a human on a fragile network can feel like your application is on their side.
So next time you see $_FILES and a big “Upload” button, maybe let that thought linger for a second:
Is this a one‑shot gamble or a series of small promises?
And perhaps, somewhere between your coffee and your logs, you decide to make those promises steadier, kinder, and a little more worthy of the people who trust your code.