Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Chunky Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Backend install (5 minutes).

composer require netipar/laravel-chunky
php artisan chunky:install   # publishes config/chunky.php + migrations
php artisan migrate

Requires PHP 8.3+ and Laravel 12/13. Verify the environment before writing any application code — this is the single highest-value first step:

php artisan chunky:doctor --wait=5   # disks, queue probe, broadcast driver, locking, tracker

It exits non-zero on errors, so wire it into your deploy pipeline the same day you install the package.

2. Define one profile. The profile is the package's central abstraction: validation, destination, authorization, and the post-assembly hook all live there. Start with Chunky::simple() in a service provider for anything trivial:

Chunky::simple('avatars', 'avatars', ['max_size' => 10 * 1024 * 1024, 'mimes' => ['image/jpeg', 'image/png']]);

Move to a generated class the moment you need a side effect:

php artisan make:chunky-profile AvatarProfile
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];   // surfaces as `payload` on the client
    }
}

Register it under profiles in config/chunky.php, keyed by the name the frontend will send.

3. Frontend.

npm install @netipar/chunky-core
import { manager, configure } from '@netipar/chunky-core';
configure({ baseUrl: '/api/chunky', headers: { 'X-CSRF-TOKEN': csrf } });

const uploader = manager.upload(file, { profile: 'avatar' });
uploader.subscribe((s) => console.log(s.status, s.progress, s.etaSeconds));
const result = await uploader.upload();      // resolves in every assembly mode
console.log(result.file?.url, result.payload?.media_id);

That is a complete working flow — no worker, no Echo, no broadcasting. Files under 256 MB assemble in-request and the final response carries the file plus your completed() payload.

Where to read next, in order: docs/en/protocol.md (wire format — read this before debugging anything), docs/en/configuration.md (deployment recipes), docs/openapi.yaml (if you have a non-JS client or want contract tests), UPGRADE.md only if you inherited a 0.x install.

First use case to ship: authenticated avatar/image upload on a single model. It exercises every part of the package — profile, authorization, completed() side effect, and frontend progress — with the smallest blast radius.

Implementation Patterns

One profile per domain concept, not per form. Profiles are the unit of validation and destination, so avatar, gallery, and video should be three profiles even if three forms post to them. Resist a generic uploads profile with a type parameter; you lose the typed rules and the per-profile transport()/disk() override.

Pick the assembly mode deliberately, per environment.

Mode Final chunk response Worker needed
sync completed + file + payload inline No
queue assembling; client polls Yes
auto (default) sync below 256 MB, queue above Only for large files

auto is the right default for a Laravel app that mostly receives images. Tighten assembly.sync_threshold if your PHP-FPM timeouts or request body limits are modest, and go explicit queue for a video/asset pipeline where you already run workers. The client API is identical in all three — await uploader.upload() always resolves — so this is a server-side decision you can change without touching the frontend.

Reach for direct_s3 when bandwidth is the bottleneck. A profile that returns 'direct_s3' from transport() streams multipart parts straight to S3 via presigned URLs; Laravel only orchestrates initiate/complete/abort and never touches the bytes. Assembly is literally CompleteMultipartUpload, so there is no merge step and no PHP memory ceiling. The frontend picks the transport up from the initiate response with zero caller changes:

class VideoProfile extends UploadProfile
{
    public function transport(): string { return 'direct_s3'; }
}

Requires aws/aws-sdk-php, chunky.transports.direct_s3.disk pointing at an s3 disk, and ExposeHeaders: ETag on the bucket CORS — the ETag is what the client needs to complete the multipart upload, and omitting it produces a confusing failure far from the config.

Let the manager own lifecycle; let components own presentation. The golden rule from the docs is that a component unmounting unsubscribes, never cancels. In Vue this is automatic if you use useUpload(); in React, do not put cancel() in a useEffect cleanup, and hoist ChunkyProvider above your router so remounts don't re-configure(). For a global tray, useUploads() (Vue) or the manager's list gives you every active upload regardless of which page started it.

Add the unload guard once, at app bootstrap. manager.installUnloadGuard() warns on real tab close/navigation while uploads are active; it deliberately ignores SPA route changes. Pair it with your router's own leave-hook if you want in-app warnings.

Use batches for multi-file UX, not loops. manager.batch(files, { profile: 'gallery', concurrency: 3 }) gives you aggregate completed/total, a single awaitable, and — importantly — cancellation that reaches every non-terminal member across both tracker drivers. Three manager.upload() calls in a row do not give you that.

Wire completed() into domain events rather than controllers. Because the hook receives the assembled CompletedUpload, it's the natural place to create the Media row, dispatch a ProcessImage job, or attach to a parent model. Return a minimal payload — an id, not a whole model — since it goes straight back over the wire.

Treat error codes as the contract. Frontend errors are typed ChunkyError with a stable .code (validation_failed, unauthorized, upload_not_found, invalid_state, checksum_mismatch, lock_timeout). Retry on lock_timeout and transient network failures; do not retry invalid_state or checksum_mismatch. Never branch on .message.

