shetabit / chunky
resumable chunk upload manager
Requires
- php: ^8.4
- ext-fileinfo: *
- ext-json: *
- ext-mbstring: *
Requires (Dev)
- phpcsstandards/php_codesniffer: ^4.0
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^11.5|^12.0|^13.0
- rector/rector: ^2.6
This package is auto-updated.
Last update: 2026-08-14 14:44:09 UTC
README
Chunky :)
handle and upload files in base64 data URI, base64 json format and normal file formats.
This package supports PHP 8.4+ and has no dependencies of its own, so it can be used in any PHP framework —
Laravel, Symfony and the rest — as well as in no framework at all.
Chunky can be used to handle input/output file streams, both of
inputandoutputstreams can be multi-chunk and resumable.
List of contents
- Install
- A running example
- How to use
- Errors
- Testing
- Change log
- Contributing
- Security
- Credits
- License
Install
Via Composer
$ composer require shetabit/chunky
A running example
There is a small application in examples/ that uploads a file chunk by chunk — with a pause and a resume
— and hands it back out as a resumable download. It is an application of its own, with its own composer.json and
its own container, and it is not installed with the package.
cd examples make up # then open http://localhost:8080 make down
See examples/README.md for what to try and how the pieces fit together.
how-to-use
this package can be used in order to stream input/output files.
- stream output files (stream download)
- stream input files (stream upload)
Stream output
outputs can be streamed using Shetabit\Chunky\Classes\StreamOut like the below
# On the top of the file. use Shetabit\Chunky\Classes\StreamOut; ... $path = '../path/to/yourfile.mp3'; $stream = new StreamOut($path); $stream->process();
the download stream will be multi-chunk and resumable as you see in the below screenshot (in Internet download manager)
The stream reads the Range header of the request on its own. If your framework hands you the headers of a request
itself, you can pass the header along instead:
$stream->process($request->header('Range'));
A client that asks for a part of the file receives a 206 Partial Content, one that asks for a range that is not
there receives a 416 Requested Range Not Satisfiable, and one that asks for several ranges at once receives them as
a multipart/byteranges response.
The stream can be throttled, and the size of the buffer it writes can be changed:
$delay = 1000; // wait a millisecond after every buffer $bufferSize = 16 * 1024; // write 16 KB at a time $stream = new StreamOut($path, $delay, $bufferSize);
Stream input
input files can be uploaded chunk by chunk and resumable. you can collect files and upload them like the below
# On the top of the file. use Shetabit\Chunky\Classes\StreamIn; ... $inputName = 'media'; // html input's name $uploadPath = './path/to/address'; $inputStream = new StreamIn($inputName, $uploadPath); $uploads = $inputStream->process();
after uploading files, they will be in $uploads in array format: one entry per chunk, each of them holding the
path the chunk was written to, the range of the destination it was written at ([offset, length]), the file
itself and how much of it is assembled so far.
the upload stream is multi-chunk and resumable: an upload that was paused — or whose browser was closed — starts again at the byte it stopped on, because every chunk says which bytes of the file it holds and the server writes it exactly there.
A chunk carries the bytes of the whole file it holds in its range (1024-2047), and that is where it is written —
whatever order the chunks arrive in. The name a chunk is stored under is its own name with everything but the file
name cut off it, so a client can not write outside of the upload directory.
The screenshot above is the example that comes with the package; make up in examples/ runs it.
The formats a chunk can be sent in
The same input can be sent in three formats, and all three of them are collected together:
| format | where it is read from | example |
|---|---|---|
| an uploaded file | $_FILES[$inputName] |
<input name="media" type="file"> |
| a base64 data uri | $_REQUEST[$inputName] |
data:image/png;name=avatar.png;range=0-1023;base64,iVBORw0KGgo... |
| a json payload | $_REQUEST[$inputName] |
{"mime":"image/png","meta":{"name":"avatar.png","range":"0-1023"},"data":"iVBORw0KGgo..."} |
The name, the range and the size of the meta section of a data uri and of the meta of a json payload become
the attributes of the file the package hands you.
Sending chunks as plain uploads
A data uri and a json payload carry the range and the size of their file in their meta section. A plain upload has
nowhere to put them — $_FILES holds a name, a content type and the size of the chunk itself — so they travel next to
the upload as fields of their own, and ChunkedUploadCollector puts them back together:
# On the top of the file. use Shetabit\Chunky\Classes\ChunkedUploadCollector; use Shetabit\Chunky\Classes\StreamIn; ... $stream = new StreamIn($inputName, $uploadPath, new ChunkedUploadCollector($inputName)); $uploads = $stream->process();
One chunk per request, the fields hold one value each:
media: <the bytes of this chunk, with the name of the whole file>
range: 262144-524287
size: 1048576
Several files in one request — name="media[]", name="range[]" — and they hold one value per chunk, in the order
the files were sent in:
media[0]: <a chunk of the first file> media[1]: <a chunk of the second file>
range[0]: 0-262143 range[1]: 262144-524287
size[0]: 1048576 size[1]: 524288
A field holding a single value belongs to every chunk of the request; a field holding a list belongs to the chunks one
by one. What the request says wins over what a chunk says about itself — a client naming the size of the whole file is
telling the server something $_FILES can not know, where the size of the chunk sits under the same name.
range and size are read by default. Any attribute can be, and out of a field of another name:
new ChunkedUploadCollector($inputName, ['range', 'size', 'name', 'mime']); new ChunkedUploadCollector($inputName, ['range' => 'chunk_range', 'size' => 'total_bytes']);
name and mime are not read by default on purpose: $_FILES holds both already, and a form that happens to have a
field called name next to its upload would otherwise rename what it uploads. The fields are read from $_POST
unless a third argument says where else to read them from.
Validations
An incoming stream can be told what it takes. Every chunk is checked before anything of it is written, and a request that carries a single chunk too many is turned away whole, so it leaves no half a file behind.
# On the top of the file. use Shetabit\Chunky\Classes\StreamIn; use Shetabit\Chunky\Exceptions\ValidationFailedException; use Shetabit\Chunky\Validations\Extension; use Shetabit\Chunky\Validations\MimeType; use Shetabit\Chunky\Validations\Size; ... $stream = new StreamIn($inputName, $uploadPath, validations: [ new Size('5M', '1K'), // nothing over 5 MB, nothing under 1 KB new Extension(['jpg', 'jpeg', 'png', 'gif']), new MimeType(['image/jpeg', 'image/png', 'image/gif']), ]); try { $uploads = $stream->process(); } catch (ValidationFailedException $exception) { // every single thing that is wrong, one message per validation that failed $errors = $exception->getErrors(); }
They can be added afterwards as well, with addValidation() and addValidations(), and $stream->validate() runs
them on their own — it looks a request over and writes nothing either way.
| validation | what it checks |
|---|---|
Size($maximum, $minimum = 0) |
The size the client announced for the whole file, and the bytes of the chunk that arrived. Sizes are written as 5M, 512 KB, 1.5g or a plain number of bytes. |
Extension([...]) |
What the file is called. Case is ignored and the dot is optional. |
MimeType([...], detect: true) |
What the file says it is — and, for a chunk that holds the beginning of its file, what its bytes say it is. |
A chunk out of the middle of a file has none of the bytes that give a file away, so MimeType can only look into the
chunk that starts the file. detect: false turns the looking off and takes the client's word.
Your own validation is any class that implements Shetabit\Chunky\Contracts\ValidationInterface — throw a
ValidationFailedException to turn a file away:
use Shetabit\Chunky\Contracts\FileInterface; use Shetabit\Chunky\Contracts\ValidationInterface; use Shetabit\Chunky\Exceptions\ValidationFailedException; class NotOnAWeekend implements ValidationInterface { public function validate(FileInterface $file): void { if (in_array(date('N'), ['6', '7'], true)) { throw new ValidationFailedException('Come back on monday.', $file); } } }
Events
An incoming stream calls you back around each of the two things it does to a chunk:
$stream ->beforeValidate(function (TempFileInterface $file): void { // before the chunk is checked }) ->afterValidate(function (TempFileInterface $file, array $errors): void { // after it was checked, whether it passed or not }) ->beforeUpload(function (TempFileInterface $file, string $path): void { // before the chunk is written, and where it is written to }) ->afterUpload(function (TempFileInterface $file, string $path, int $assembled): void { // after it was written, and how much of the file is there now });
$assembled is what makes the last chunk of a file recognisable, which is usually where the interesting part of an
application starts:
$stream->afterUpload(function (TempFileInterface $file, string $path, int $assembled): void { if ($assembled === (int) $file->size) { // the file is whole: put it in the database, hand it to a queue, ... } });
The same number comes back from process(), under assembled, next to the path, the range and the file of
every chunk that was stored.
Collect and store input files
you can collect input files and then upload them as you want.
# On the top of the file. use Shetabit\Chunky\Classes\Collector; ... // html: <input name="media" type="file"> $inputName = 'media'; $collector = new Collector($inputName); // collect all input files $files = $collector->collect(); $file = $files[0]; // retrieve the first file // you can store each file like the below $path = './path/to/filename.jpg'; $file->saveAs($path); // save file as filename.jpg // or we can use file's original name $path = './path/to/'.$file->name; $file->saveAs($path);
saveAs() takes the offset of the destination the file is written at, and how many of its bytes to write:
// write the first 1024 bytes of the chunk at byte 4096 of the destination $file->saveAs($path, 4096, 1024);
Create stream in Laravel Framework
Resumable chunk download stream example in Laravel
create a controller like the below and create an indirect resumable file stream in Laravel.
namespace App\Http\Controllers; use App\Models\File; use Illuminate\Http\Request; use Shetabit\Chunky\Classes\StreamOut; class StreamOutController extends Controller { /** * Stream file output */ public function __invoke(Request $request, File $file): void { // proceed until all of the file has been sent to the client ini_set('max_execution_time', '0'); // retrieve file's path $path = '../'.$file->path; // prepare stream $stream = new StreamOut($path); // run stream $stream->process($request->header('Range')); } }
in this example we have a File eloquent model.
Resumable chunk upload stream example in Laravel
namespace App\Http\Controllers; use Illuminate\Http\Request; use Shetabit\Chunky\Classes\Collector; use Shetabit\Chunky\Classes\StreamIn; class StreamInController extends Controller { /** * Stream file input */ public function __invoke(Request $request): void { /** if you want simple file upload (not resumable and chunk) you can use the below code **/ $inputName = 'media'; $collector = new Collector($inputName); // collect all input files $files = $collector->collect(); $file = $files[0]; // retrieve the first file // you can store each file like the below $path = './path/to/filename.jpg'; $file->saveAs($path); // save file as filename.jpg // or we can use file's original name $path = './path/to/'.$file->name; $file->saveAs($path); // --------------------------------------------- /** if you want advanced file upload (resumable and chunk) you can use the below code **/ $inputName = 'media'; // html input's name $uploadPath = './path/to/address'; $inputStream = new StreamIn($inputName, $uploadPath); $uploads = $inputStream->process(); } }
Errors
Everything the package throws extends Shetabit\Chunky\Exceptions\ChunkyException, so a single catch covers all of
it:
| exception | when it is thrown |
|---|---|
FileNotFoundException |
a file that has to be read is not there, or the upload directory does not exist |
FileAccessException |
a file exists but could not be opened or written to |
InvalidDataException |
a data uri or a json payload could not be decoded |
ValidationFailedException |
a file was turned away by a validation; getErrors() says everything that is wrong and getChunk() hands back the file, when a single one was at fault |
Testing
Every pull request and every push to master is checked by GitHub Actions: the test suite runs on
PHP 8.4 and 8.5 (against both the lowest and the highest supported dependencies), the coding style is checked with
PHP_CodeSniffer, the sources are analysed with PHPStan and the code coverage of the test suite is measured and has to
stay above 90%.
Next to the unit tests there are feature tests that send a whole file chunk by chunk — in every order the chunks can arrive in, and in all three formats the package reads — and download it again range by range, with an interruption in the middle.
You can run the same checks locally. With PHP and Composer installed on your machine:
composer install composer test # run the test suite composer test-coverage # run the test suite and report code coverage composer check-style # check the coding style composer fix-style # fix the coding style where possible composer analyse # run static analysis composer ci # run all of the checks above
If you would rather not install PHP on your machine, the shipped Dockerfile and Makefile run everything inside a
container:
make test # run the test suite make coverage # run the test suite and report code coverage make check-style # check the coding style make fix-style # fix the coding style where possible make analyse # run static analysis make ci # run all of the checks above make shell # open a shell inside the container make help # list every available target
Another PHP version can be used with make test PHP_VERSION=8.5.
Change log
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING and CONDUCT for details.
Security
If you discover any security related issues, please email khanzadimahdi@gmail.com instead of using the issue tracker.
Credits
License
The MIT License (MIT). Please see License File for more information.


