The file-storage capability your app uses to store uploaded files and hand them back to visitors — on disk or in the cloud, with built-in validation and signed, expiring links.
ForgeStorage gives your app a single, uniform way to deal with files, no matter where they actually live. A small driver interface hides the difference between local disk and S3-compatible storage, and the same upload flow drives both.
It covers the whole file lifecycle your app cares about:
Framing: this is a component of your app, not a framework subsystem. You pick a provider, point your file inputs at it, and call a couple of helpers — the rest is yours.
php forge.php package:install-module --module=forge-storage
Installation runs the capability's migrations so the tables your app needs for file records are in place. The capability relies on the router to expose its upload endpoint, so that capability should be present too.
You choose a provider and its options through a small set of environment variables:
| Variable | Default | Effect |
|---|---|---|
STORAGE_PROVIDER (or FORGE_STORAGE_PROVIDER) |
local |
Which driver to use (local or s3) |
FILE_STORAGE_PATH (or FORGE_STORAGE_ROOT_PATH) |
storage/files |
Local disk root for stored files |
FORGE_STORAGE_PUBLIC_PATH |
public/storage |
Where public files are served from |
FORGE_STORAGE_AWS_ACCESS_KEY_ID / FORGE_STORAGE_AWS_SECRET_ACCESS_KEY |
— | S3 credentials when using the S3 driver |
FORGE_STORAGE_AWS_DEFAULT_REGION / FORGE_STORAGE_AWS_BUCKET / FORGE_STORAGE_AWS_ENDPOINT |
region us-east-1 |
S3 region, bucket, and optional custom endpoint |
FORGE_STORAGE_HASH_FILENAMES |
true |
Store files under a random name instead of the original |
FORGE_STORAGE_SIGNED_URL_DEFAULT_EXPIRATION / ..._MAX_EXPIRATION |
3600 / 86400 | Default and maximum lifetime, in seconds, for signed URLs |
FORGE_STORAGE_HASH_FILENAMES (config max_size / allowed_types) |
10 MB / * |
Global upload limits applied when a location sets none |
You can also define per-location rules —
different folders for avatars, documents, or imports
— each with its own allowed_types and
max_size, via the storage config's
locations. When you upload into a named
location, those rules apply instead of the global ones.
Every driver implements one interface — put, get, delete, exists, URL, signed URL, metadata, copy, and list — so switching storage is a config change, not a rewrite.
Stores files under your configured root on
disk and serves them from
/storage/<path>. Your
server config usually maps public storage so
files are reachable by URL.
Stores files in an S3-compatible bucket
(AWS or any endpoint that speaks S3). It needs
the aws/aws-sdk-php package and
your credentials, region, and bucket. URLs and
signed URLs come from the bucket rather than
your server.
Extensible: the provider list is a plain map in the resolver. Adding a new storage backend means writing one class that implements the driver contract and registering it — your app code keeps calling the same interface.
The ready-made path is an upload_input()
helper for your forms plus a built-in
POST /__upload endpoint that handles the
file. The helper emits a file input wired to that
endpoint with a signed token baked in:
<form action="/avatars" method="post">
<?= upload_input('avatar', 'avatars') ?>
<button>Upload</button>
</form>
That renders a single file input (name
avatar, targeting /__upload)
with a hidden signature field and a CSRF token. Pass
multiple, an accept filter,
or extra attributes via the options array:
<?= upload_input('docs', 'documents', ['multiple' => true, 'accept' => '.pdf']) ?>
The endpoint verifies the signature before accepting
anything, validates each file (size and type, using
that location's rules when one is named), stores it,
and returns a JSON response — a single result object,
or a files array for multiple uploads.
Every upload is checked before it's written anywhere.
With no location, the global limits apply (default
max 10 MB, any type); when you upload into a named
location, that location's allowed_types
and max_size take precedence.
allowed_types are rejected (when it isn't *).
Uniqueness is handled for you: when
hash_filenames is on (the default),
files are stored under a generated random name (with
their extension preserved). If it's off, the original
filename is cleaned of unsafe characters instead.
Public files get a straight URL back from the
driver's getUrl() — the same value that
comes with every upload result.
For private content, the local driver signs a URL with a hash of the path, an expiry time, and your app key, so it can only be used for a limited window:
$url = $storage->signedUrl('uploads/reports/q1.pdf', 3600);
The signed_url defaults cap expiry at a
default of one hour and a maximum of twenty-four
hours, so a careless caller can't mint a forever-link.
When you want to drive storage directly, resolve the storage driver — it's registered as a singleton under its interface — and call it like any object:
use Modules\ForgeStorage\Contracts\StorageDriverInterface;
$storage = $container->get(StorageDriverInterface::class);
$storage->put('users/1/profile.txt', $contents);
$contents = $storage->get('users/1/profile.txt');
$meta = $storage->getMetadata('users/1/profile.txt');
// ['size' => ..., 'mime_type' => ..., 'etag' => ..., 'last_modified' => ...]
$files = $storage->list('users/1/'); // first 1000
$copied = $storage->copy('a.txt', 'backup/a.txt');
$storage->delete('users/1/profile.txt');
Each upload returns an
UploadResult — path, URL, size, MIME
type, and original filename — which is exactly what
you persist to your own records (for example, a
files table in your database).