Appearance
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."
/>| Prop | Does |
|---|---|
type | the upload type key, the only required prop |
current | the stored image, drawn until a new one is picked |
shape | circle (default) or square |
disabled | draws the field but refuses input, for a user without the role |
hint | one line of help under the button |
onUploaded | called 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.
| Check | At presign | At confirm |
|---|---|---|
| May this user upload this type? | yes | yes, again |
| Is the content type allowed? | as declared by the browser | – |
| Is the file small enough? | as declared by the browser | measured, 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.