← Laracrate 8 / 19

Uploads

Upload modes

Laracrate gives you three ways to get a file's bytes into your storage backend. They differ in who carries the bytes (your PHP server or the browser talking straight to S3/R2) and how large the file is. In every mode the end result is the same: a row in laracrate_files created through $model->addFile(...), with the binary already at its canonical key.

Mode How the bytes travel Best for Pros Cons
Server-side Browser to your PHP, then PHP to the disk Small files, simple forms, trusted server flows One request, no JS, works on any disk Bytes pass through PHP (memory, request size limits)
Direct presigned PUT Browser straight to S3/R2 via a presigned URL Most uploads up to roughly 100 MB Offloads bandwidth from PHP, progress events Needs S3-compatible disk (or the local fallback), two steps
Multipart Browser uploads in parts straight to S3/R2 Large files (video, archives) Parallel parts, resumable, no PHP transfer S3/R2 only, more orchestration

The threshold between presigned and multipart is just a frontend hint, config('laracrate.multipart.threshold') (100 MB by default). The server does not enforce it. Your client code decides which path to take based on file.size.

Server-side (addFile with an UploadedFile)

The simplest mode. Hand addFile() the UploadedFile straight from the request and Laracrate writes it to the collection's disk for you.

public function store(Request $request)
{
    $request->validate(['avatar' => 'required|image|max:5120']);

    $file = $request->user()->addFile($request->file('avatar'), 'avatar');

    return back();
}

addFile() also accepts a Binary value object, a FileUpload (see below), or a string key. See the Working with files from your models section for the full signature and the $data, $slots, $folder parameters.

Direct presigned PUT

Here the browser uploads straight to S3/R2 and your server never touches the bytes. The flow is: ask the server for a presigned URL, PUT the file to it, then confirm by sending the resulting key back so addFile() can persist the File row.

The JS helper ships at resources/js/laracrate.js inside the package (it is not published to npm). Copy it into your app's JS sources, or point a bundler alias at vendor/edulazaro/laracrate/resources/js/laracrate.js, then import presignAndUpload, which does the presign request and the PUT (with progress) in one call:

import { presignAndUpload } from './laracrate';

const result = await presignAndUpload(file, {
    disk: 'media',
    maxSizeKb: 10240,
    onProgress: (ratio) => console.log(Math.round(ratio * 100) + '%'),
});

// result = { key, disk, original_name, mime_type, size }
// Send it to your own controller to confirm.
await fetch('/profile/avatar', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
    },
    body: JSON.stringify(result),
});

presignAndUpload(file, opts) options: disk (required), maxSizeKb, presignUrl (override the presign route), and onProgress (a 0..1 callback). It returns { key, disk, original_name, mime_type, size }.

On the backend, rebuild a FileUpload from that payload and pass it to addFile():

use EduLazaro\Laracrate\Support\FileUpload;

public function confirm(Request $request)
{
    $data = $request->validate([
        'disk'          => 'required|string',
        'key'           => 'required|string',
        'original_name' => 'required|string',
        'mime_type'     => 'required|string',
        'size'          => 'required|integer',
    ]);

    $file = $request->user()->addFile(
        FileUpload::fromArray($data),
        'avatar'
    );

    return response()->json(['slug' => $file->slug]);
}

FileUpload::fromArray() accepts disk, key, original_name (or originalName), mime_type (or mimeType), and optional size, width, height, duration, digest.

The size is measured, not trusted. addFile() asks the bucket how big the object is (one HEAD, no download) and checks the collection's rules with that, whatever size says: a presigned PUT does not bind the length, so the size the browser declares means nothing. An object over the maximum is refused (UploadRejected with reason size) and deleted from temp/; one that is not in the bucket is refused with reason missing.

Where the file lands. The presign endpoint always signs a key under temp/; the request cannot name a model path, which used to let anyone write into another model's folder. When you then call addFile() with that temp/ key, Laracrate moves the object to its canonical key (/{fileable_id}/{collection}/{ulid}_{name}). On S3-compatible disks the move is a server-side copyObject plus deleteObject (StorageManager::moveServerSide()): the bytes never come back through PHP.

