Skip to content

Uploading from the frontend

The reference edition ships <UploadField />, which does the whole three-step dance and shows a preview and a progress bar while it happens.

tsx
import { UploadField } from '@/components/upload-field';

<UploadField
    type="avatar"
    current={auth.user.avatar}
    alt={auth.user.name}
    hint="JPEG, PNG or WebP, up to 2 MB."
/>
PropDoes
typethe upload type key, the only required prop
currentthe stored image, drawn until a new one is picked
shapecircle (default) or square
disableddraws the field but refuses input, for a user without the role
hintone line of help under the button
onUploadedcalled with (key, url); without it the page's props are reloaded

Drag a file onto it or click it. Nothing is uploaded until a file is chosen, and nothing is stored until the confirm step returns.

What it does

fetch for the two JSON calls and XMLHttpRequest for the PUT. Only the second reports upload progress, and a large photo on a slow connection with no progress bar reads as a broken page.

The component never learns which kind of URL it was given. Against S3 the PUT goes to the bucket; against a local disk it comes back to the application. A frontend that branched on the driver would have an upload path only one environment ever exercises, so it does not branch.

Errors come from the server rather than from the component: a 422 from the presign or the confirm is unwrapped and its message shown, so what the user reads is what the server actually decided.

Adding an upload of your own

Three steps, and only the first is in this package's territory.

1. A column, in a migration you own.

php
Schema::table('posts', function (Blueprint $table) {
    $table->string('cover_path')->nullable();
});

Keep it out of $fillable. A path is written by the confirm step, after the key has been checked, and never by a form.

2. A type, in app/Storage/LaraspringUploadTypes.php.

php
UploadType::make('post-cover', 'Cover image')
    ->prefix('post-covers')
    ->maxSize(4096)
    ->mimeTypes(['image/jpeg', 'image/png', 'image/webp'])
    ->visibility('public')
    ->ability('update', fn () => request()->route('post'))
    ->stored(fn ($user, string $key) => /* write the column */);

3. The field, wherever it belongs.

tsx
<UploadField type="post-cover" shape="square" current={post.cover} />

Uploading without the component

The endpoints are ordinary JSON, so a Livewire, Vue or Blade edition needs no new backend:

js
const presigned = await post('/storage/uploads', { type, filename, content_type, size });

await fetch(presigned.url, { method: presigned.method, headers: presigned.headers, body: file });

await post('/storage/uploads/confirm', { type, key: presigned.key });

Both application calls need a session and a CSRF token. The middle one needs neither: it is going to your bucket, and what authorizes it is the signature in the URL.

Where the limits really apply

Worth being precise about, because it is the part that most often goes wrong in a hand-rolled direct upload.

CheckAt presignAt confirm
May this user upload this type?yesyes, again
Is the content type allowed?as declared by the browser
Is the file small enough?as declared by the browsermeasured, and deleted if not
Does the key belong to this user and type?yes

The presign checks are worth having: they stop the honest 40 MB video before it is uploaded. They are not what makes the rules hold. Nothing between the browser and your bucket enforces a size, so the confirm step is where a limit becomes real.

A bucket accepts what it is given

Anyone holding a valid presigned URL can PUT anything to that one key until it expires, whatever the content type said. Keep upload_ttl short, keep the buckets holding user uploads separate from anything you execute or trust, and do not serve user-uploaded SVG from the same origin as your application unless you have thought about what that means.

Laraspring is a commercial starter kit. Buying it gets you the source.