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 Tus Upload Laravel Package

oneofftech/laravel-tus-upload

Add tus.io resumable uploads to Laravel. Provides an upload endpoint, upload lifecycle management, and events so large files can resume after network drops and integrate cleanly with your app's storage and authorization.

View on GitHub
Deep Wiki
Context7

Getting Started

Install (v0.11.0 requires PHP 8.0+ and supports Laravel 9):

composer require oneofftech/laravel-tus-upload:^0.11
php artisan vendor:publish --provider="OneOffTech\TusUpload\TusUploadServiceProvider"
php artisan migrate

If you are still on Laravel 8 with PHP 7.4, pin ^0.10 — v0.11 dropped PHP ≤ 7.4, so the upgrade is a hard platform requirement, not an optional one.

Where to look first:

  1. The published config/tus-upload.php — this is the whole surface area: route prefix, middleware stack applied to the tus endpoints, target storage disk/directory, max upload size, and upload expiration. Read it before writing any code.
  2. The routes the service provider registers (POST creation, PATCH chunk append, HEAD offset probe, DELETE termination under your prefix, default /tus).
  3. The shipped OneOffTech\TusUpload\TusUpload model + migration — the row that represents an in-progress upload and exposes the request_id used in the upload URL.

First use case — hand an upload slot to your frontend:

// routes/web.php
Route::post('/uploads', function (Request $request) {
    $upload = TusUpload::create([
        'size'     => $request->integer('size'),
        'filename' => $request->string('name'),
        // attach whatever ownership columns you added to the table
    ]);

    return ['request_id' => $upload->request_id];
})->middleware('auth');
// frontend, with tus-js-client
const upload = new tus.Upload(file, {
  endpoint: '/tus',
  uploadUrl: `/tus/${requestId}`,   // from the call above
  chunkSize: 5 * 1024 * 1024,
  metadata: { filename: file.name, filetype: file.type },
  onSuccess: () => fetch(`/uploads/${requestId}/complete`, { method: 'POST' }),
});
upload.start();

The mental model: Laravel issues the upload record, the tus client owns the transfer, your app only asserts ownership and then consumes the finished file.

Implementation Patterns

Server-issued uploads, not client-issued. Never let the browser hit the tus creation endpoint directly unless you have no quotas, no auth, and no tenancy. Create the TusUpload record in your own controller (where auth(), plan limits, and per-user storage paths are available) and return only the request_id. Add a user_id / tenant_id column via your own migration and a policy check on the tus routes.

Middleware is the auth seam. Put auth, tenancy resolution, and throttling into the middleware array of the config (or override per-route if you re-register the routes). Because tus clients send PATCH/HEAD/DELETE, anything keyed off session POST semantics needs care — see Gotchas.

Completion handling — event listener, not polling. The package fires events around creation/completion (browse OneOffTech\TusUpload\Events). Register a listener that:

  1. Loads the TusUpload row, verifies the owner.
  2. Moves/streams the assembled file off the tus disk into permanent storage:
    $stream = Storage::disk(config('tus-upload.disk'))->readStream($upload->file_path);
    Storage::disk('s3')->writeStream("files/{$upload->request_id}", $stream);
    
  3. Creates your domain model (Media, Attachment) and dispatches heavy work (thumbnails, virus scan, PDF extraction) to a queue.
  4. Marks the row consumed so a replayed completion call is a no-op.

Keep the listener fast — it runs inside the final PATCH request, which the client is still holding open.