If the user cancels before confirming, delete the orphaned temp/ object with deleteTemp(disk, key) from the JS helper. It only deletes keys that start with temp/.

Multipart (large files)

For files past the threshold, S3/R2 multipart splits the upload into parts that the browser PUTs in parallel, each returning an ETag. It works under an upload grant, like sign and confirm: the grant is the only source of the target, so the request never names a disk, a model or a key. There is no bundled multipart client yet (the upload queue sends every file with a single PUT), so you drive the endpoints yourself based on file.size:

  1. Init. POST /laracrate/multipart/init with grant, file_name, mime and size, and optionally part_size. The file is checked against the collection and slot rules first, as sign does. The response gives you id, part_size, total_parts, expires_at, and parts (an array of { part_number, url, method }).
  2. Upload parts. PUT each part's bytes to its url. Capture the ETag response header for every part (your bucket's CORS has to expose it).
  3. Reissue (optional). If a part URL expires before you use it, POST /laracrate/multipart/{id}/parts with grant and part_numbers to get fresh URLs.
  4. Complete. POST /laracrate/multipart/{id}/complete with grant and parts as [{ part_number, etag }, ...]. S3 assembles the object and Laracrate creates the File, answering like confirm. The size checked is the one the bucket holds, since no part URL binds a length, and an object the collection refuses is deleted. To cancel instead, DELETE /laracrate/multipart/{id} with grant.

Reissuing, completing and cancelling only accept the grant that started the session, which only the user it was issued to can send. The object goes straight to its canonical key rather than to temp/: moving it afterwards would be a single CopyObject, which S3 refuses past 5 GB.

Sessions are tracked in laracrate_multipart_uploads, with the File they produced in file_id. Schedule laracrate:abort-stale-multipart (see the artisan commands section): until a session is aborted, its parts take space in the bucket without showing in any listing.

An application that starts multipart sessions on its own, with InitiateMultipartUploadAction and its siblings behind its own routes, keeps doing so: those sessions carry no grant, and these endpoints do not reach them.

Earlier versions took the disk and the key of init from the request, and left these routes with no middleware at all when multipart.middleware was null, the shipped default. If you cannot update yet, set that key explicitly.

Multipart requires an S3-compatible disk. Tuning lives under config('laracrate.multipart'): part_size (10 MB default, 5 MB minimum), expire_minutes, and url_ttl_minutes.

Local driver for development

You usually do not have S3/R2 in local dev. When the upload disk uses Laravel's local driver, StorageManager::presignedUpload() cannot mint a true presigned URL, so it returns a Laravel signed route to the local upload endpoint instead (POST instead of PUT). presignAndUpload follows whatever URL and method the presign response specifies, so the same client code works against local storage with no changes. The signed-route endpoints serving this are described in the next section.

HTTP endpoints

Laracrate registers its routes in routes/web.php. They cover four concerns: direct uploads (presign and multipart), streaming and downloading protected files, polling processing status, and the local-driver upload/serve fallback. Each group has its own prefix, middleware, and route-name prefix driven by config, so you can move or re-protect them without touching the package.

You rarely call these routes by name from PHP. The JS helper and your upload flow hit them by URL. The full set:

