resumable / chunked-uploader
Enterprise-grade, framework-agnostic PHP library for handling large file uploads via chunking with stream-based assembly, resume capability, and cryptographic integrity checks.
Requires
- php: ^8.2
- ext-json: *
- psr/event-dispatcher: ^1.0
- psr/log: ^3.0
Requires (Dev)
- aws/aws-sdk-php: ^3.300
- illuminate/console: ^10.0 || ^11.0
- illuminate/contracts: ^10.0 || ^11.0
- illuminate/support: ^10.0 || ^11.0
- mikey179/vfsstream: ^1.6
- phpstan/phpstan: ^1.12
- phpunit/phpunit: ^10.5 || ^11.0
- predis/predis: ^2.0
- squizlabs/php_codesniffer: ^3.10
- symfony/config: ^6.4 || ^7.0
- symfony/console: ^6.4 || ^7.0
- symfony/dependency-injection: ^6.4 || ^7.0
- symfony/http-kernel: ^6.4 || ^7.0
Suggests
- ext-pdo: Required for PdoMetadataRepository.
- ext-redis: Required for RedisMetadataRepository with native Redis client.
- aws/aws-sdk-php: Required for S3ChunkStorage.
- league/flysystem: Required for optional Flysystem storage bridge.
- predis/predis: Alternative Redis client for RedisMetadataRepository.
Provides
None
Conflicts
None
Replaces
None
README
Resumable Chunked Uploader
Enterprise-grade, framework-agnostic PHP 8.2 library for handling large file uploads via chunking with constant-memory stream assembly, resume capability, and cryptographic integrity checks.
Chunks are persisted independently, progress is kept in a durable store (Redis or a relational database), and the final file is assembled using native PHP streams — never buffering the whole file into memory, regardless of how large it is.
Table of contents
- Highlights
- Architecture
- Core benefits
- Installation
- Standalone PHP quickstart
- Framework integration
- Vanilla JavaScript client
- Security model
- API reference
- Configuration options
- Testing
- License
Highlights
- O(1) memory chunk assembly via
fopen()+stream_copy_to_stream(). - Resume support for out-of-order chunks and interrupted requests.
- Atomic progress updates in Redis (optimistic locking).
- HMAC tokens bound to upload metadata and client context.
- Magic-byte MIME validation (only reads the first 4096 bytes).
- Strict path sanitization against directory traversal.
- Optional ClamAV scanning and Redis rate limiting.
- Atomic failure cleanup removes a stored chunk when its metadata update fails, so transient database errors do not create permanent orphan bytes.
- Bounded cleanup scans local directories, S3 pages, Redis cursors, and PDO rows incrementally, including S3 deletion batches larger than 1,000 objects.
ChunkUploaderis the single canonical coordinator.- Optional PHP 8 attributes provide endpoint-specific immutable config overrides without changing global defaults.
- Flysystem v3 storage is available as an optional driver.
- Zero framework dependency in the core package.
- Laravel and Symfony bridges + a dependency-free vanilla JS client.
Architecture
flowchart LR
subgraph Client
JS[Vanilla JS / Fetch API]
end
subgraph Server
M[ChunkUploader]
V[ValidationPipeline]
S[ChunkStorageInterface]
MD[MetadataRepositoryInterface]
PT[ProgressTrackerInterface]
A[FileAssemblerInterface]
TS[UploadTokenService]
end
JS -->|POST /upload token| TS
JS -->|POST /upload chunks| M
M --> V
M --> S
M --> MD
M --> PT
M --> A
A --> F[Final file]
JS -->|GET /upload/status| MD
Loading
Data flow for a single chunk:
sequenceDiagram
participant C as Client
participant M as ChunkUploader
participant TS as ChunkSecurityValidator
participant MD as MetadataRepo
participant S as ChunkStorage
participant A as Assembler
C->>M: processChunk(Chunk{index, token, ...})
M->>TS: validate(chunk)
TS-->>M: valid
M->>MD: get(identifier)
MD-->>M: UploadState|null
M->>S: store(chunk)
M->>MD: markChunkAsUploaded(identifier, index)
MD-->>M: UploadState (updated)
alt isComplete(state)
M->>A: assemble(state, storage)
A->>S: getChunkStream(index 0..N-1)
S-->>A: stream
A-->>M: finalPath
M->>S: deleteChunks(identifier)
end
M-->>C: UploadState
Loading
Core benefits
| Benefit | How it works |
|---|---|
| Low memory | Assembly streams chunk-by-chunk through a fixed 4 MiB buffer. A 10 GB file needs under 10 MiB of PHP memory. |
| Resumable | Progress persists per-chunk. Any client can query missing indices and resume exactly where it stopped. |
| Idempotent | Re-sending an already-accepted chunk is a no-op — no duplicated bytes, no corruption. |
| Secure | Magic-byte MIME checks, HMAC token verification, traversal-safe paths, and optional ClamAV scanning. |
| Out-of-order safe | Chunks may arrive in any order; assembly only runs when every index is present. |
Installation
composer require resumable/chunked-uploader
Requirements:
- PHP ^8.2
ext-json- One of
ext-redisorpredis/predis(for the Redis metadata driver) ext-pdo(for the PDO metadata driver)aws/aws-sdk-php(for the S3 storage driver)
Standalone PHP quickstart
<?php declare(strict_types=1); use Resumable\ChunkedUploader\Core\Assembler\StreamAssembler; use Resumable\ChunkedUploader\Core\Drivers\Metadata\RedisMetadataRepository; use Resumable\ChunkedUploader\Core\Drivers\Storage\LocalChunkStorage; use Resumable\ChunkedUploader\Core\Models\Chunk; use Resumable\ChunkedUploader\Core\Security\PathSanitizer; use Resumable\ChunkedUploader\Core\Security\UploadTokenService; use Resumable\ChunkedUploader\Core\ChunkUploader; use Resumable\ChunkedUploader\Core\Validation\ChunkSecurityValidator; use Resumable\ChunkedUploader\Core\Validation\ValidationPipeline; use Resumable\ChunkedUploader\Core\Contracts\EventDispatcherInterface; use Resumable\ChunkedUploader\Core\Contracts\FileAssemblerInterface; use Resumable\ChunkedUploader\Core\Contracts\ProgressTrackerInterface; use Resumable\ChunkedUploader\Core\Contracts\ChunkValidatorInterface; use Resumable\ChunkedUploader\Core\Contracts\ChunkStorageInterface; use Resumable\ChunkedUploader\Core\Contracts\MetadataRepositoryInterface; use Resumable\ChunkedUploader\Core\Exceptions\ChunkUploaderException; // 1. Wire the infrastructure. $storage = new LocalChunkStorage('/var/lib/my-app/chunks', new PathSanitizer()); $metadata = new RedisMetadataRepository(new \Redis(['host' => '127.0.0.1'])); $assembler = new StreamAssembler('/var/lib/my-app/final'); $validator = new ChunkSecurityValidator( sanitizer: new PathSanitizer(), pipeline: new ValidationPipeline([]), tokenService: new UploadTokenService($_ENV['UPLOAD_TOKEN_SECRET']), ); // 2. Create the manager once, inject it into your request handler. $manager = new ChunkUploader( storage: $storage, metadata: $metadata, progress: $metadata, assembler: $assembler, validator: $validator, dispatcher: new class implements EventDispatcherInterface { public function dispatch(object $event): object { return $event; } }, ); // 3. Issue a token bound to the upload signature and client context. $token = (new UploadTokenService($_ENV['UPLOAD_TOKEN_SECRET'])) ->createToken('upload_123', 4, 20_000_000, 'client-context'); // 4. For each incoming chunk, build a Chunk DTO and process it. try { $state = $manager->processChunk(new Chunk( identifier: 'upload_123', token: $token, index: 0, totalChunks: 4, chunkSize: filesize($tempFile), // actual byte size of this chunk totalSize: 20_000_000, // expected total size tmpFilePath: $tempFile, // from PHP's upload temp dir originalFilename: 'archive.zip', )); } catch (ChunkUploaderException $e) { // validation / storage / assembly failure } // The manager returns the latest upload state; when isCompleted is true the // final file has been assembled and temp chunks deleted. if ($state->isCompleted) { // finalPath points to the assembled artifact echo $state->finalPath; }
Create one Chunk per HTTP request. index is zero-based. Retrying an
already-accepted index is safe and idempotent.
Framework integration
Laravel 10 / 11
1. Register the service provider
In bootstrap/providers.php (Laravel 11) or config/app.php (Laravel 10):
// Laravel 11: bootstrap/providers.php return [ Resumable\ChunkedUploader\Bridge\Laravel\Providers\ChunkUploaderServiceProvider::class, ]; // Laravel 10: config/app.php 'providers' => [ Resumable\ChunkedUploader\Bridge\Laravel\Providers\ChunkUploaderServiceProvider::class, ],
2. Publish configuration (optional)
php artisan vendor:publish --provider="Resumable\ChunkedUploader\Bridge\Laravel\Providers\ChunkUploaderServiceProvider"
Then adjust config/chunk-uploader.php (allowed MIME types, spool directory,
storage driver, etc.).
New in this version: token_salt (binds tokens to a client fingerprint),
virus_scanning (ClamAV host/port), and rate_limiting (Redis-backed chunk
flood protection) — all with matching .env variable names (see the config
file for the full list).
3. Use the Facade
use Resumable\ChunkedUploader\Bridge\Laravel\Facades\ChunkUploader; $state = ChunkUploader::processChunk($chunk);
4. Add routes (see the example controller)
Route::post('/upload/token', [ChunkUploadController::class, 'issueToken']); Route::post('/upload', [ChunkUploadController::class, 'store']); Route::get('/upload/status/{identifier}', [ChunkUploadController::class, 'status']);
A complete, copy-pasteable controller lives at
examples/laravel/ChunkUploadController.php.
Symfony 6 / 7
1. Enable the bundle
// config/bundles.php return [ Resumable\ChunkedUploader\Bridge\Symfony\ChunkUploaderBundle::class => ['all' => true], ];
2. Configure the bundle
# config/packages/chunk_uploader.yaml chunk_uploader: max_chunk_size: 5242880 max_file_size: 104857600 max_chunks: 1000 allowed_mime_types: [] spool_directory: '%kernel.project_dir%/var/chunked-uploader' garbage_collection_ttl: 3600 token_secret: '%env(APP_SECRET)%' token_salt: '%env(APP_SECRET)%' # optional client-fingerprint salt storage: local # local | s3 local: base_directory: '%kernel.project_dir%/var/chunked-uploader/chunks' s3: bucket: 'my-bucket' prefix: 'chunks/' config: { version: latest, region: eu-west-1, key: ~, secret: ~ } metadata: redis # redis | pdo redis: client: phpredis # phpredis | predis prefix: 'chunked-uploader:' ttl: 0 connection_service: 'redis' # required when rate limiting is enabled pdo: table: chunked_upload_states connection: default virus_scanning: enabled: false host: '127.0.0.1' port: 3310 rate_limiting: enabled: false max_attempts: 100 decay_seconds: 60 key: 'chunked-uploader:chunks'
3. Add routes via attribute on the controller
The example at examples/symfony/ChunkUploadController.php
uses attribute routing and constructor injection, so no routing YAML is needed.
Vanilla JavaScript client
A 100% dependency-free ES6+ client, using Blob.prototype.slice(), the Fetch
API and FormData, is included in examples/vanilla-js/.
<script src="uploader.js"></script>
const uploader = new ChunkedUploader({ file, endpoint: '/upload', chunkSize: 2 * 1024 * 1024, // 2 MiB token, // HMAC token from your backend onProgress: (percent, index, total) => { progressBar.value = percent; }, onSuccess: (result) => console.log('Done', result), onError: (error) => console.error(error), }); await uploader.start(); // also valid: await uploader.resume()
Features:
- Splits a
Fileinto binary chunks withfile.slice(start, end). - Sends each chunk as
multipart/form-dataviafetch(). - Automatic retry with exponential backoff on transient failures.
pause()/resume()for interactive control.missingChunks()queriesGET {endpoint}/status/{id}so a partially uploaded file resumes instead of restarting.- Works in any modern browser with native
fetch,FormData, andBlob.
Open examples/vanilla-js/index.html in a
browser for a ready-to-run drag-and-drop demo.
Security model
- Tokens —
UploadTokenServiceissues HMAC-SHA256 tokens bound to the upload identifier, chunk count, total size, and an optional client salt (IP / fingerprint). Verification useshash_equals()(constant-time). - Paths —
PathSanitizerrejects null bytes, directory separators, traversal markers (..,../), and unsafe identifiers. Never build a filesystem path from user input without running it through the sanitizer. - MIME —
MagicByteValidatorinspects only the first 4096 bytes viafinfoand compares against an allow-list. Client-supplied MIME headers are never trusted. - Extension matching —
ExtensionMimeMatchRulerejects files whose claimed extension disagrees with their detected MIME type. - Malware —
ClamAvScannerstreams data toclamdover the INSTREAM protocol in 1 MiB chunks. UseNullVirusScannerwhen scanning is handled elsewhere. - Abuse —
RedisRateLimitercan key limits per IP, user, or upload token and applies them before accepting chunk bodies. - Storage — Keep temporary and final directories outside the public document root, and configure permissions and retention deliberately.
API reference
ChunkUploaderInterface
| Method | Description |
|---|---|
processChunk(Chunk $chunk): UploadState |
Validates the token, persists the chunk, advances progress, and assembles when complete. Idempotent and concurrent-safe. |
cancelUpload(string $identifier): void |
Removes chunks and metadata. Idempotent for unknown identifiers. |
getStatus(string $identifier): ?UploadState |
Reads the current state of an upload, or null if unknown. |
Chunk (readonly DTO)
identifier, token, index, totalChunks, chunkSize, totalSize,
tmpFilePath, originalFilename, and optional checksum.
UploadState (readonly DTO)
identifier, totalChunks, totalSize, originalFilename, uploadedChunks,
isCompleted, finalPath; plus hasChunk(), isComplete(),
withUploadedChunk(), and withFinalPath().
Contracts
| Contract | Methods |
|---|---|
ChunkStorageInterface |
store, getChunkStream, deleteChunks, cleanOrphanedChunks |
MetadataRepositoryInterface |
save, get, delete, markChunkAsUploaded, cleanExpired |
ProgressTrackerInterface |
getPercentage, isComplete, getMissingChunkIndices |
FileAssemblerInterface |
assemble(UploadState, ChunkStorageInterface): string |
ValidationRuleInterface |
validate(Chunk): void |
ChunkValidatorInterface |
validate(Chunk): bool |
VirusScannerInterface |
scan(string): void |
EventDispatcherInterface |
dispatch(object): object |
Endpoint-specific attributes
Global Laravel or Symfony configuration remains the default. A controller method can opt into a narrower immutable configuration for its own uploads:
use Resumable\ChunkedUploader\Core\Attributes\ChunkedUpload; use Resumable\ChunkedUploader\Core\Attributes\AllowedMimes; use Resumable\ChunkedUploader\Core\Attributes\MaxFileSize; final class MediaController { #[ChunkedUpload(tokenSalt: 'media-endpoint')] #[AllowedMimes(['video/mp4'])] #[MaxFileSize(50 * 1024 * 1024)] public function upload(): void { // Resolve the method with ChunkedUploadConfigResolver in the bridge, // then pass the returned UploaderConfig to ChunkUploader::processChunk. } }
The core resolver accepts either a ReflectionMethod or ReflectionClass and
returns the unchanged global instance when no attribute is present.
Configuration options
| Setting | Default | Description |
|---|---|---|
maxChunkSize |
5 MiB | Hard limit of a single chunk. |
maxFileSize |
100 MiB | Hard limit of the assembled file. |
maxChunks |
1000 | Max chunks per upload (DoS guard). |
allowedMimeTypes |
[] |
Whitelist; empty = any verified type. |
spoolDirectory |
/tmp/chunked-uploader |
Chunk + final assembly dir. |
garbageCollectionTtl |
3600 s | Incomplete-upload window before GC. |
identifierPattern |
^[a-zA-Z0-9_-]{1,128}$ |
Legal identifier pattern. |
tokenSalt |
'' |
Optional HMAC salt bound to client context. |
virusScanning |
disabled | ClamAV host/port (requires virus_scanning.enabled: true). |
rateLimiting |
disabled | Redis-backed chunk-flood guard (max_attempts, decay_seconds, key). |
Testing
composer install vendor/bin/phpunit
The full suite runs completely offline: all disk I/O uses ephemeral temporary files that are cleaned up automatically; storage and metadata are in-memory doubles (FakeRedis, FakePdo); and Redis, S3, and ClamAV are never contacted. The suite covers:
- Unit tests against
PathSanitizer,MagicByteValidator,UploadTokenService,StreamAssembler(memory ceiling),ClamAvScanner(stream-pair seam),RedisRateLimiter,S3ChunkStorage, andPdoMetadataRepository(with Postgres/MySQL dialect assertions). - Feature tests for the full sequential upload flow, out-of-order resumable uploads, interrupted-upload recovery (idempotent retries, resume-from-missing-index, service-restart continuity), and both the Laravel and Symfony framework bridges.
License
MIT. See LICENSE.