Polling as a fallback. For UIs that need progress, expose a tiny endpoint returning Upload-Offset / Upload-Length (or read the model's stored offset) rather than proxying HEAD through your own controller. If you must proxy, forward Tus-Resumable: 1.0.0 and the Upload-Offset request header verbatim.

Separate disk for in-flight data. Point the tus disk at a dedicated directory or a tus-staging bucket with a lifecycle rule. Completed files should always be copied out; treat the tus location as ephemeral.

Chunk size tuning. 5–10 MB chunks are a good default. Smaller chunks mean more requests and more DB writes against the upload row; much larger chunks make resume granularity coarse and risk hitting proxy/PHP timeouts on slow links.

Testing. Use Storage::fake() for the tus disk, $this->withoutMiddleware() or an authenticated user, and drive the protocol with explicit headers:

$this->withHeaders([
    'Tus-Resumable' => '1.0.0',
    'Upload-Offset' => '0',
    'Content-Type'  => 'application/offset+octet-stream',
])->patch("/tus/{$upload->request_id}", $chunk);

Assert on Upload-Offset in the response, not just the status code.

Gotchas and Tips

The v0.11 upgrade is a platform jump. PHP 8.0+ is now mandatory, and Laravel 9 moves the filesystem to Flysystem 3. If you write custom code touching the tus disk, audit it: put()/writeStream() now throw on failure by default (throw: true) instead of returning false, and adapter-specific metadata keys differ. Wrap disk writes in try/catch and surface a real error rather than a silent failed upload.

412 Missing/Unsupported Tus-Resumable. Every request, including HEAD and DELETE, must carry Tus-Resumable: 1.0.0. If you proxy or add middleware that strips headers, uploads fail with a confusing 412. Also make sure your proxy forwards Upload-Offset, Upload-Length, Upload-Metadata, and Content-Type: application/offset+octet-stream.

CORS will bite you. A cross-origin SPA needs the tus headers both allowed and exposed:

// config/cors.php
'exposed_headers' => ['Tus-Resumable','Tus-Version','Tus-Extension','Tus-Max-Size','Upload-Offset','Upload-Length','Location'],
'allowed_headers' => ['Tus-Resumable','Upload-Offset','Upload-Length','Upload-Metadata','Upload-Checksum','Content-Type'],

Without exposed_headers the browser cannot read Upload-Offset and the client silently restarts from zero.

CSRF. PATCH requests from tus-js-client won't carry a CSRF token. Either put the tus prefix in VerifyCsrfToken::$except or move those routes into the api middleware group; otherwise you get 419s mid-upload.

Web server limits are the #1 cause of "stuck at 99%". For nginx: client_max_body_size, proxy_request_buffering off, and a generous proxy_read_timeout on the tus location. Also check post_max_size, max_execution_time, and any CDN/WAF body-size cap in front of the app. The Tus-Max-Size value returned by the server should match reality, or clients will happily start uploads that can never finish.

409 on PATCH = offset mismatch, not a bug. On resume (tab reload, network drop), the client must HEAD the upload URL first to learn the server's committed offset. If you're writing a custom client, do that before every retry; don't assume your last-known offset is authoritative.

Expiration is opt-in work. Incomplete uploads accumulate. Configure the expiration window in the config and schedule the purge command — discover its exact name with php artisan list | grep -i tus — e.g. $schedule->command('tus:purge')->hourly();. Purge removes DB rows; verify it also removes staged files on your disk, and add a cleanup job for orphaned files if it doesn't.

Don't assume extensions exist. Probe the base endpoint (OPTIONS /tus or an unauthenticated HEAD) and read Tus-Extension. Creation, termination, and expiration are typically present; checksum and concatenation are commonly absent. Building checksum verification into your client against a server that doesn't advertise it wastes a debugging session.

Never trust Upload-Metadata. It's client-supplied base64 key/value pairs. Validate filename and filetype server-side against an allowlist before using them for storage paths or content-type decisions, and derive the real size from Upload-Length you recorded at creation time, not from the client's claim at completion.

Idempotent completion. The final PATCH can be retried by a flaky client. Guard your completion listener with a status check or a unique constraint on request_id in your domain table, and never do the "move file" step twice.

Debugging checklist for a dead upload: response has Tus-Resumable? → config route prefix matches the client's endpoint? → Storage::disk() in the config actually writable in the web process (permissions on local, IAM on S3)? → storage/logs/laravel.log for the actual exception, since tus clients often swallow the body and only report the status.

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.
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core