Peter Amiri
07/07/2026, 5:59 PMhasAttachment, no upload service, no params.file. And that's fine. The honest idiomatic flow is five steps, and this post walks every one end to end.
What it covers:
• The multipart trap — startFormTag does NOT auto-add enctype just because a fileField is on the form. Default is multipart=false. Forget multipart=true and the upload fails silently — no error, file posts as plain text. This eats more uploads than everything else combined.
• fileField vs fileFieldTag — bound (user[avatar]) vs unbound (document) field names, and why the name is your contract with the controller.
• The controller — native CFML cffile action="upload" (Wheels has zero upload wrapper), nameConflict="makeUnique", and copying the result onto params.user so the model can validate it.
• Validation — validatesFormatOf over the stored string + a custom validate() callback for size/MIME, because validators can't see multipart uploads.
• sendFile — the one direction with a real Wheels helper, with built-in path-traversal protection.
Plus a "Sharp edges" section of real, source-confirmed gotchas.