Skip to content

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.cfm and resolve them by name
  • How LocalDisk stores objects on the filesystem and serves them through a URL prefix
  • How S3Disk talks to S3 over plain cfhttp with 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)

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.

config/settings.cfm
<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>

Register the manager as a singleton in config/services.cfm. Because its init() takes the storage config struct — which isn’t a registered service — wrap construction in toFactory(), and end with .asSingleton() (a factory mapping is transient otherwise, so every service("storage") call would build a new manager):

config/services.cfm
<cfscript>
local.di = injector();
local.di.map("storage").toFactory(function() {
return new wheels.storage.StorageManager(config = get("storage"));
}).asSingleton();
</cfscript>

StorageManager.disk() returns the default disk when you pass no name, and throws Wheels.Storage.UnknownDisk for a name you didn’t configure:

resolving a disk
local.uploads = service("storage").disk(); // "local" (the default)
local.assets = service("storage").disk("s3"); // "s3"

If you aren’t using the container, instantiate the manager directly — new wheels.storage.StorageManager(config = get("storage")) — and keep it in a scope you control.

Every disk exposes the same six methods. Keys are forward-slash-delimited path strings; put() creates intermediate directories for you.

basic put/get/exists/delete
local.disk = service("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"); // binary
local.has = local.disk.exists("avatars/42.png"); // true
local.disk.delete("avatars/42.png"); // true
MethodReturnsNotes
put(key, content, contentType, visibility)the keyBinary or string content; visibility is "public" or "private".
get(key)binaryThrows Wheels.Storage.NotFound when absent.
exists(key)booleanLocalDisk checks the filesystem; S3Disk issues a HEAD.
delete(key)booleanfalse when the object was already gone.
url(key)stringPublic URL (local prefix, or the S3 object URL).
signedUrl(key, expiresIn, contentDisposition)stringExpiring 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.

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:

KeyRequiredDefaultMeaning
rootyes—Directory objects are written under.
urlPrefixno""Public URL path the app serves the files from.
signingKeyno""HMAC secret for signedUrl(); required only if you call it.
config/settings.cfm — local disk
<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.

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:

KeyRequiredDefaultMeaning
bucketyes—Bucket name.
regionyes—AWS region, e.g. us-east-1.
accessKeyIdyes—AWS access key.
secretAccessKeyyes—AWS secret key.
visibilityno"private"Default ACL for put() (public → public-read).
endpointno""S3-compatible endpoint (MinIO, R2, etc.).
usePathStylenofalseForce path-style addressing for non-AWS endpoints.
timeoutno60HTTP timeout in seconds.
putting an object to S3
service("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:

presigned download URL
local.url = service("storage").disk("s3").signedUrl(
key = "reports/q3.pdf",
expiresIn = 900,
contentDisposition = "attachment; filename=q3.pdf"
);

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.

Storage errors are typed so you can rescue them precisely:

ErrorRaised when
Wheels.Storage.UnknownDiskdisk("nope") names a disk that isn’t configured.
Wheels.Storage.UnknownDriverA disk’s driver is neither local nor s3.
Wheels.Storage.InvalidConfigurationA disk omits a required config key (e.g. root, bucket).
Wheels.Storage.NotFoundget() on a missing key.
Wheels.Storage.InvalidKeyAn empty or slash-only key on LocalDisk.
Wheels.Storage.InvalidExpiresInexpiresIn outside 1..604800.
Wheels.Storage.MissingSigningKeysignedUrl() on a local disk with no signingKey.
Wheels.Storage.RequestFailedAn 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.”