Digging Deeper
Storage
This page shows you how to read and write files through the wheels.storage abstraction instead of reaching for FileWrite / cfhttp directly. You configure one or more named disks — a local directory, an S3 bucket, or both — and resolve them through StorageManager for a single put / get / exists / delete / url / signedUrl surface that swaps backends with a config change.
You’ll learn:
- How to declare disks in
config/settings.cfmand resolve them by name - How
LocalDiskstores objects on the filesystem and serves them through a URL prefix - How
S3Disktalks to S3 over plaincfhttpwith from-scratch SigV4 signing — no AWS SDK, no JARs - How signed (presigned) URLs work on both drivers
- What the failure contract is (
Wheels.Storage.*errors)
The model
Section titled “The model”Storage mirrors the disk abstraction you already know from Laravel’s Filesystem or Rails’ ActiveStorage. Configuration names each disk and assigns it a driver — "local" or "s3". Application code asks StorageManager for a disk by name and receives a wheels.interfaces.StorageDiskInterface back, regardless of which driver is behind it.
<cfscript>set(storage = { default = "local", disks = { local = { driver = "local", root = ExpandPath("../storage/uploads"), urlPrefix = "/uploads" }, s3 = { driver = "s3", bucket = "my-bucket", region = "us-east-1", accessKeyId = env("S3_ACCESS_KEY_ID"), secretAccessKey = env("S3_SECRET_ACCESS_KEY"), visibility = "private" } }});</cfscript>Create the manager once, when the application starts. StorageManager’s init() takes the storage config struct, and the 4.0 DI container can’t pass a constructor argument that isn’t a registered service, so build it in app/events/onapplicationstart.cfm and keep it in the application scope. Settings are loaded by the time this file runs, so get("storage") returns the struct above:
<cfscript>application.storage = new wheels.storage.StorageManager(config = get("storage"));</cfscript>A reload (?reload=true) runs the file again, so a changed storage setting takes effect on the next reload.
StorageManager.disk() returns the default disk when you pass no name, and throws Wheels.Storage.UnknownDisk for a name you didn’t configure:
local.uploads = application.storage.disk(); // "local" (the default)local.assets = application.storage.disk("s3"); // "s3"Storing and reading objects
Section titled “Storing and reading objects”Every disk exposes the same six methods. Keys are forward-slash-delimited path strings; put() creates intermediate directories for you.
local.disk = application.storage.disk();
local.disk.put("avatars/42.png", local.binary, contentType = "image/png", visibility = "public");// -> "avatars/42.png"
local.content = local.disk.get("avatars/42.png"); // binarylocal.has = local.disk.exists("avatars/42.png"); // truelocal.disk.delete("avatars/42.png"); // true| Method | Returns | Notes |
|---|---|---|
put(key, content, contentType, visibility) | the key | Binary or string content; visibility is "public" or "private". |
get(key) | binary | Throws Wheels.Storage.NotFound when absent. |
exists(key) | boolean | LocalDisk checks the filesystem; S3Disk issues a HEAD. |
delete(key) | boolean | false when the object was already gone. |
url(key) | string | Public URL (local prefix, or the S3 object URL). |
signedUrl(key, expiresIn, contentDisposition) | string | Expiring URL; see below. |
get() and put() round-trip bytes exactly — LocalDisk.put() writes binary, never a string, so a PNG comes back identical to what went in.
LocalDisk
Section titled “LocalDisk”The local driver stores objects under a configured root and exposes them through urlPrefix, which is whatever path your app already serves statically. Its config:
| Key | Required | Default | Meaning |
|---|---|---|---|
root | yes | — | Directory objects are written under. |
urlPrefix | no | "" | Public URL path the app serves the files from. |
signingKey | no | "" | HMAC secret for signedUrl(); required only if you call it. |
<cfscript>set(storage = { default = "local", disks = { local = { driver = "local", root = ExpandPath("../storage/uploads"), urlPrefix = "/uploads", signingKey = env("STORAGE_SIGNING_KEY") } }});</cfscript>url("avatars/42.png") produces /uploads/avatars/42.png. Because the filesystem has no native presigning, signedUrl() appends an HMAC expires / signature query string over the key and expiry — your download route is expected to verify it with verifySignature() before streaming. This is the same tradeoff Rails’ DiskController and Laravel’s serve make.
S3Disk
Section titled “S3Disk”The S3 driver speaks to S3 (and S3-compatible endpoints) over plain cfhttp, signing each request with a from-scratch SigV4 implementation in wheels.storage.S3Signer — no AWS SDK, no bundled JARs. Its config:
| Key | Required | Default | Meaning |
|---|---|---|---|
bucket | yes | — | Bucket name. |
region | yes | — | AWS region, e.g. us-east-1. |
accessKeyId | yes | — | AWS access key. |
secretAccessKey | yes | — | AWS secret key. |
visibility | no | "private" | Default ACL for put() (public → public-read). |
endpoint | no | "" | S3-compatible endpoint (MinIO, R2, etc.). |
usePathStyle | no | false | Force path-style addressing for non-AWS endpoints. |
timeout | no | 60 | HTTP timeout in seconds. |
application.storage.disk("s3").put( key = "reports/q3.pdf", content = local.pdfBytes, contentType = "application/pdf", visibility = "private");url() returns the object’s public URL; signedUrl() returns a presigned, expiring GET URL computed locally by S3Signer — no round-trip to AWS required:
local.fileUrl = application.storage.disk("s3").signedUrl( key = "reports/q3.pdf", expiresIn = 900, contentDisposition = "attachment; filename=q3.pdf");Signed URLs on both drivers
Section titled “Signed URLs on both drivers”Both signedUrl() signatures match: signedUrl(key, expiresIn = 300, contentDisposition = ""). expiresIn must be within 1..604800 (one second to seven days) or the call throws Wheels.Storage.InvalidExpiresIn. On S3 the URL is a real AWS presigned GET; on local it’s a URL with an HMAC token your app verifies. Object keys are RFC3986-encoded on both drivers, so spaces and reserved characters behave identically.
Failure contract
Section titled “Failure contract”Storage errors are typed so you can rescue them precisely:
| Error | Raised when |
|---|---|
Wheels.Storage.UnknownDisk | disk("nope") names a disk that isn’t configured. |
Wheels.Storage.UnknownDriver | A disk’s driver is neither local nor s3. |
Wheels.Storage.InvalidConfiguration | A disk omits a required config key (e.g. root, bucket). |
Wheels.Storage.NotFound | get() on a missing key. |
Wheels.Storage.InvalidKey | An empty or slash-only key on LocalDisk. |
Wheels.Storage.InvalidExpiresIn | expiresIn outside 1..604800. |
Wheels.Storage.MissingSigningKey | signedUrl() on a local disk with no signingKey. |
Wheels.Storage.RequestFailed | An S3 request fails in a way that isn’t “the object is absent”. |
exists() and delete() deliberately distinguish “object absent” (false) from “the backend didn’t answer” (a thrown error) — a connection failure must not read as “no file here.”