File Uploads in Wheels 4.0, the Idiomatic Way #3304
bpamiri
announced in
Announcements
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
A net-new how-to in the post-GA series — the first one covering file uploads, because it's the question that comes up the moment someone tries to add an avatar field and goes looking for a Wheels equivalent of Active Storage.
Read: https://blog.wheels.dev/posts/file-uploads-in-wheels-4
The short version: Wheels 4.0 has no dedicated file-upload abstraction, and the post argues that's a deliberate, honest design rather than a gap. Grep
vendor/wheels/for a storage layer —storeFile,mountUploader,activeStorage— and you get nothing; the word "upload" appears only in comments and doc strings, never as an abstraction.cffile/fileUpload()appear zero times invendor/wheels/controller/. The framework owns the two ends that need framework knowledge and leaves the middle to plain CFML.The flow the post walks, end to end
Building avatar uploads for a
User:fileField(objectName="user", property="avatar")(bound, emits the bracket nameuser[avatar]) orfileFieldTag(name="document")(unbound, plain name). The field name is the contract with the controller.startFormTag(multipart=true)to getenctype="multipart/form-data".cffile action="upload"withnameConflict="makeUnique", then copycffile.serverFile/.fileSize/.contentTypeontoparams.user.validatesFormatOfover the stored path string + a customvalidate()callback for size and MIME, because validators can't inspect a multipart upload.sendFile, the one direction with a real Wheels helper, with built-in path-traversal protection.The one thing everyone gets wrong
startFormTagdoes not detect afileFieldand does not add the multipart enctype on its own. The framework default ismultipart=false. The actual logic is:if (!StructKeyExists(arguments, "enctype") && arguments.multipart) { arguments.enctype = "multipart/form-data"; }Forget
multipart=trueand the browser posts the file input as a plain-text filename,paramshas no file,cffilefinds nothing, and the framework throws no error. The upload fails silently while everything looks correct. The post leads with this because it's the bug that costs people an hour every time.Corroboration from the framework itself
The framework's own AI-reference doc (
vendor/wheels/public/views/ai.cfm) documents the "File Uploads" pattern using nativefileUpload(expandPath('./public/uploads/'), 'avatar', 'image/*', 'MakeUnique')reading.serverFile, paired withstartFormTag(enctype='multipart/form-data'). It's illustrative snippet text in a JSON blob (not executed code), but it's the framework telling you in its own voice that native CFML is the intended upload path.A few sharp edges the post details
cffilewrites the bytes in the controller; validation runs afterward. A validation failure stops the DB row but leaves an orphan file. Reject-and-delete belongs in the controller or thevalidatemethod.sendFileguards the download path (throwsWheels.InvalidPathon.., strips null bytes), but nothing guards the upload destination.nameConflict="makeUnique"is your friend.$file()inGlobal.cfcis NOT an upload API. It's an internalcffilewrapper for a cross-engineattributeCollectiongotcha. Don't let an assistant present it as a public upload helper.fileField→user[avatar]→cffile fileField="user[avatar]"→StructKeyExists(form, "user[avatar]"). Mismatch is another silent failure.Discussion
If you've shipped uploads on Wheels, I'd like to hear how you handle the storage-location question (in-docroot vs. out, object storage), and whether you've built any conventions around orphan cleanup or per-user authorization on
sendFile. There's deliberately no framework convention enforcing a storage location — curious what patterns people have settled on. Feedback on what's missing or what a follow-up should cover is welcome in this thread.All reactions