Use the listeners for observability, not the response. Eleven lifecycle events fire (UploadInitiated, ChunkUploaded, UploadCompleted, BatchCompleted, …). A single UploadCompleted listener writing structured logs is usually enough to debug production, and it's where you'd attach metrics on failure rate per profile.

Schedule cleanup on day one. chunky:cleanup removes expired, unfinished uploads and their chunks. Without it, abandoned chunks accumulate forever in whichever tracker driver you chose.

Opt into end-to-end verification for compliance-sensitive uploads. manager.upload(file, { profile: 'avatar', fileChecksum: true }) hashes the file in parallel with the upload and the server verifies the assembled result. It's dependency-free but it is still a full SHA-256 pass over the file — enable per profile, not globally.

Gotchas and Tips

Config fails fast at boot. config/chunky.php is validated into a typed ChunkyConfig and a bad value throws with the offending key. Good for production, mildly annoying in tests — if you override config per-test, expect the failure at boot rather than at request time.

The 404/403 asymmetry is intentional. Non-owners get 404 on status/cancel (so upload ids can't be enumerated) but 403 on chunk POST. Don't "fix" this into a consistent 403; you'd reintroduce the enumeration channel. It also means your frontend can't always distinguish "gone" from "not yours" — treat both as terminal.

Anonymous uploads are open by default for backward compatibility. If your app is authenticated-only, add authorize() returning $context->user !== null (or similar) to every profile, and swap the Authorizer in config if you need different semantics globally. Audit this before going live — it's the easiest way to accidentally ship an open upload endpoint.

A queue worker becomes mandatory the moment a file exceeds the threshold. Under auto, 300 MB of video silently requires a worker while 200 MB doesn't. This is the classic "works in staging, hangs in production" bug. Gate deploys on php artisan chunky:doctor --wait=5 and make the threshold explicit in your config comments.

Chunk size and count come from initiate. The server dictates chunk_size and total_chunks; never hardcode them client-side or you'll break resume and checksums. If you write a non-JS client against docs/openapi.yaml, treat those fields as authoritative per-upload.

Resume is fingerprint-based, so changing the profile can strand uploads. A reload resumes by matching the file fingerprint; if you change max_size, mimes, or the destination between attempts, an in-flight upload may fail validation instead of resuming. Clear the tracker (or run cleanup) when iterating on profile rules in development.

upload_not_found after a deploy is usually not your code. Switching tracker driver (database ↔ filesystem) or clearing the tracking store orphans in-flight sessions. Pick a driver early, and treat it as infrastructure rather than a per-environment toggle.

Broadcasting is off by default — and lossy by design when on. Payloads are sanitized (disk, final_path, user_id never leave the server) and versioned {"v":1,…}. High-frequency events like ChunkUploaded are excluded, so enabling broadcasting gives you completion signals, not chunk-level telemetry. If you need chunk-level progress elsewhere, subscribe on the frontend instead.

Direct-to-S3 has three moving parts that fail independently: the AWS SDK installed, the direct_s3.disk correctly configured, and ExposeHeaders: ETag in the bucket CORS. S3-compatible targets (MinIO solid, Cloudflare R2 best effort) deviate in the details — smoke-test with a small file before committing to a provider.

completed() return value is your API payload. Keep it small and stable; it's consumed by the frontend's result.payload, so renaming a key is a breaking change to any client that reads it. Version it if you must change it.

The upload lives outside your component's lifecycle. The most common integration bug is a React useEffect cleanup calling cancel(), or a Vue component being re-created by v-if and starting a second upload of the same file. manager de-duplicates by fingerprint on initiate, so the usual symptom is a resume, not an error — which makes it hard to spot in logs.

Version pinning matters here. v1.0 is a ground-up rewrite and the package is very young (zero dependents, new ecosystem). Pin exact versions in composer.json and package.json, keep CHANGELOG.md in the upgrade review, and check that the released frontend wrappers track the same minor as the backend — the wire protocol is versioned for events, not for chunk endpoints.

Preview URLs are managed resources. uploader.previewUrl() lazily creates a cached object URL; the manager revokes it on manager.remove(), not on component unmount. If you render previews for long-lived trays, call remove() when a user dismisses an upload or you'll leak blobs. For real thumbnails, await createThumbnail(file, { maxDimension: 256 }) returns a dependency-free WebP Blob.

Headless components mean you own the CSS. UploadTray and ChunkDropzone ship unstyled. Budget a little time for styling rather than expecting a drop-in UI — this is a deliberate trade, but it's the most common surprise for teams expecting a Filament-style widget. The Livewire path (<livewire:chunky-upload profile="avatar" />) requires livewire/livewire plus the Alpine plugin registration; it's the fastest route to a working UI in a Blade app.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
adachsoft/php-code-style