flowjs/flow-php-server
Server-side PHP companion for Flow.js that handles chunked file uploads, validates chunks, manages temporary storage, and assembles complete files for reliable resumable uploads.
flowjs/flow-php-server is the PHP half of the flow.js resumable-upload stack. flow.js chunks a file in the browser, POSTs each chunk to your endpoint, and asks GET whether a chunk already exists before sending it. This package validates each chunk, stores it under a temp directory keyed by the upload identifier, and merges the chunks into one file when the last one arrives.
Install it with Composer:
composer require flowjs/flow-php-server
The only hard requirement is a writable temp directory (the hash extension is used for chunk identifiers). First place to look is src/Flow/ — File.php is the workhorse, Request.php parses the flow* parameters, and Config.php holds the temp-dir/hash settings. The README on GitHub has a working endpoint you can copy.
A minimal, framework-free endpoint looks like this — it is the canonical first use case:
use Flow\Config;
use Flow\Request;
use Flow\File;
$config = new Config();
$config->setTempDir(__DIR__ . '/tmp'); // writable, outside the webroot
$request = new Request();
$file = new File($config, $request);
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
// flow.js asks "do you already have this chunk?"
if ($file->checkChunk()) {
header('HTTP/1.1 200 Ok');
} else {
header('HTTP/1.1 204 No Content');
}
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($file->validateChunk()) {
$file->saveChunk();
} else {
header('HTTP/1.1 400 Bad Request');
exit;
}
if ($file->validateFile()) { // all chunks present
$file->save($destinationPath); // merge to a final path
}
exit;
}
Once that responds correctly, swap the $destinationPath for something derived from your application data (e.g. a UUID-based storage path) rather than trusting flowFilename.
Single route, two verbs. In Laravel this maps cleanly to one controller action handling both the checkChunk (GET) and saveChunk (POST) paths. Keep the status codes exactly right — flow.js uses 200 vs 204 to decide whether to re-send a chunk, so a stray 200 with a body for a missing chunk will make the client skip data.
Route::match(['get', 'post'], '/uploads/chunk', ChunkController::class)
->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
Wire the temp directory through config, not hard-codes. In a Laravel app, point Config::setTempDir() at storage_path('app/flow-tmp') (or a Storage disk path). Keeping it on the same filesystem volume as the final destination makes the merge-and-move cheap.
$config = new \Flow\Config();
$config->setTempDir(storage_path('app/flow-tmp'));
$request = new \Flow\Request(); // reads the flow* params from the request superglobals
$file = new \Flow\File($config, $request);
If your version of Request accepts an override array, you can pass $request->all() from Laravel explicitly, which is handy for testing; otherwise it reads $_GET/$_POST/$_FILES, which Laravel populates anyway.
Decide the final name yourself. $request->getFileName() gives you the client's filename, but the identifier (getIdentifier()) is the stable key across retries. A common pattern is to use the identifier as the temp grouping and a UUID or hashed name as the destination, persisting the mapping in your own uploads table:
if ($file->validateFile()) {
$dest = storage_path("app/uploads/{$uuid}");
$file->save($dest); // merge
Upload::create([...]); // record original filename, size, mime
}
Resumed and parallel uploads. flow.js fires several chunks concurrently; the package handles this because each chunk is written to its own file inside the identifier's temp folder, and the merge only runs when validateFile() succeeds. This means your endpoint must be stateless — do not assume chunks arrive in order, and do not keep per-request state in the session.
Chunk cleanup. After a successful save(), the temp chunk files are no longer needed. Add a scheduled command that sweeps directories under the temp dir older than N hours; abandoned uploads (closed tabs, failed mobile connections) otherwise accumulate forever.
Guarding the endpoint. Chunk requests are ordinary multipart POSTs, so authenticate as usual (middleware), but exempt them from CSRF or attach the token to the flow.js request — otherwise every resumed chunk fails with a 419 and the client retries endlessly.
saveChunk() after validateChunk() only. saveChunk() will happily write a chunk that failed validation if you call it unconditionally. The validateChunk() guard is what enforces the chunk size and the identifier/filename sanity checks.checkChunk() returns false on a fresh upload, so returning 204 No Content for that case is correct and expected — the client then POSTs the chunk.flowFilename for a filesystem path. It is attacker-controlled. Store the original name in your database, but write to disk under an ID you generated.public/ is a data-leak waiting to happen, and a read-only temp dir produces confusing "chunk not saved" errors that look like client failures.upload_max_filesize / post_max_size (plus nginx client_max_body_size) apply per chunk, not per file. Set the flow.js chunkSize comfortably beneath those limits (e.g. 1–5 MB). If a chunk is rejected by PHP before your code runs, flow.js sees a non-2xx and retries — producing an infinite retry loop that looks like a hang.Config between deploy and resume, previously uploaded chunks become invisible and the client restarts the whole file. Keep the setting stable, or at least version it.save(). The package throws a FileLockException in that situation; catch it and return a retryable status (5xx with a delay) rather than a 200, so the client doesn't mark the upload complete.validateFile() is the "all chunks present" check, not a virus/type check. It confirms every chunk arrived; it says nothing about the merged file's MIME type or content. Follow the merge with your own validation (mime sniffing, size checks, scanning) before promoting the file into permanent storage.save() merges into the destination path you give it. If the path exists, behavior depends on the version — write to a temp name and rename() atomically into place to avoid a half-merged file being served during the merge.Storage::putFile can stream it but not merge chunks for you.flowChunkNumber, flowTotalChunks, flowTotalSize, flowIdentifier, flowFilename and a file part, then a final POST that triggers the merge. That's the fastest way to reproduce a reported "upload stuck at 99%" bug.How can I help you explore Laravel packages today?