netipar/laravel-chunky
Resumable, chunk-based file uploads for Laravel. Send large files reliably over flaky connections with server-side chunk tracking and fingerprint-based resume. Typed backend plus Vue, React, Alpine and Livewire frontends. No queue worker or broadcasting required by default.
Resumable, chunk-based file uploads for Laravel — upload large files reliably over flaky connections. A small, typed backend built on ports & adapters, and a framework-agnostic frontend engine with first-class wrappers for Vue 3, React, Alpine.js, and Livewire.
Works out of the box with no queue worker and no broadcasting — the direct upload response already carries the result. Turn those on later for scale.
v1.0 is a ground-up rewrite. Coming from
0.x? See UPGRADE.md.
auto mode small files assemble in-request and the final response contains the file + your payload. No Echo, no worker required.assembly.mode = queue (or auto for files above the threshold)1. Install the backend.
composer require netipar/laravel-chunky
php artisan chunky:install # publishes config + migrations
php artisan migrate
2. Register an upload profile — where files go and what to do when they finish. In a service provider:
use NETipar\Chunky\Facades\Chunky;
Chunky::simple('avatars', 'avatars', ['max_size' => 10 * 1024 * 1024, 'mimes' => ['image/jpeg', 'image/png']]);
For real logic (e.g. creating a Media record), generate a class:
php artisan make:chunky-profile AvatarProfile
use NETipar\Chunky\Profiles\CompletedUpload;
use NETipar\Chunky\Profiles\UploadContext;
use NETipar\Chunky\Profiles\UploadProfile;
class AvatarProfile extends UploadProfile
{
public function rules(): array
{
return ['file_size' => ['max:10485760'], 'mime_type' => ['in:image/jpeg,image/png']];
}
public function directory(UploadContext $context): string
{
return "avatars/{$context->userId()}";
}
public function authorize(UploadContext $context): bool
{
return $context->user !== null;
}
public function completed(CompletedUpload $upload): array
{
$media = Media::create(['disk' => $upload->disk, 'path' => $upload->path, 'user_id' => $upload->userId]);
return ['media_id' => $media->id]; // becomes the response `payload`
}
}
Register it in config/chunky.php:
'profiles' => ['avatar' => \App\Chunky\Profiles\AvatarProfile::class],
3. Upload from the frontend.
npm install @netipar/chunky-core
import { manager, configure } from '@netipar/chunky-core';
configure({
baseUrl: '/api/chunky',
headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]')!.getAttribute('content')! },
});
const uploader = manager.upload(file, { profile: 'avatar' });
uploader.subscribe((state) => {
console.log(state.status, state.progress, state.etaSeconds);
});
const result = await uploader.upload(); // resolves once assembled
console.log(result.file?.url, result.payload?.media_id);
That's it — no worker, no broadcasting. Files up to 256 MB assemble in the request and the result comes straight back.
initiate ──▶ upload chunks (concurrent, retried) ──▶ assemble ──▶ completed
POST /upload POST /upload/{id}/chunks (sync or queued)
upload_id, chunk_size, and total_chunks. If a matching fingerprint is found, it resumes an existing upload instead.completed() hook, and — in sync mode — returns the result inline.The full wire protocol is documented in docs/en/protocol.md (magyarul) and docs/openapi.yaml.
A profile is the single place that decides validation, destination, authorization, and the post-assembly hook. Two ways to define one:
Chunky::simple($name, $directory, $options) — a fixed directory + optional max_size / mimes.UploadProfile — override rules(), disk(), directory(), authorize(), fileName(), completed(), maxFileSize().The batch member endpoint validates against the batch's profile, so a client can't smuggle in a more permissive one.
All wrappers are thin adapters over @netipar/chunky-core. The golden rule: a component unmounting only unsubscribes — it never cancels the upload. The upload lives in the manager and survives navigation.
import { manager, configure, Uploader } from '@netipar/chunky-core';
const uploader = manager.upload(file, { profile: 'avatar' });
uploader.on('completed', (result) => { /* ... */ });
uploader.on('failed', (error) => console.error(error.code, error.message));
uploader.pause();
uploader.resume();
await uploader.cancel();
// Warn on real page unload while uploads are active (SPA nav is unaffected):
manager.installUnloadGuard();
uploader.getState() returns an immutable snapshot: { status, progress, uploadedChunks, totalChunks, bytesPerSecond, etaSeconds, file, result, error }.
For image uploads, uploader.previewUrl() lazily creates a cached object URL you can drop into an <img> (null for non-image files); the manager revokes it when the upload is evicted via manager.remove(). Need a real downscaled thumbnail instead? await createThumbnail(file, { maxDimension: 256 }) returns a Blob (WebP by default), dependency-free.
Want end-to-end verification? manager.upload(file, { profile: 'avatar', fileChecksum: true }) hashes the whole file (SHA-256, dependency-free) in parallel with the upload and the server verifies the assembled result against it.
@netipar/chunky-vue3import { createChunky } from '@netipar/chunky-vue3';
app.use(createChunky({ baseUrl: '/api/chunky', headers: { 'X-CSRF-TOKEN': token } }));
<script setup lang="ts">
import { useUpload } from '@netipar/chunky-vue3';
const { start, state, previewUrl } = useUpload();
const onPick = (e: Event) => start((e.target as HTMLInputElement).files![0], { profile: 'avatar' });
</script>
<template>
<input type="file" @change="onPick" />
<img v-if="previewUrl" :src="previewUrl" alt="" />
<progress v-if="state" :value="state.progress" max="100" />
</template>
useUploads() gives a reactive list of all active uploads (for a global tray); UploadTray and ChunkDropzone are headless components you can style.
@netipar/chunky-reactimport { ChunkyProvider, useUpload } from '@netipar/chunky-react';
function Uploader() {
const { start, state } = useUpload();
return (
<>
<input type="file" onChange={(e) => start(e.target.files![0], { profile: 'avatar' })} />
{state && <progress value={state.progress} max={100} />}
</>
);
}
// <ChunkyProvider config={{ baseUrl: '/api/chunky', headers: { 'X-CSRF-TOKEN': token } }}>…</ChunkyProvider>
@netipar/chunky-alpineimport { registerChunky } from '@netipar/chunky-alpine';
Alpine.plugin((Alpine) => registerChunky(Alpine, { baseUrl: '/api/chunky' }));
<div x-data="chunkyUpload({ profile: 'avatar' })">
<input type="file" @change="onFileChange($event)" />
<template x-if="state"><progress :value="state.progress" max="100"></progress></template>
</div>
composer require livewire/livewire, register the Alpine component as above, then drop in the widget:
<livewire:chunky-upload profile="avatar" />
config('chunky.assembly.mode') — auto (default), sync, or queue.
| Mode | Final chunk response | Needs a worker? |
|---|---|---|
sync |
{"status":"completed", "file":…, "payload":…} |
No |
queue |
{"status":"assembling"} — client polls status |
Yes |
auto |
sync under assembly.sync_threshold (256 MB), else queue |
Only for large files |
The frontend await uploader.upload() resolves with the final result in all modes — it polls automatically when assembly is queued.
For the biggest scale win, a profile can opt into the direct_s3 transport — chunks go straight to S3 as multipart parts via presigned URLs, and Laravel only orchestrates (initiate, URL issuing, complete, abort). PHP never touches the bytes and there is no merge step: assembly is the S3 CompleteMultipartUpload.
class VideoProfile extends UploadProfile
{
public function transport(): string
{
return 'direct_s3';
}
// directory(), completed(), … as usual
}
Set chunky.transports.direct_s3.disk to your s3 disk, require aws/aws-sdk-php, and add ExposeHeaders: ETag to the bucket CORS (recipe). The frontend clients pick the transport up automatically from the initiate response — the caller API (upload/pause/resume/cancel/subscribe) is unchanged. Works with S3-compatible targets (MinIO; Cloudflare R2 best effort). Wire details in docs/en/protocol.md.
const batch = manager.batch([file1, file2, file3], { profile: 'gallery', concurrency: 3 });
batch.subscribe((s) => console.log(`${s.completed}/${s.total}`));
const result = await batch.upload(); // { status: 'completed' | 'partially_completed' | 'cancelled', results }
Cancelling a batch cancels every non-terminal member on any tracker driver.
Uploads are owned by the authenticating user. Non-owners are answered with 404 on status/cancel (so upload ids can't be enumerated) and 403 on chunk POST. Anonymous uploads stay open for backward compatibility. Swap the Authorizer in config/chunky.php to customize.
Eleven events fire through the lifecycle (UploadInitiated, ChunkUploaded, UploadCompleted, BatchCompleted, …). Listen to them like any Laravel event.
Broadcasting is off by default (broadcasting.enabled). When enabled, payloads are sanitized (disk, final_path, user_id never leave the server) and versioned ({"v":1,…}). High-frequency events (ChunkUploaded, …) are excluded by default so turning it on only pushes the useful completion events.
Every non-2xx response is a machine-readable envelope:
{ "error": { "code": "upload_expired", "message": "The upload has expired." } }
The frontend surfaces this as a typed ChunkyError with a stable .code (validation_failed, unauthorized, upload_not_found, invalid_state, checksum_mismatch, lock_timeout, …). Retry decisions use the code, never the message. Full table in docs/en/protocol.md.
| Command | What it does |
|---|---|
chunky:install |
Publish config + migrations |
chunky:doctor |
Live health checks: disks, queue worker probe (--wait=5), broadcast driver, locking, tracker — exits non-zero on errors (CI/deploy gate) |
chunky:cleanup |
Remove expired, unfinished uploads and their chunks (schedule it) |
make:chunky-profile |
Generate an UploadProfile class |
config/chunky.php is validated at boot into a typed ChunkyConfig — a bad value fails fast with the offending key. Full reference and deployment recipes in docs/en/configuration.md (magyarul).
0.xLicensed under the MIT license.
How can I help you explore Laravel packages today?