Skip to content

File Uploads

The File field uploads to temporary storage by default. That is the path most forms should use: the user gets immediate progress and validation, while the final Inertia request only submits a small encrypted token.

Choosing an Upload Path

Start with the default temporary upload. Reach for the other methods only when the form needs something specific:

  • Use File::make('avatar') for normal uploads, previews, edit forms, and Spatie Media Library fields.
  • Use storeWithForm() when a small file should be sent with the final form request as a native multipart File.
  • Use temporaryUploadUrl('/uploads/avatar') when your app already has a temporary upload endpoint.
  • Use chunked() for large same-origin uploads that need pause, resume, retry, or smaller requests.
  • Use directToStorage('s3') or directToStorage('minio') when the browser should send bytes directly to a Laravel filesystem disk backed by S3-compatible object storage.

Multiple files work with each path. Temporary uploads, chunked uploads, and direct-to-storage uploads all submit encrypted temporary upload tokens in the current UI order.

Choose one upload transport per field. Direct-to-storage takes precedence in the frontend when both chunked() and directToStorage() are enabled.

DestinationTransportAPIPause/resume
Laravel serverFinal form multipart requeststoreWithForm()No
Laravel serverTemporary upload before submitDefault File::make()No
Laravel serverTemporary upload before submit, split into chunkschunked()Yes
S3-compatible object storageSingle signed upload below the multipart thresholddirectToStorage()No
S3-compatible object storageMultipart upload above the multipart thresholddirectToStorage()Yes

Temporary Uploads

Temporary uploads are the default:

php
File::make('avatar');

File selection follows this flow:

  1. The frontend field posts the file to POST /_inertia-forms/file-upload.
  2. Laravel stores the file temporarily and validates upload-time file rules.
  3. The endpoint returns JSON with a key.
  4. The form stores that key instead of the raw browser file.
  5. A request macro decrypts the named key on first read and exposes the resolved upload as a SubmittedUpload. A FormRequest implementing HasUploads may eager-hydrate upload keys from its rules.

The endpoint response looks like this:

json
{
    "key": "encrypted-temporary-upload-token",
    "name": "avatar.jpg",
    "mimeType": "image/jpeg",
    "mime_type": "image/jpeg",
    "size": 12345
}

The key value is the submitted form value. For multiple fields, the value is an array of keys.

Reading Submitted Uploads

Temporary upload handling does not replace $request->input() or $request->all() values with raw UploadedFile instances. The submitted input still contains encrypted keys, while resolved uploads are stored separately on the request.

After validation, use the Form helpers to read submitted uploads. upload() returns one SubmittedUpload; uploads() returns the ordered collection for a multiple field. Use validated(files: false) for model attributes so encrypted temporary upload strings are not persisted by accident:

php
use App\Forms\ProfileForm;
use InertiaUI\Forms\FileUploads\SubmittedUpload;
use InertiaUI\Forms\Validate;

public function update(#[Validate] ProfileForm $form, Profile $profile)
{
    $profile->update($form->validated(files: false));

    $avatar = $form->upload('avatar'); // ?SubmittedUpload

    if ($avatar) {
        $profile->forceFill([
            'avatar_path' => $avatar->store('avatars', 'public'),
        ])->save();
    }

    $photoNames = $form->uploads('photos')
        ->map(fn (SubmittedUpload $photo) => $photo->getIdentifier());
}

The request-level equivalents remain available. Use formUpload() for one file and orderedFormUploads() for an ordered collection:

php
$avatar = $request->formUpload('avatar');
$photos = $request->orderedFormUploads('photos');

Accessor Gating

Form and request upload accessors only return files allowed by the active form. They return null or an empty collection for unauthorized fields, hidden fields, and tokens uploaded under different field rules. The same gate applies before and after validate().

Once a form registers upload gates, unknown keys also fail closed. Request accessors remain ungated only when no form has registered upload context, such as a standalone HasUploads FormRequest workflow.

Do not use $request->file('avatar') for default temporary uploads, chunked uploads, or direct-to-storage uploads. The final form request contains encrypted token strings for those fields, not native multipart files. The supported retrieval path is $form->upload('avatar') or $request->formUpload('avatar').

For eager hydration, implement the marker contract on a FormRequest. The package reads that request's rules, discovers file and temporary-token paths, and hydrates it when Laravel resolves the request:

php
use Illuminate\Foundation\Http\FormRequest;
use InertiaUI\Forms\Fields\File;
use InertiaUI\Forms\FileUploads\Contracts\HasUploads;

class StoreProfileRequest extends FormRequest implements HasUploads
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        $avatar = File::make('avatar')->image()->nullable();
        $photos = File::make('photos')->multiple()->image()->nullable();

        return [
            'avatar' => $avatar->getRules(),
            ...$avatar->getAdditionalRules(),
            'photos' => $photos->getRules(),
            ...$photos->getAdditionalRules(),
        ];
    }
}

SubmittedUpload may represent a new temporary upload, a direct-to-storage upload, or an existing file that stayed selected while editing. Use isNew() and isExisting() to branch, getUploadedFile() for a new local or remote upload, getRemoteFile() when direct storage details matter, getExistingFile() for a retained existing value, and getIdentifier() for a stable order key. Default server-side temporary uploads expose an InertiaUI\Forms\FileUploads\UploadedFile, which extends Laravel's uploaded file class. Use store() or storeAs() directly on the SubmittedUpload to persist new uploads:

php
$path = $form->upload('avatar')?->store('avatars', 'public');
$path = $form->upload('avatar')?->storeAs('avatars', $filename, 'public');

The helpers remove the Inertia Forms temporary upload after successful storage by default. Pass deleteTemporary: false as a named argument to keep the temporary source.

Direct-to-storage uploads expose a RemoteFile. The storage helpers may promote those objects on the same Laravel filesystem disk: deleteTemporary: true moves the object to its final path, and deleteTemporary: false copies it. Passing a different disk throws an exception because cross-disk promotion may hide unexpected storage cost. Persist getRemoteFile()->getDisk() / getRemoteFile()->getPath() metadata instead when the direct-to-storage object should stay where the browser uploaded it. Existing files cannot be stored again because there is no new upload to persist.

Stored Filenames

store() generates a random 40-character filename with an extension derived from the file contents. Local uploads use Laravel's hashName() behavior. Direct-to-storage uploads inspect the start of the stored object without downloading the full file. Unknown file types are stored without an extension.

storeAs() is unaffected: the name you pass is used exactly as given, so you remain responsible for the extension it carries.

Store With The Form

Use storeWithForm() when you want a newly selected file to travel with the final Inertia form request:

php
File::make('document')->storeWithForm();

This is useful for small create forms where you do not need existing file preservation, preview tokens, or Media Library synchronization. In this mode, Laravel receives native multipart File values during the normal form submission and all validation runs at submit time. $request->file('document') works in this mode, and the Form upload helpers return null because there is no temporary upload token to resolve:

php
public function store(Request $request, DocumentForm $form)
{
    $path = $request->file('document')?->store('documents', 'public');
}

Custom Temporary Endpoint

Use temporaryUploadUrl() when the browser should post selected files to your own endpoint:

php
File::make('avatar')->temporaryUploadUrl('/uploads/avatar');

Your endpoint receives multipart data with the file in the file field. Return a submitted value in key, path, url, or id; key is recommended because it matches the built-in endpoint.

Custom endpoints that return Inertia Forms temporary upload tokens for fields with upload-time rules must include the expected validation hash. Endpoints that perform equivalent validation themselves should opt out of that token guard:

php
File::make('avatar')
    ->temporaryUploadUrl('/uploads/avatar')
    ->requireValidatedUploads(false);

Chunked Uploads

Use chunked() for large files that should still flow through your Laravel app, but should not depend on one huge request:

php
File::make('video')
    ->chunked(size: 8 * 1024 * 1024); // bytes

The browser starts a pending upload, appends chunks in order, checks status when needed, and completes it into the same encrypted temporary upload token used by regular temporary uploads. The built-in UI may pause, resume, cancel, retry, and show byte-based progress.

Chunked uploads are the right fit for local 1-2 GB uploads when PHP is still responsible for receiving the bytes.

Direct To Storage

Use directToStorage() when the browser should send bytes to a Laravel filesystem disk instead of proxying them through PHP. This follows Laravel's Storage mental model: configure a disk in config/filesystems.php, then tell the field which disk should receive the upload.

php
File::make('archive')
    ->directToStorage('s3')
    ->partSize(16 * 1024 * 1024)
    ->multipartThreshold(100 * 1024 * 1024);

