Direct uploads
A direct upload sends the file from the browser straight to storage on a URL the package signs. MediaLibrary::adoptUpload($token) then turns the stored file into media: it inspects the file where it lies and moves it inside storage, so the bytes never pass through your application, not even to be copied. It is off by default — set media.direct_uploads.enabled:
use RoundlyConsulting\MediaLibrary\Facades\MediaLibrary;
// 1. Your API signs an upload for the browser.
$upload = MediaLibrary::for($post)
->upload(fileName: $request->name, size: $request->size) // declared size vs the bucket's cap
->declaringType($request->type) // optional: early refusal only
->withSha256($request->sha256) // optional: storage refuses other bytes
->forActor($request->user()) // optional: only this actor may adopt
->usingName('Cover')->withProperty('alt', 'Sea')->onQueue('media')
->expiresIn(15)
->toMediaBucket('gallery');
return [
'token' => $upload->token, // shown once — the package stores only its sha256
'method' => $upload->method->value, // put | post | multipart
'url' => $upload->url,
'headers' => $upload->headers, // send with the PUT
'fields' => $upload->fields, // the form fields of a POST
];
// 2. The browser uploads, then calls you back with the token.
$media = MediaLibrary::adoptUpload(
$request->token,
actor: $request->user(),
expectedOwner: $post, // the record this route serves
expectedBucket: 'gallery', // a ticket for another owner or bucket throws, unspent
);MediaLibrary::upload(...)->toBucket('library') signs a global media upload, and ->asDraft() adopts it as a draft you bind later with bindDraft(). The builder also takes withCustomProperties(), withVisibility() and useDisk(). Everything runs through MediaLibraryManager, so dependency injection works the same way ($manager->for($post)->upload(...)).
| DirectUpload | Meaning |
|---|---|
token | Adopt with it. Shown only here — the package stores its sha256. |
method | DirectUploadMethod::Put, Post or Multipart. |
url, headers | The PUT target and the headers to send (no Host). |
fields | A POST’s form fields; append the file field last. |
key | Where the bytes land — always minted by the server. |
maxSize | The ticket’s cap: the declared size. |
sizeEnforcedBySignature | true when storage itself refuses other sizes: a POST policy, or a PUT that signed its sha256. |
expiresAt | When the ticket and its URLs stop working. |
mediaUuid | The uuid the adopted media gets. |
partSize, partCount | Multipart only. |
The ticket
Issuing records a ticket in media_uploads: the disk, the server-minted key, the owner, bucket, actor, size cap, visibility, your options and the uuid the media will get. The token is the ticket’s id plus 48 random characters; only its sha256 is stored, lookups compare it in constant time and exception messages never contain it. Before anything is signed, the package refuses:
- uploads while direct uploads are off, and an unsaved owner;
- a declared size over the bucket’s cap (maxFileSize(), else media.max_file_size) and a declared type the bucket does not accept, strict mime mode included;
- more than max_pending_per_owner pending tickets for the owner (or for the actor, on global uploads): TooManyPendingDirectUploads, with the cap in $limit and the model it counted against in $holder;
- a disk that cannot sign what is asked (DirectUploadsNotSupported).
TooManyPendingDirectUploads extends DirectUploadsNotSupported, so one catch still covers both; catch it first to answer the cap differently — with a 429, say.
The key is never chosen by the browser — it lands under the private staging prefix (_uploads/). Private media whose bytes cannot change after the check upload in place, straight to their final key: multipart uploads, and a PUT that signed its sha256 on S3. Public media always go through staging, so unvetted bytes never sit under a public path.
Adoption
Bind the token to its route. A token is a bearer secret: on its own it does not say which record the route that adopts it serves. Pass expectedOwner: and expectedBucket: to adoptUpload() and a ticket issued for another owner (compared by morph type and key), for no owner (a global upload or a draft) or for another bucket throws DirectUploadTargetMismatch before anything else happens. The ticket is not claimed or changed, so the right route can still adopt it, and an adopted ticket’s media is not returned to the wrong route. $exception->mismatched is DirectUploadTargetMismatch::OWNER or ::BUCKET; the message names neither the ticket’s owner nor its bucket. completeUpload() takes the same two arguments and checks them before completing the upload. Without them any owner’s ticket is adopted, as in 2.1 — the next major plans to require the owner.
Then adoption runs AdoptDirectUploadAction:
- Claim — one conditional update moves the ticket from pending to adopting, so of two concurrent requests exactly one gets it; the other gets DirectUploadIncomplete. Once adopted, a repeated call returns the same media.
- Size — read from storage (HeadObject on S3). A missing object throws DirectUploadIncomplete and hands the ticket back; an object over the cap is rejected.
- Inspect — the type is sniffed from the leading bytes (one ranged read on S3), never taken from the stored Content-Type the uploader chose. Dimensions come from the same bytes, EXIF orientation included, and the checksum is one streamed pass in constant memory. A declared sha256 must match.
- Add — the file enters the bucket like any add: the bucket’s current rules, the dedup scope, singleFile(), variants on the ticket’s queue and MediaHasBeenAdded. The object is moved inside storage to a path of the media’s own — a server-side CopyObject on S3, a filesystem move elsewhere — and an in-place upload stays where it is. If anything fails after the row is saved, the row goes, the object moves back and the ticket can be adopted again, even when your own DB::transaction() rolls back.
- Done — the ticket becomes adopted and DirectUploadHasBeenAdopted fires; its deduplicated flag is true when identical content in the dedup scope was shared and the upload deleted after commit.
A file the bucket refuses — type, size, dimensions, checksum, an owner that is gone — is rejected for good: its object is deleted once the transaction commits, the ticket records the reason, DirectUploadHasBeenRejected fires and FileUnacceptableForBucket is rethrown. Adopting it again throws DirectUploadNotFound.
On S3 the move sets ContentType to the sniffed type and ContentDisposition by the streaming route’s rule, so HTML sent as image/png is stored as text/html, as an attachment. Media adopted in place keep the Content-Type the browser sent, because nothing rewrites them — they are always private, and every temporary URL of theirs, getTemporaryUrl() included, asks the storage origin for the response headers whatever media.temporary_url_response_headers says. That costs one indexed lookup per media instance while direct uploads are enabled.
PUT, POST or multipart
- PUT (default) — a presigned PutObject on S3 (presign_endpoint honoured), or the disk’s temporaryUploadUrl() elsewhere. The size is checked at adoption; when a sha256 is signed, storage itself refuses other bytes.
- POST (disks.<name>.method = post) — an S3 POST policy with content-length-range [1, size], the key and the declared Content-Type: the only signed size cap. Cloudflare R2 does not support it.
- Multipart (->multipart(?int $partSize)) — created on issue, parts signed in batches; the size is checked before every batch and at completion.
$upload = MediaLibrary::for($video)->upload('film.mp4', $size)->multipart()->toMediaBucket('videos');
$parts = MediaLibrary::signUploadParts($upload->token, [1, 2, 3]); // list<UploadPart>: partNumber, url, headers
// … the browser PUTs each part and keeps each response's ETag …
$media = MediaLibrary::completeUpload($upload->token, [1 => $etag1, 2 => $etag2, 3 => $etag3]);Before every batch the parts already uploaded are listed: a part bigger than the ticket’s part size, or parts adding up past its cap, reject the ticket and abort the upload. completeUpload() runs under the adoption claim, checks the parts against the reported ETags, the part size and the cap, completes the upload and adopts it. Add an AbortIncompleteMultipartUpload lifecycle rule to the bucket as a backstop.
Abort, expiry, pruning and trusted adoption
MediaLibrary::abortUpload($token); // cancel a pending upload and remove its object
MediaLibrary::pruneUploads(); // expire stale tickets, clear leftovers; returns the count
// Adopt a trusted stored file without a ticket — never pass a path a client chose.
MediaLibrary::for($post)->adoptFromDisk('imports/2026-11/film.mp4', 's3')->toMediaBucket('videos');- abortUpload() makes a pending ticket aborted and removes its object or unfinished multipart upload; an adopted, rejected or aborted ticket is left unchanged, one being adopted throws DirectUploadIncomplete.
- After its expiry a ticket can no longer be adopted (DirectUploadExpired).
- pruneUploads() / media:prune-uploads (schedule it) expires tickets past expiry plus prune_grace that were never adopted, removes staging objects no live ticket holds and aborts orphaned multipart uploads. A prune and an adoption never both take a ticket.
- adoptFromDisk() inspects a stored file in place and moves it, never through the app. Only a path under the staging prefix or adoptable_prefixes that no media row and no live ticket uses can be adopted — never pass a path a client chose. Moving between two disks needs both to be S3; other pairs throw DirectUploadsNotSupported.
Configuration
// config/media.php
'direct_uploads' => [
'enabled' => true,
'adoptable_prefixes' => ['imports/'],
'disks' => [
's3' => [
'method' => 'post', // a signed size cap (AWS S3, MinIO; not Cloudflare R2)
'storage_checksums' => false, // true = trust the store's own sha256 of a signed PUT
],
],
],| Key (media.direct_uploads.) | Default | Meaning |
|---|---|---|
enabled | false | Everything is refused while off. |
table_name | media_uploads | The tickets table. |
staging_prefix | _uploads/ | Where tickets upload before adoption. Keep it private: no public ACL or bucket policy may expose it. |
staging_disk | null | null = the media’s own disk. Another disk must be an S3 bucket the same credentials can copy from — e.g. a private bucket while media live in a public one, needed on R2, where public access is bucket-wide. |
ttl | 15 | Minutes a ticket and its signed URLs live (at least 1); expiresIn() per upload. |
max_pending_per_owner | 20 | Pending tickets per owner, or per actor for global uploads. |
sniff_bytes | 65536 | Bytes read to sniff the type (at least 512). Up to 512 KiB is read for image dimensions. |
probe_max_bytes | 16 MiB | Largest raster image kept locally during the checksum pass, so its placeholders are computed. Larger images get them from the variants pass. |
prune_grace | 60 | Minutes after expiry before media:prune-uploads clears a ticket. |
adoptable_prefixes | [] | Prefixes adoptFromDisk() accepts besides the staging prefix, e.g. ['imports/']. |
multipart.part_size | 16 MiB | At least 5 MiB (S3’s minimum). Cloudflare R2 needs equal parts. |
multipart.max_parts | 10000 | The most parts one upload may have. |
disks.<name>.method | put | post for a signed size cap (AWS S3, MinIO; not R2). |
disks.<name>.storage_checksums | false | Trust the store’s own sha256 of a PUT that signed one instead of re-reading the file (checksum algorithm sha256 only; test your store first). |
Exceptions and events
- DirectUploadsNotSupported — direct uploads are off, the disk cannot sign what is asked (POST and multipart are S3 only), a staging and media disk that cannot copy inside storage, a path adoptFromDisk() may not adopt, too many parts or too many pending tickets. Nothing was signed.
- DirectUploadNotFound — an unknown, malformed, rejected or aborted token; it never echoes the token.
- DirectUploadExpired — the ticket expired before adoption.
- DirectUploadIncomplete — retryable: the file is not in storage yet, another request is adopting the ticket, or a multipart completion names parts the store does not hold. The ticket stays usable.
- TooManyPendingDirectUploads — extends DirectUploadsNotSupported: the owner (or, for a global upload or a draft, the actor) already holds max_pending_per_owner pending tickets. Public $limit and $holder; the message names neither.
- DirectUploadActorMismatch — a ticket issued forActor() was adopted by another actor, or none.
- DirectUploadTargetMismatch — adoptUpload() or completeUpload() got an expectedOwner or expectedBucket the ticket was not issued for; a global or draft ticket never matches an expected owner. The ticket is untouched and stays adoptable. Public $mismatched (OWNER / BUCKET).
- DirectUploadHasBeenAdopted (media, upload, deduplicated) and DirectUploadHasBeenRejected (upload, reason) fire alongside the usual media events.
Security notes
- Keys are server-minted, the staging prefix is private and adoption takes only the token; the ticket fixes the disk, key, owner, bucket, draft flag, size cap and optional actor.
- Pass expectedOwner: and expectedBucket: on adoption and completion, so a token issued for one record can’t be finished on another.
- The type is always sniffed, the stored name’s extension is made to match it and the bucket’s current rules apply — nothing the browser declared is trusted at adoption.
- The overwrite-after-check race is closed by the move (staging) or by in-place uploads that cannot change (completed multipart, signed sha256).
- Laravel’s local serve upload route buffers the whole body in PHP: fine for development, not a production path for large files. The package ships no receiving route of its own.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.