Method URI Route name Purpose
POST laracrate/uploads/presign laracrate.uploads.presign Mint a presigned PUT URL (or local signed-route fallback) for a direct upload
DELETE laracrate/uploads/{disk}/{encodedKey} laracrate.uploads.cancel Delete an abandoned temp/ object (encodedKey is base64 then URL-encoded)
POST laracrate/uploads/sign laracrate.uploads.sign Grant based: check a file against the rules, then a presigned PUT and a receipt, or the address to post it to
POST laracrate/uploads/confirm laracrate.uploads.confirm Grant based: create the File of a direct upload (the receipt proves the key was issued to this grant)
POST laracrate/uploads/receive laracrate.uploads.receive Grant based: take a file through PHP and create the File at once (encrypted collections)
POST laracrate/uploads/register laracrate.uploads.register Presign mode of the components: create the File of an object the component signed, by its key alone
POST laracrate/uploads/remove laracrate.uploads.remove Grant based: remove the current file of a target that replaces it (a replacing grant only)
GET laracrate/uploads/targets/{target} laracrate.uploads.target An upload target: the collection's rules, the current file and the scope, with no grant (?params[venue]=12)
POST laracrate/uploads/targets/{target} laracrate.uploads.target.grant An upload target: a grant, asked for when the user picks files
POST laracrate/multipart/init laracrate.multipart.init Grant based: check a file against the rules and start a multipart session, returns the part URLs
POST laracrate/multipart/{multipart}/parts laracrate.multipart.parts Reissue presigned URLs for specific parts (with the session's grant)
POST laracrate/multipart/{multipart}/complete laracrate.multipart.complete Assemble the object from the part ETags and create the File (with the session's grant)
DELETE laracrate/multipart/{multipart} laracrate.multipart.abort Abort and clean up a multipart session (with the session's grant)
GET laracrate/files/{file:slug}/stream laracrate.files.stream The file through PHP, inline (streamUrl()). Checks canView on every request, honours Range, decrypts, logs; not counted as a download
GET laracrate/files/{file:slug}/download laracrate.files.download The file through PHP as an attachment (downloadUrl()), counted in downloads_count
GET laracrate/files/{file:slug}/viewer laracrate.files.viewer What the viewer shows (JSON): name, type and a link made at request time, after canView
GET laracrate/files/{file:slug}/status laracrate.files.status Processing status for one file (JSON)
POST laracrate/files/status laracrate.files.status.batch Processing status for many files in one request
POST _laracrate/local/upload laracrate.local.upload Receive bytes for the local-driver presigned fallback (signed)
GET _laracrate/local/serve/{file:slug} laracrate.local.serve Serve a local-disk file after verifying the URL signature (signed)

Prefixes and middleware. Each group reads its own config, so prefixes and route names shown above are the defaults:

Group Prefix config Middleware config Name prefix
Presigned uploads laracrate.uploads.route_prefix (laracrate/uploads) laracrate.uploads.middleware (['web', 'auth']) laracrate.uploads.
Multipart laracrate.multipart.route_prefix (laracrate/multipart) laracrate.multipart.middleware (falls back to the uploads middleware when null) laracrate.multipart.
Stream / download / viewer laracrate.stream.route_prefix (laracrate/files) laracrate.stream.middleware (['web', 'auth']) laracrate.stream.route_name_prefix (laracrate.files)
Status laracrate.status.route_prefix (laracrate/files) laracrate.status.middleware (['web', 'auth']) laracrate.files.
Local driver fixed _laracrate/local signed laracrate.local.

A few details worth knowing:

  • Authorization is yours. The upload and multipart groups only apply the configured middleware. Restricting which disks a user may write to is enforced separately by config('laracrate.uploads.allowed_disks'), checked in the presign endpoint. The grant based endpoints, multipart included, take the disk from the grant's collection.
  • cancel only touches temp/, and only what this browser signed. The DELETE endpoint refuses any key that is not a plain temp/ name, and any key that was not signed by presign in the same browser session (or for the same user when there was no session). The deleteTemp() JS helper builds the segment for you.
  • Files through PHP are not signed. stream, download and viewer sit behind the session and check the file's view policy on every request, so their links never expire. Keep auth in laracrate.stream.middleware.
  • Multipart sessions answer to their grant. parts, complete, and abort return 403 for any grant other than the one that started the session, and for sessions started outside these endpoints.
  • Local routes are signed, not authed. The _laracrate/local group is protected by Laravel's signed middleware. The URLs are minted by StorageManager::presignedUpload() and GenerateSignedUrlAction, so they carry their own expiry. They exist only as the dev-time stand-in for real presigned S3/R2 URLs.
  • Status responses. Both status endpoints check canView() per file and return { slug, status, ready, url, preview, variants, error } per file (the batch endpoint keys the map by slug and silently omits files you cannot view). Use the pollFileStatus and pollFilesStatus JS helpers to consume them; see the displaying files and processing pipeline sections for how ready and variants are populated.