Skip to content

[6.x] Forms 2: Various file upload improvements#15006

Merged
jasonvarga merged 29 commits into
forms-2from
forms-2-file-uploads
Jul 21, 2026
Merged

[6.x] Forms 2: Various file upload improvements#15006
jasonvarga merged 29 commits into
forms-2from
forms-2-file-uploads

Conversation

@duncanmcclean

@duncanmcclean duncanmcclean commented Jul 14, 2026

Copy link
Copy Markdown
Member

This pull request makes various improvements to how File Uploads are handled in Forms.

Upload fieldtype

This PR adds a new Upload fieldtype, designed to better facilitate file uploads on forms.

Previously, the Upload form fieldtype resolved to either the assets/files fieldtypes, depending on whether the uploaded file should be stored or not.

This worked great — until we started building automagic form pages in Forms Pro, which reuses fieldtype Vue components. The assets and files fieldtypes had different UIs, which was a little confusing, but they also hit /cp URLs which require authentication (something a visitor filling out a form obviously doesn't have).

FormUploadFieldtype.vue has two states:

  • Editable: a drag & drop UI styled after the files fieldtype. Instead of uploading files the moment they're added, they're uploaded when the page is submitted. This is the state used on the Automagic Form page.
  • Read-only: used when viewing a submission. File uploads can't be changed, so the upload UI is hidden. Users can click to download assets.

Deferring asset creation until the submission finalises

Previously, a store: true upload field created a real Asset as soon as that page of a form was submitted. On multi-page forms, this meant an Asset could be created before the submission was actually complete — if the visitor abandoned the form partway through, that Asset was left orphaned.

Uploads now land in temporary storage first (storage/statamic/form-uploads/{submission}/{field}), and are only turned into real Assets once the submission is finalized, via a new CreateAssetsFromFileUploads job.

Submission::finalize() runs through this order: create assets, send emails (with attachments), then delete anything left over in temporary storage via DeleteTemporaryFiles, which also cleans up old-style files field uploads.

This timing only really matters for multi-page forms. On a single-page form, submission and finalization happen in the same request, so the behavior is effectively unchanged.

Support for assets & files fields

Forms on 6.x currently use the assets fieldtype for fields which should be retained after submission, or the files fieldtype for files which should be deleted once the email's sent.

Form blueprint fields will be converted to their "form fieldtype" equivalents when updating to Forms 2. As part of this, assets and files fields are migrated to the new Upload fieldtype w/ the relevant config options.

However, fields imported from a fieldset are not converted as field sets continue to use "normal" fieldtypes. This means we still need to maintain support for assets and files fields as they work right now.

@duncanmcclean duncanmcclean changed the title [6.x] Forms 2: Add Upload fieldtype to handle form file uploads [6.x] Forms 2: File Upload Improvements Jul 14, 2026
@duncanmcclean duncanmcclean changed the title [6.x] Forms 2: File Upload Improvements [6.x] Forms 2: Various file upload improvements Jul 14, 2026
@duncanmcclean
duncanmcclean marked this pull request as ready for review July 15, 2026 13:35
@duncanmcclean
duncanmcclean requested a review from jasonvarga July 15, 2026 13:35
duncanmcclean and others added 20 commits July 15, 2026 15:47
…oads...

For example:

1. Upload files
2. Submit the page
3. Go back to the previous page (the one w/ files)
4. Attempt to resubmit the page

Before this commit, a validation error would be thrown complaining that you haven't uploaded a valid file.

After this commit, you don't see any validation errors because the array/min/max rules are removed before validation happens if it detects existing file uploads.

Obviously, if you upload a new file, it will be re-validated as you'd expect.
merges stuff from `extraRules` and the `withoutRulesForAlreadyUploadedFiles` method i added.

should make things _slightly_ easier to read and understand.
DeleteTemporaryFiles bailed early when a form had no `files` or `store: false` upload fields, so a form with only `store: true` fields never had its `form-uploads/{id}` directory removed. For abandoned partial submissions - which are never promoted to real assets - this left their temporary uploads orphaned on disk.

The directory is now always deleted regardless of each field's `store` setting; only `files`/`store: false` field values are removed from the submission, so promoted asset paths are preserved. SendEmails also dispatches the cleanup for any upload field, so `store: true`-only forms get their temp directory cleaned on finalize too.
Carried-over upload values were validated with a byte-for-byte equality check against what's stored, so removing one of several already-uploaded files (resending a subset) was rejected as a forged value. Only clearing the field entirely or resending it unchanged was allowed.

Values not present in the request now just have to be a subset of what's already stored against the field. Removing files validates normally, while paths that were never uploaded - which could be used to read or delete arbitrary files at finalize - are still prohibited.
and add config option to change form file uploads path
CreateAssetsFromFileUploads was ShouldQueue, so finalize()'s dispatchSync
serialized it and the asset-path rewrite landed on a detached copy. The
original submission kept the temp path, which DeleteTemporaryFiles then
persisted, leaving store:true uploads pointing at a non-existent temp path
(augmentation returned null). Run the job in-process so the rewrite sticks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e CP

preload() looked up the asset with Asset::find($value) using the bare path
that submissions store, but Asset::find needs a container-qualified id, so it
returned null and the CP submission view showed an un-clickable filename with
no download link. Qualify the path with the container, mirroring augment().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… a fieldset

Fields imported into a form via a fieldset import keep their raw
assets/files type instead of being normalized to upload, unlike
inline form fields. Since the dedicated assets/files form field
views were removed, these fields resolved to the default text
input instead of a file upload input. Fallback::equivalentFormFieldtype()
now maps assets/files to the upload form fieldtype so the view
falls back correctly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
jasonvarga and others added 3 commits July 21, 2026 14:56
FormUpload::preload() typehinted its map closure as string $value, so a
non-string element in the field value (e.g. a null slot from a cleared or
partial submission) threw a TypeError while rendering the submission in the
CP. augment() already iterates the same values untyped; drop the hint to
match and let the existing basename/asset-not-found path handle it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CreateAssetsFromFileUploads::uploadAsset() built the Symfony
UploadedFile directly from $disk->path($diskPath). That method just
concatenates the disk's configured root with the path — it doesn't
guarantee a real local filesystem location. For the default local
disk that happens to work, but now that file_uploads_disk is
configurable, a cloud disk (S3 etc.) returns a bare object key,
so the UploadedFile pointed nowhere and asset creation silently
failed downstream in Uploader::upload()'s getRealPath() call.

Stream the temp upload down to a real local temp file (matching
the pattern used in ReplacementFile::writeTo()) and build the
UploadedFile from that instead, cleaning up both the local temp
file and the remote temp object afterwards.

Added a test using a minimal in-memory Flysystem adapter to
simulate a non-local disk, since Storage::fake() is itself a
local driver and wouldn't have caught this.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CreateAssetsFromFileUploads::uploadAsset() passed the readStream()
resource straight into file_put_contents() without closing it
afterwards, matching the fclose pattern already used for readStream
in Imaging/ResponseFactory. Close it explicitly.

Replaced the 111-line hand-rolled InMemoryFlysystemAdapter with a
~10-line NonLocalPathFilesystemAdapter that wraps a real local
Flysystem backend and only overrides path(), which is the one thing
that needs to lie to reproduce a cloud-disk-style bug. exists(),
readStream(), mimeType(), and delete() all hit real files instead of
being reimplemented. Confirmed it still fails against the pre-fix
code (FileNotFoundException from the bogus path()).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jasonvarga

Copy link
Copy Markdown
Member

Built on top of the original work with runtime verification in a live sandbox, which surfaced and fixed several issues:

Fixes

  • Stored asset reference lost on submission — a store: true upload was saved as its temporary path instead of the created asset, so augmentation returned null and the asset rendered empty in the CP and on the front-end. CreateAssetsFromFileUploads no longer runs as a queued job during finalize(), so the rewritten asset path persists before the temp files are cleaned up. (+ integration test that exercises the real finalize job chain, not Bus::fake())
  • CP couldn't display stored uploadsFormUpload::preload() looked up the asset with a bare path rather than a container-qualified id; now resolves via the container, matching augment().
  • Fieldset-imported assets/files fields rendered as plain text inputs — after the upload fieldtype consolidation, legacy fields imported via a fieldset (intentionally not normalized) fell back to a removed view. They now fall back to the upload view, while a published custom assets/files view still takes precedence. (+ unit & integration tests)
  • Non-local file_uploads_disk broke asset creation — finalize assumed the temp-upload disk was local ($disk->path()); it now streams the temp file down to a local temp file first, so cloud disks (S3, etc.) work. (+ test)
  • preload() could throw a TypeError on a null/non-string value — removed the overly strict string typehint.

Verified end-to-end

  • Path-traversal protection and email-attachment handling confirmed working in a live sandbox.

(Yes, I had Claude write that. Can you tell?)

@jasonvarga
jasonvarga merged commit fa798d0 into forms-2 Jul 21, 2026
19 checks passed
@jasonvarga
jasonvarga deleted the forms-2-file-uploads branch July 21, 2026 20:59
@duncanmcclean duncanmcclean mentioned this pull request Jul 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants