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 multipartFile. - 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')ordirectToStorage('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.
| Destination | Transport | API | Pause/resume |
|---|---|---|---|
| Laravel server | Final form multipart request | storeWithForm() | No |
| Laravel server | Temporary upload before submit | Default File::make() | No |
| Laravel server | Temporary upload before submit, split into chunks | chunked() | Yes |
| S3-compatible object storage | Single signed upload below the multipart threshold | directToStorage() | No |
| S3-compatible object storage | Multipart upload above the multipart threshold | directToStorage() | Yes |
Temporary Uploads
Temporary uploads are the default:
File::make('avatar');File selection follows this flow:
- The frontend field posts the file to
POST /_inertia-forms/file-upload. - Laravel stores the file temporarily and validates upload-time file rules.
- The endpoint returns JSON with a
key. - The form stores that key instead of the raw browser file.
- A request macro decrypts the named key on first read and exposes the resolved upload as a
SubmittedUpload. AFormRequestimplementingHasUploadsmay eager-hydrate upload keys from its rules.
The endpoint response looks like this:
{
"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:
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:
$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:
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:
$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:
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:
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:
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:
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:
File::make('video')
->chunked(size: 8 * 1024 * 1024); // bytesThe 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.
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
PUTURL 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:
composer require league/flysystem-aws-s3-v3Configure a normal Laravel s3 disk in config/filesystems.php, then point Inertia Forms at that disk:
// 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:
[
{
"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:
// 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:
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:
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:
startcreates a pending upload.statuschecks which bytes have already been received.chunkappends the next chunk.completeturns the uploaded chunks into a temporary upload token.abortcancels the pending upload.
Direct-to-storage uploads add a route group below /_inertia-forms/file-upload/direct:
startcreates a signed single-object or multipart upload.objectreceives local test-driverPUTuploads.partsigns or receives individual multipart parts.statuschecks already uploaded parts.completevalidates the stored object and returns a temporary upload token.abortcancels an unfinished upload.
You may customize the prefix and middleware:
Route::inertiaFormUploads(
prefix: '/uploads',
middleware: ['web', 'auth', 'throttle:60,1'],
);For separate application sections, register another route group with its own route-name prefix:
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:
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.
| Prop | Meaning |
|---|---|
storeWithForm | Keep native browser File objects for the final multipart form request. It bypasses temporary, chunked, and direct uploaders. |
temporaryUploadUrl | Regular multipart upload endpoint. The request field is file; the response may supply the submitted value as key, path, url, or id. |
temporaryUploadDeleteUrl | Optional delete endpoint used to discard a completed temporary upload that the user removes. |
chunked / chunkSize | Enable same-origin chunking and set bytes per append request. |
chunkedUrls | Endpoints named start, status, append, complete, and abort; all are required for standalone chunked uploads. |
directToStorage / uploadDisk | Enable direct upload and identify the Laravel filesystem disk bound to the signing endpoint. |
uploadPartSize / uploadMultipartThreshold | Set multipart part bytes and the byte threshold above which direct uploads become multipart. |
directUploadUrls | Endpoints named start, signPart, status, complete, and abort; all are required for standalone direct uploads. |
uploadRulesToken | Encrypted 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. |
requiresUploadRulesToken | Require 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:
'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:
File::make('avatar')
->image()
->maxSize(2048)
->maxDimensions(width: 1000, height: 1000);Submit-time rules such as required() run when the form is submitted:
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:
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:
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:
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:
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:
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
$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:
{
"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:
// 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:
php artisan form:cleanup-uploads --lifetime=7200Which 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 thes3disk 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:
'file_uploads' => [
'cleanup_disks' => ['media', 'documents'],
],php artisan form:cleanup-uploads --disk=media --disk=documentsRepeated --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.