Skip to content

Storage

Storage ships as laraspring/storage, a Composer package your application installs. It owns direct-to-bucket uploads: the browser asks for a signed URL, PUTs the file straight to your bucket, and confirms the key so the application can check what arrived and write it somewhere.

The file never passes through PHP. No post_max_size to raise, no request occupying a worker for the length of a slow upload, no memory spent on bytes you are only going to forward.

What you get out of the box

FeatureWhere
Presigned uploads to S3, R2, Spaces or MinIOstorage.presign
Per-type size, MIME and authorization rulesApp\Storage\LaraspringUploadTypes
A confirm step that re-checks the key and the sizestorage.confirm
A drag-and-drop upload field with progresscomponents/upload-field.tsx · UploadField.vue
User avatars and organization logos, workingprofile and organization settings
A local disk fallback so a fresh clone just worksstorage.upload, local only

The three steps

POST storage.presign   { type, filename, content_type, size }
   → { key, method, url, headers, expires_at }

PUT  <that url>        the file itself, straight to the bucket

POST storage.confirm   { type, key }
   → { key, url }

The middle step is the only one your bucket sees. The other two are ordinary authenticated requests to your application.

Why confirm exists

A direct upload leaves the application out of the loop entirely: nothing would learn the object arrived, nothing would write the key to a column, and nothing would check that what landed is the size it was supposed to be.

So the confirm step does all three, and it trusts none of what it is handed:

  • The key is re-derived, not believed. It must start with the type's prefix and the user's own directory, and end in a ULID this package generated. A user cannot confirm somebody else's key, and cannot confirm their own avatar as an organization logo.
  • Authorization is asked again. A role can be revoked between the presign and the confirm, and the second request is the one that writes a column.
  • The size is measured. The number sent at presign time was a claim; a presigned PUT accepts whatever the browser sends. An object over the limit is deleted rather than left to be paid for.

Upload types

A type is a kind of thing somebody may upload. It carries two things configuration cannot hold, who is allowed and what to do with the key, so it is declared in PHP:

php
// app/Storage/LaraspringUploadTypes.php
UploadType::make('avatar', 'Profile photo')
    ->prefix('avatars')
    ->maxSize(2048)                                  // kilobytes
    ->mimeTypes(['image/jpeg', 'image/png', 'image/webp'])
    ->visibility('public')
    ->authorize(fn () => true)
    ->stored(fn (Authenticatable $user, string $key) => $user->forceFill(['avatar_path' => $key])->save());

The package ships no types at all. A type names a column somebody's model owns, and laraspring/storage has no idea your application has users with faces. The edition declares them, exactly as it registers the mail previews, because it is the one thing that knows which packages are installed.

Everything an operator might want to change without touching code is still configuration, and wins over the declaration:

php
// config/laraspring-storage.php
'types' => [
    'avatar' => ['max_size' => 1024, 'mime_types' => ['image/jpeg', 'image/png']],
],

Authorizing through the gate

The organization logo is the case that needs two packages at once: an upload from laraspring/storage, authorized by a role from laraspring/organizations. Neither may name the other, so the type asks the gate for the same ability that guards the organization's name:

php
UploadType::make('organization-logo', 'Organization logo')
    ->ability('update', fn () => Organizations::current())

"Who may change this organization" then has one definition, in laraspring/organizations' policy, rather than a role check written out again next to an upload. A null subject, meaning no active tenant, is a no.

Reading a key back

Store the key, never a URL. A URL bakes in the disk, the bucket and the CDN hostname, so moving any of the three would mean rewriting every row.

php
use Laraspring\Storage\Support\Uploads;

Uploads::url($user->avatar_path, 'avatar');   // null when there is no key
Uploads::delete($oldKey, 'avatar');

A public type gets a plain URL a CDN can cache. A private one gets a temporary signed URL, expiring after laraspring-storage.url_ttl minutes, which nothing can cache and no email can embed. That is the trade visibility makes.

The reference edition appends the avatar URL to the User model, so it rides along in Inertia's shared props and the nav, the user menu and the profile page all get it without asking.

Next

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