Laravel still controls the upload. It creates the signed URLs, validates the completed object, runs custom validators, returns the encrypted temporary upload token, and later exposes that token as a SubmittedUpload during form submission. The difference is that the heavy file bytes go straight from the browser to S3, MinIO, or another S3-compatible disk.

Files at or below the multipart threshold use one signed PUT. Larger files use S3 multipart upload parts and may resume from already uploaded parts.

The argument passed to directToStorage() is a Laravel disk name from config/filesystems.php, just like Storage::disk('s3'). Most applications only need to configure a normal S3-compatible disk and pass that disk name to the field.

Completed Upload Immutability

Completing a direct upload moves the object to a fresh key and spends the upload handle before Laravel validates the file. The validated object is therefore no longer writable through the original upload URL.

  • Package-mediated writes and repeated completion return 409.
  • An unexpired S3 PUT URL may recreate only the abandoned original key.
  • store(), storeAs(), explicit deletion, and aborts remove the full temporary upload directory by default.
  • S3 multipart abort failures propagate without reporting successful cleanup.

S3 Configuration

Install Laravel's S3 filesystem adapter in the consuming app:

bash
composer require league/flysystem-aws-s3-v3

Configure a normal Laravel s3 disk in config/filesystems.php, then point Inertia Forms at that disk:

php
// config/inertia-forms.php
'file_uploads' => [
    'direct_to_storage' => [
        'disk' => 's3',
        'url_lifetime' => 900,
        'part_size' => 16 * 1024 * 1024,
        'multipart_threshold' => 100 * 1024 * 1024,
    ],
],

Publish the runtime config from Installation, then see Direct To Storage for the full option reference.

Your bucket CORS policy must allow browser PUT requests from your app origin and expose ETag so multipart completion may send the uploaded part identifiers back to Laravel.

Minimum CORS shape:

json
[
    {
        "AllowedOrigins": ["https://your-app.example"],
        "AllowedMethods": ["PUT"],
        "AllowedHeaders": ["*"],
        "ExposeHeaders": ["ETag"],
        "MaxAgeSeconds": 3600
    }
]

MinIO

MinIO uses Laravel's normal s3 filesystem driver. Define it as a normal Laravel filesystem disk:

php
// config/filesystems.php
'minio' => [
    'driver' => 's3',
    'key' => env('MINIO_ACCESS_KEY_ID', 'minioadmin'),
    'secret' => env('MINIO_SECRET_ACCESS_KEY', 'minioadmin'),
    'region' => env('MINIO_REGION', 'us-east-1'),
    'bucket' => env('MINIO_BUCKET', 'inertia-forms-tests'),
    'endpoint' => env('MINIO_ENDPOINT', 'http://127.0.0.1:9000'),
    'use_path_style_endpoint' => true,
    'throw' => true,
],

Then use that disk globally or per field:

php
File::make('archive')->directToStorage('minio');

Local MinIO setups may configure browser CORS globally with MINIO_API_CORS_ALLOW_ORIGIN instead of accepting S3 bucket CORS through PutBucketCors. The browser requirement is the same: the signed PUT requests must be allowed from your app origin and multipart responses must expose ETag.

Routes

Register the upload routes in routes/web.php:

php
Route::inertiaFormUploads();

This registers the built-in endpoints under /_inertia-forms by default. You usually do not need to call these routes directly; the frontend field reads the route names from the serialized field configuration.

Temporary uploads use two endpoints with the default route-name prefix:

  • POST /_inertia-forms/file-upload (inertia-forms.file-upload.store) stores a selected file and returns an encrypted temporary upload token.
  • DELETE /_inertia-forms/file-upload (inertia-forms.file-upload.destroy) removes an unused temporary upload.

Chunked uploads add a small route group below /_inertia-forms/file-upload/chunked:

  • start creates a pending upload.
  • status checks which bytes have already been received.
  • chunk appends the next chunk.
  • complete turns the uploaded chunks into a temporary upload token.
  • abort cancels the pending upload.

Direct-to-storage uploads add a route group below /_inertia-forms/file-upload/direct:

  • start creates a signed single-object or multipart upload.
  • object receives local test-driver PUT uploads.
  • part signs or receives individual multipart parts.
  • status checks already uploaded parts.
  • complete validates the stored object and returns a temporary upload token.
  • abort cancels an unfinished upload.

You may customize the prefix and middleware:

