ez-php / storage
File storage abstraction for the ez-php framework — local filesystem and S3-compatible drivers
Requires
- php: ^8.5
- ext-curl: *
- ez-php/contracts: ^2.0
- ez-php/http: ^2.0
Requires (Dev)
- ez-php/docker: ^2.0
- friendsofphp/php-cs-fixer: ^3.94
- phpstan/phpstan: ^2.1
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^13.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- 2.5.6
- 2.5.5
- 2.5.4
- 2.5.3
- 2.5.2
- 2.5.1
- 2.5.0
- 2.4.11
- 2.4.10
- 2.4.9
- 2.4.8
- 2.4.7
- 2.4.6
- 2.4.5
- 2.4.4
- 2.4.3
- 2.4.2
- 2.4.1
- 2.4.0
- 2.3.9
- 2.3.8
- 2.3.7
- 2.3.6
- 2.3.5
- 2.3.4
- 2.3.3
- 2.3.2
- 2.3.1
- 2.3.0
- 2.2.1
- 2.2.0
- 2.1.1
- 2.1.0
- 2.0.1
- 2.0.0
- 1.14.0
- 1.13.0
- 1.12.2
- 1.12.1
- 1.12.0
- 1.11.2
- 1.11.1
- 1.11.0
- 1.10.0
- 1.9.2
- 1.9.1
- 1.9.0
- 1.8.0
- 1.7.1
- 1.7.0
- 1.6.1
- 1.6.0
- 1.5.1
- 1.5.0
- 1.4.2
- 1.4.1
- 1.4.0
- 1.3.0
- 1.2.0
- 1.1.1
- 1.1.0
- 1.0.1
This package is auto-updated.
Last update: 2026-09-30 19:50:26 UTC
README
File storage abstraction for the ez-php framework.
Provides a unified interface (put, get, delete, exists, url) over pluggable drivers. Ships with a LocalDriver (filesystem), an S3Driver (AWS S3 and S3-compatible APIs via cURL + AWS Signature V4), and a GcsDriver (Google Cloud Storage via cURL + Bearer access token). Integrates with UploadedFile from ez-php/http.
Installation
composer require ez-php/storage
Configuration
Add config/storage.php to your application:
return [ 'driver' => getenv('STORAGE_DRIVER') ?: 'local', 'local' => [ 'root' => getenv('STORAGE_ROOT') ?: 'storage/app', 'url' => getenv('STORAGE_URL') ?: '', ], 's3' => [ 'key' => getenv('AWS_ACCESS_KEY_ID') ?: null, 'secret' => getenv('AWS_SECRET_ACCESS_KEY') ?: null, 'region' => getenv('AWS_DEFAULT_REGION') ?: 'us-east-1', 'bucket' => getenv('AWS_BUCKET') ?: null, 'endpoint' => getenv('AWS_ENDPOINT') ?: null, // optional: MinIO, R2, etc. 'url' => getenv('AWS_URL') ?: null, // optional: CDN base URL 'url_expiry' => 3600, 'multipart_part_size' => 8 * 1024 * 1024, // putStream(): larger streams use multipart upload ], 'gcs' => [ 'bucket' => getenv('GCS_BUCKET') ?: null, 'access_token' => getenv('GCS_ACCESS_TOKEN') ?: null, // OAuth2 Bearer token; caller refreshes it 'url' => getenv('GCS_URL') ?: null, // optional: CDN base URL ], ];
Register the provider in provider/modules.php:
\EzPhp\Storage\StorageServiceProvider::class,
Usage
Via static façade
use EzPhp\Storage\Storage; Storage::put('avatars/user-1.jpg', $imageBytes); $data = Storage::get('avatars/user-1.jpg'); Storage::exists('avatars/user-1.jpg'); // true Storage::url('avatars/user-1.jpg'); // public URL or presigned URL Storage::delete('avatars/user-1.jpg');
Storing an uploaded file
Storage::putUploadedFile('uploads/' . $file->originalName(), $file);
Via dependency injection
use EzPhp\Storage\StorageInterface; class AvatarController { public function __construct(private readonly StorageInterface $storage) {} public function upload(Request $request): Response { $file = $request->file('avatar'); $this->storage->putUploadedFile('avatars/' . $file->originalName(), $file); // ... } }
Serving files over HTTP
StorageResponse turns a stored file into a response — with Range support so browsers can seek in audio/video and resume downloads:
use EzPhp\Storage\StorageResponse; // controller: GET /files/{path} return StorageResponse::serve($storage, $path, $request, filename: 'report.pdf', contentType: 'application/pdf', inline: true);
| Request | Response |
|---|---|
no Range |
200, Content-Length, Accept-Ranges: bytes |
Range: bytes=2-5 / bytes=15- / bytes=-4 |
206 + Content-Range: bytes 2-5/20 |
unsatisfiable (bytes=100- on a 20-byte file) |
416 + Content-Range: bytes */20 |
| several ranges, another unit, malformed | 200, the whole file |
| unknown path | 404 |
Range needs a seekable stream (local files, in-memory); for a non-seekable remote stream the whole file is streamed without Content-Length. If-Range is not evaluated.
Let the web server send the file (PHP only checks access):
// nginx: location /protected/ { internal; alias /var/www/storage/app/; } return StorageResponse::xAccelRedirect('/protected', $path, 'report.pdf', 'application/pdf'); // Apache mod_xsendfile / lighttpd, LocalDriver only: return StorageResponse::xSendfile($localDriver, $path, 'report.pdf', 'application/pdf');
Expiring links for the LocalDriver (the S3 driver's url() is already presigned):
$signer = new SignedUrl($_ENV['FILES_SECRET'], 'https://app.test/files'); // secret >= 16 bytes $link = $signer->make('reports/2026.pdf', ttl: 600); // …?expires=…&signature=… // in the /files/{path} handler if (!$signer->verifyRequest($path, $request)) { return new Response('Forbidden', 403); } return StorageResponse::serve($storage, $path, $request, basename($path));
The signature is HMAC-SHA256 over the path and the expiry, compared in constant time; changing either invalidates it. A link is valid until it expires (no single-use tracking).
Drivers
LocalDriver
Stores files under a configurable root directory. Creates nested directories automatically.
$driver = new LocalDriver('/var/www/storage', 'https://cdn.example.com'); $driver->put('docs/readme.txt', 'Hello'); $driver->url('docs/readme.txt'); // https://cdn.example.com/docs/readme.txt
S3Driver
Uploads and retrieves objects using cURL with AWS Signature Version 4. Works with AWS S3 and any S3-compatible service (MinIO, Cloudflare R2, DigitalOcean Spaces).
url()returns a presigned GET URL (valid forurl_expiryseconds)- If a custom
url(CDN) is configured,url()returns{url}/{path}instead - Custom
endpointoverrides the default{bucket}.s3.{region}.amazonaws.comhost putStream()uploads streams larger thanmultipart_part_size(default 8 MiB) with S3 multipart upload, one part in memory at a time; failed uploads are aborted. Parts must be ≥ 5 MiB except the last (S3 rule).
$driver = new S3Driver('key', 'secret', 'eu-west-1', 'my-bucket'); $driver->put('report.pdf', file_get_contents('/tmp/report.pdf')); $driver->url('report.pdf'); // presigned URL
GcsDriver
Uploads and retrieves objects using cURL against the Google Cloud Storage JSON API,
authenticated with a Bearer access token you supply (e.g. from
gcloud auth print-access-token, or a workload-identity/metadata-server token in
production). The driver never mints or refreshes tokens itself.
url()returns a directhttps://storage.googleapis.com/{bucket}/{path}URL — no signed URLs, since that requires a service-account private key rather than a Bearer token. Put a CDN or a bucket/object ACL in front for controlled public access.- If a custom
url(CDN) is configured,url()returns{url}/{path}instead.
$driver = new GcsDriver('my-bucket', $accessToken); $driver->put('report.pdf', file_get_contents('/tmp/report.pdf')); $driver->url('report.pdf'); // https://storage.googleapis.com/my-bucket/report.pdf
InMemoryDriver (tests only)
Keeps file contents in a PHP array — no filesystem, no network. Use it to test code
that depends on StorageInterface without writing real files.
use EzPhp\Storage\InMemoryDriver; $driver = new InMemoryDriver(); $driver->put('invoices/2026.pdf', $bytes); $driver->exists('invoices/2026.pdf'); // true $driver->url('invoices/2026.pdf'); // memory://invoices/2026.pdf $driver->all(); // ['invoices/2026.pdf' => $bytes] $driver->flush(); // reset between tests
Set STORAGE_DRIVER=memory to use it via the service provider.
- Rejects
..path segments exactly likeLocalDriver, so a traversal bug cannot pass against the double and then fail against the real driver. - Supports the full interface, streams included (
getStream()/putStream()usephp://memory). - Not for production. Nothing is persisted or shared between processes.
Running Tests
docker compose exec app composer full
S3 integration tests are skipped unless AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_BUCKET are set in the environment. GCS integration tests are likewise skipped unless GCS_BUCKET and GCS_ACCESS_TOKEN are set.