ForgeStorage

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.

Overview

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:

  • Reading uploaded files from a form (single or multiple).
  • Validating size and file type before anything is stored.
  • Storing with safe, unique names and giving you a URL back.
  • Looking up files and their metadata when you need them.
  • Generating signed, expiring URLs for private files.

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.

Installation

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.

Configuration

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.

Drivers

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.

Local

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.

S3

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.

Uploading Files

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.

Validation

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.

  • Oversized files are rejected with a clear message.
  • File types outside allowed_types are rejected (when it isn't *).
  • The signature is confirmed against your session before the upload is even looked at.

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.

Serving & Signed URLs

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.

From Your Code

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).