php
Route::inertiaFormUploads(
    prefix: '/uploads',
    middleware: ['web', 'auth', 'throttle:60,1'],
);

For separate application sections, register another route group with its own route-name prefix:

php
Route::prefix('admin')
    ->name('admin.')
    ->group(function () {
        Route::inertiaFormUploads(
            prefix: '/backoffice/_inertia-forms',
            middleware: ['web', 'auth', 'can:access-admin'],
            name: 'backoffice',
        );
    });

Use uploadRoutes() on fields that should use that route group. The selected route group supplies the temporary, delete, chunked, and direct-to-storage endpoints for the field:

php
File::make('avatar')
    ->uploadRoutes('admin.backoffice');

Pass the full route-name prefix after Laravel composes parent route groups and the upload route group name.

Serialized Upload Configuration

PHP serializes upload methods into the same public props used by true standalone Vue and React controls. Generated fields fill the route URLs and validation token automatically. Standalone controls must provide the values required by their chosen transport.

PropMeaning
storeWithFormKeep native browser File objects for the final multipart form request. It bypasses temporary, chunked, and direct uploaders.
temporaryUploadUrlRegular multipart upload endpoint. The request field is file; the response may supply the submitted value as key, path, url, or id.
temporaryUploadDeleteUrlOptional delete endpoint used to discard a completed temporary upload that the user removes.
chunked / chunkSizeEnable same-origin chunking and set bytes per append request.
chunkedUrlsEndpoints named start, status, append, complete, and abort; all are required for standalone chunked uploads.
directToStorage / uploadDiskEnable direct upload and identify the Laravel filesystem disk bound to the signing endpoint.
uploadPartSize / uploadMultipartThresholdSet multipart part bytes and the byte threshold above which direct uploads become multipart.
directUploadUrlsEndpoints named start, signPart, status, complete, and abort; all are required for standalone direct uploads.
uploadRulesTokenEncrypted package upload profile sent with managed upload requests. It carries upload validation rules and binds built-in direct-to-storage requests to their intended disk. Applications normally obtain it from serialized PHP props.
requiresUploadRulesTokenRequire the completed temporary token to prove it used the expected validation profile in the final request.

The FileUpload field also accepts existingFiles as an explicit initial list. A non-empty list takes precedence over existing-file objects in the initial bound value. Composer applies the same transport props to attachments. RichText nests them under imageUploads and never supports storeWithForm.

Configuration

The upload configuration is grouped by responsibility:

php
'file_uploads' => [
    'route_prefix' => '/_inertia-forms',
    'route_name' => 'inertia-forms.',
    'middleware' => ['web', 'auth'],

    'temporary_uploads' => [
        'disk' => '',
        'lifetime' => 3600,
        'max_size' => 10240,

        'chunked' => [
            'size' => 5 * 1024 * 1024,
            'max_size' => 2 * 1024 * 1024,
        ],
    ],

    'direct_to_storage' => [
        'disk' => '',
        'url_lifetime' => 900,
        'part_size' => 16 * 1024 * 1024,
        'multipart_threshold' => 100 * 1024 * 1024,
        'max_size' => 5 * 1024 * 1024,
    ],

    'existing_files' => [
        'lifetime' => 3600,
    ],

    'cleanup_disks' => [],
],

Publish this file from Installation. See File Upload Configuration for the full runtime reference.

Validation

File fields validate in two phases.

Upload-time rules are added by file-specific methods such as image(), maxSize(), accept(), and dimensions(). They run while Laravel has access to a real uploaded file:

php
File::make('avatar')
    ->image()
    ->maxSize(2048)
    ->maxDimensions(width: 1000, height: 1000);

Submit-time rules such as required() run when the form is submitted:

php
File::make('avatar')
    ->required()
    ->image()
    ->maxSize(2048);

A submitted temporary token strips file-specific rules from submit-time validation because the submitted input contains an encrypted string. Use the Form upload() / uploads() helpers, or the request-level formUpload() / orderedFormUploads() macros, when controller code needs the resolved SubmittedUpload.

Managed package uploads also bind the upload validation profile to the returned temporary token. Form validation rejects unvalidated temporary tokens submitted to managed fields with upload rules.

That token guard is automatic for the built-in temporary route, chunked uploads, and direct-to-storage uploads. A custom temporaryUploadUrl() defaults to no package-token guard because the response may be an application-owned path, url, or id. Use requireValidatedUploads(true) only when that custom endpoint returns compatible Inertia Forms tokens; use false to disable the guard explicitly.

