File storage¶
Namespace Keel\Storage. A small Storage interface over a directory on disk (LocalStorage)
or anything league/flysystem reaches (FlysystemStorage: S3, SFTP, Google Cloud, in-memory), a
Disks registry naming several of them, and #[FromFile] to bind uploads to handler
parameters with a #[File] rule for size, type and extension.
// config/storage.php
return static fn(string $projectDir): StorageConfig => new StorageConfig(
disks: [
'local' => DiskConfig::local($projectDir . '/var/storage'),
'public' => DiskConfig::local($projectDir . '/public/uploads', url: '/uploads'),
's3' => DiskConfig::flysystem(S3Filesystem::class, url: 'https://cdn.example.com'),
],
default: 'local',
);
// config/services.php
return new ServicesConfig([StorageProvider::class]);
final class AvatarController
{
public function __construct(private readonly Disks $disks) {}
#[Post('/me/avatar')]
#[Auth]
public function upload(
Principal $user,
#[FromFile, File(maxSize: '2M', mimeTypes: ['image/png', 'image/jpeg'])] UploadedFileInterface $avatar,
): array {
$path = sprintf('avatars/%s.%s', $user->id(), pathinfo((string) $avatar->getClientFilename(), PATHINFO_EXTENSION));
$this->disks->disk('public')->upload($avatar, $path);
return ['url' => $this->disks->disk('public')->url($path)];
}
}
Storage¶
| Method | What it does |
|---|---|
write($path, $contents) |
a string or a readable stream; LocalStorage writes atomically (temporary file, then rename) |
upload(UploadedFileInterface $file, $path) |
moves an upload (streams it); a failed upload is a StorageException |
read($path), readStream($path) |
the contents, or a stream the caller closes; a missing file is a StorageException |
exists, delete, move, size, lastModified |
deleting a missing file is not an error |
url($path) |
the public URL under the disk's base URL; a StorageException on a disk without one |
Paths are relative and /-separated; .., absolute paths, drive letters and NUL bytes are
refused (StorageException), so a path built from request data cannot leave the root. Missing
directories are created on write.
Storage in the container is the default disk; Disks::disk('name') picks another. A
Flysystem disk points at the container id of a League\Flysystem\FilesystemOperator you build
in a provider (composer require league/flysystem plus the adapter); FlysystemStorage wraps
one directly as well. Every disk is built on first use and kept for the worker's life.
Uploads¶
#[FromFile] UploadedFileInterface $avatar takes the multipart field named like the parameter
(#[FromFile('photo')] names it). A nullable parameter is null without the field; a
non-nullable one answers 400 Missing file "avatar". An upload PHP rejected (size limit,
partial) is 400 with the error code. An array parameter receives every file of a multi-file
field (photos[]). Validation rules on the parameter run on each file:
#[File(maxSize: '2M', mimeTypes: ['image/png'], extensions: ['png'])]: size in bytes or
512K/2M/1G; MIME types sniffed from the first bytes of the content with finfo, never from
the client's Content-Type; extensions of the client file name, case-insensitive. The client
file name is for display and extension checks only: build the stored path yourself.
The OpenAPI document describes such an operation with a multipart/form-data request body:
one string/binary property per file (an array of them for a multi-file field), required
when the parameter is, with the fields of a #[FromBody] DTO beside them.
Input::file('avatar') and Input::files() read uploads without an attribute, see
HTTP and kernel.