Upload Validators

Use validateUploadsUsing() for malware scans, content validation, tenant policy, or other upload-time validation that does not fit Laravel's built-in file rules:

php
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Validation\ValidationException;
use InertiaUI\Forms\FileUploads\Contracts\ValidatesFileUploads;
use InertiaUI\Forms\FileUploads\TemporaryUpload;
use InertiaUI\Forms\FileUploads\UploadRules;

class EnsureArchiveIsSafe implements ValidatesFileUploads
{
    public function validate(
        TemporaryUpload $upload,
        UploadedFile $file,
        UploadRules $rules,
        Request $request,
    ): void {
        if ($file->getClientOriginalExtension() !== 'zip') {
            return;
        }

        // Run your scanner here...
        $blocked = false;

        if ($blocked) {
            throw ValidationException::withMessages([
                'file' => ['The archive failed the security scan.'],
            ]);
        }
    }
}

File::make('archive')
    ->directToStorage('s3')
    ->maxSize(1024 * 1024)
    ->validateUploadsUsing(EnsureArchiveIsSafe::class);

Upload validators run after the completed object is available and before the encrypted temporary token is returned to the browser. Throw a ValidationException to return a normal 422 upload error and clean up the rejected temporary object.

For direct-to-storage uploads, Laravel streams remote S3 or MinIO objects to a temporary local file only when Laravel file rules or custom validators need to read the completed bytes. A disk-only upload profile still binds the request to the intended disk without re-reading the object through PHP.

Spatie Media Library

For Spatie Media Library, bind a file field to a media collection:

php
File::make('photos')
    ->multiple()
    ->mediaCollection('photos');

Edit forms automatically load existing media as preview items. On submit, use validated(files: false) for normal model attributes, then call MediaLibraryUploads::syncCollection() to add new uploads, remove deleted items, and preserve the submitted order. The media helper reads orderedFormUploads() internally, so controller code does not need to persist the encrypted token array:

php
use App\Forms\ProductForm;
use InertiaUI\Forms\FileUploads\MediaLibraryUploads;
use InertiaUI\Forms\Validate;

public function update(#[Validate] ProductForm $form, Request $request, Product $product)
{
    $product->update($form->validated(files: false));

    MediaLibraryUploads::syncCollection(
        request: $request,
        model: $product,
        field: 'photos',
        collection: 'photos',
    );
}

The model must be saved before syncCollection() runs. The method treats the submitted files as the collection's complete contents. Any media missing from the field is deleted, and retained media follows the submitted order. Do not share the collection with another field or feature.

Pass the optional disk argument to override the media collection's storage disk. Leave it empty to use the collection's configured disk.

Responsive Images and Media Properties

Pass responsiveImages: true to generate Spatie responsive images. Use the configure closure to customize each new Media item before it is stored. The closure receives the FileAdder and submitted upload:

php
use Illuminate\Support\Str;
use InertiaUI\Forms\FileUploads\SubmittedUpload;
use Spatie\MediaLibrary\MediaCollections\FileAdder;

MediaLibraryUploads::syncCollection(
    request: $request,
    model: $product,
    field: 'photos',
    collection: 'photos',
    responsiveImages: true,
    configure: function (FileAdder $adder, SubmittedUpload $upload): void {
        $file = $upload->getUploadedFile();

        $adder
            ->usingFileName(
                Str::ulid().'.'.pathinfo($file->getClientOriginalName(), PATHINFO_EXTENSION),
            )
            ->usingName($file->getClientOriginalName())
            ->withCustomProperties(['source' => 'product-form'])
            ->onQueue('media');
    },
);

Responsive-image generation follows Spatie's queue configuration. A sync queue driver runs the job immediately, while onQueue() selects its queue. Conversions registered on the model continue to use Spatie's queued() and nonQueued() configuration.

Custom Media Models

Existing media restoration and ordering use the Media model selected by the owner, with media-library.media_model as the fallback. A custom Spatie Media model therefore keeps its model hooks throughout the sync.

Temporary Upload Lifecycle

New media keeps the client original filename by default. The helper internally preserves the staged source until storage, collection cleanup, and ordering all succeed. It then deletes the Inertia Forms temporary upload.

A failed sync removes newly created Media records and leaves the staged upload available for retry. This rollback does not restore existing media already removed or reordered earlier in the sync.

Existing Files

Edit forms may pass existing files to the form so they appear in the UI.

From a Storage Disk

A string path returns one ExistingFile; an array of paths returns an array. Image previews use a temporary URL with a five-minute default expiration. Pass the optional expiration, withPreview, and metadata arguments when a different behavior is required:

php
use InertiaUI\Forms\FileUploads\ExistingFile;

$existingFile = ExistingFile::fromDisk('public', 'avatars/photo.jpg');
$withoutPreview = ExistingFile::fromDisk('public', 'documents/report.pdf', withPreview: false);

ExistingFile::fromFilesystem() accepts an already-resolved Laravel filesystem instance with the same path, preview, and metadata options.

From Media Library

php
$existingFiles = ExistingFile::fromMediaLibrary($product->getMedia('photos'));
$thumbnails = ExistingFile::fromMediaLibrary(
    $product->getMedia('photos'),
    previewConversion: 'thumbnail',
);

Use fromMediaLibraryWithoutPreview() when the media items should remain selectable without generating preview URLs. fromMediaLibrary() accepts one media model or an iterable and uses a five-minute preview expiration unless you pass another value.

Image previews use a temporary URL when the disk supports one, then fall back to the Media Library model's normal URL. Pass previewConversion to preview a generated conversion. A conversion still waiting on its queue falls back to the original media until it is ready.

Existing files serialize to a browser value with an encrypted key plus display fields:

json
{
    "key": "encrypted-existing-file-token",
    "id": "stable-file-identifier",
    "identifier": "stable-file-identifier",
    "filename": "photo.jpg",
    "name": "photo",
    "previewUrl": "https://example.com/photo.jpg",
    "preview_url": "https://example.com/photo.jpg",
    "mimeType": "image/jpeg",
    "mime_type": "image/jpeg",
    "size": 12345,
    "size_in_bytes": 12345
}

Selected existing files submit their key. Removed files omit that key from the submitted value.

Token Scoping

Temporary upload keys and existing file keys are scoped and expire. Stateful requests bind keys to the session. Stateless requests bind them to the authenticated guard, provider, model, and user.

Managed upload endpoints and existing-file serialization require either a session or an authenticated user. Upload endpoints return 422 before writing bytes without that scope. Place upload routes and forms with existing files behind session or authentication middleware.

Existing file keys expire after file_uploads.existing_files.lifetime seconds, which defaults to one hour. You may adjust it in Existing Files Configuration.

Cleanup

Storage helpers delete staged uploads after a successful operation. A failed operation retains its files for retry, and uploads that are never submitted remain on disk. Schedule the cleanup command as a safety net for both cases:

php
// In app/Console/Kernel.php or routes/console.php
Schedule::command('form:cleanup-uploads')->hourly();

The command deletes temporary upload directories older than file_uploads.temporary_uploads.lifetime, which defaults to one hour:

bash
php artisan form:cleanup-uploads --lifetime=7200

Which Disks Are Swept

The command deletes only inertia-forms-upload-* directories on these disks:

  • file_uploads.temporary_uploads.disk (or the local staging disk when it is empty).
  • file_uploads.direct_to_storage.disk (or the s3 disk when it is empty).
  • Every disk listed in file_uploads.cleanup_disks.
  • Every disk passed with --disk, which may be repeated.

Per-field disks from directToStorage('media') or uploadDisk('media') are not discovered automatically. Add them to file_uploads.cleanup_disks:

php
'file_uploads' => [
    'cleanup_disks' => ['media', 'documents'],
],
bash
php artisan form:cleanup-uploads --disk=media --disk=documents

Repeated --disk options add disks for one command run. Expiration uses the newest file in each directory, so active chunked or multipart uploads remain. One inaccessible disk does not prevent the remaining disks from being swept.

For S3-compatible direct-to-storage uploads, also configure bucket lifecycle rules to expire abandoned inertia-forms-upload-* objects and abort incomplete multipart uploads. Multipart sessions that are never completed are not visible as ordinary objects until S3 completes them.

For large-upload testing, keep normal CI focused on representative multipart uploads. The demo browser suite uses a live MinIO multipart upload that is large enough to exercise multipart behavior without making every pull request move gigabytes of data. Reserve 1-2 GB upload tests for manual or scheduled infrastructure smoke runs.