Storage¶
Note
Not part of core. Install it separately:
composer require kinetis/storage
kinetis/storage gives an application one
League\Flysystem\FilesystemOperator for writing, reading, listing and
deleting files. The local driver runs on Amp\File, so a call suspends
the calling Fiber instead of blocking the worker. Storage (S3) puts
S3 behind the same interface, and Appendix: Storage Reference holds the
contracts behind every rule on this page.
Configure and inject¶
FILESYSTEM_DRIVER=local
FILESYSTEM_ROOT=/var/app/storage
With FILESYSTEM_DRIVER set, the package binds FilesystemOperator, so
a controller, command or service constructor-injects it with nothing to
register. With the key unset, the package binds nothing.
FILESYSTEM_ROOT is required and must be non-empty, and every path the
application passes is relative to it.
The binding builds one instance per worker process or thread and keeps
it for the application’s lifetime. Each instance owns its own Amp\File
driver, which without ext-uv or ext-eio is a pool of up to eight
worker processes. Install either extension in production to replace the
pool with an OS-native driver.
Write, read, list and delete¶
use League\Flysystem\FilesystemOperator;
final readonly class MonthlyReports
{
public function __construct(private FilesystemOperator $storage) {}
public function save(\DateTimeImmutable $month, string $csv): void
{
$this->storage->write(self::path($month), $csv);
}
public function find(\DateTimeImmutable $month): ?string
{
$path = self::path($month);
return $this->storage->fileExists($path) ? $this->storage->read($path) : null;
}
/** @return list<string> */
public function paths(): array
{
$paths = [];
foreach ($this->storage->listContents('reports', deep: true) as $item) {
if ($item->isFile()) {
$paths[] = $item->path();
}
}
return $paths;
}
public function remove(\DateTimeImmutable $month): void
{
$this->storage->delete(self::path($month));
}
private static function path(\DateTimeImmutable $month): string
{
return 'reports/' . $month->format('Y-m') . '.csv';
}
}
write() creates missing parent directories and replaces an existing
file. read() of a file that does not exist throws UnableToReadFile.
fileSize(), lastModified(), mimeType(), copy(), move(),
createDirectory() and deleteDirectory() complete the set. The
injected value is plain Flysystem, so its documentation applies
directly.
A write requesting no visibility keeps the mode of the file it replaces, and a new file gets the umask default. Ask for a private file explicitly:
use League\Flysystem\Config;
use League\Flysystem\Visibility;
$storage->write('exports/payroll.csv', $csv, [
Config::OPTION_VISIBILITY => Visibility::PRIVATE,
]);
visibility names the file only. A parent directory created on the way
reads directory_visibility; see Appendix: Storage Reference.
A queued job’s constructor holds only data that survives serialization,
so a job receives the filesystem as a class-typed parameter of
handle(), which the container resolves when the job runs (see
Queue):
use Kinetis\Queue\Job;
use League\Flysystem\FilesystemOperator;
final readonly class ArchiveExport implements Job
{
public function __construct(public string $path) {}
public function handle(FilesystemOperator $storage): void
{
$storage->move($this->path, 'archive/' . basename($this->path));
}
}
Handle an upload¶
An upload carries two labels the client wrote: its media type and its filename. Neither is evidence of what the bytes are. The route below bounds the size, detects the type from the content, and generates the storage key itself:
use Kinetis\Http\Attributes\Post;
use Kinetis\Http\Responses\ErrorResponse;
use Kinetis\Validation\Constraints\FileSize;
use League\Flysystem\FilesystemOperator;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UploadedFileInterface;
final readonly class AvatarController
{
/** Accepted formats, keyed by the type detected from the content. */
private const array EXTENSIONS = [
'image/png' => 'png',
'image/jpeg' => 'jpg',
];
public function __construct(private FilesystemOperator $storage) {}
#[Post('/avatars')]
public function store(
#[FileSize(maxBytes: 2_000_000, minBytes: 1)]
UploadedFileInterface $avatar,
): ResponseInterface|array {
$contents = (string) $avatar->getStream();
$detected = (new \finfo(\FILEINFO_MIME_TYPE))->buffer($contents);
$extension = self::EXTENSIONS[$detected] ?? null;
if ($extension === null) {
return ErrorResponse::create(422, 'The avatar must be a PNG or JPEG image.');
}
$path = 'avatars/' . bin2hex(random_bytes(16)) . '.' . $extension;
$this->storage->write($path, $contents);
return ['path' => $path];
}
}
Size.
#[FileSize]refuses a part outside its bounds with a422before the handler runs (see Routing & Validation). The request body has already been received and staged, sowrite()with the contents is the direct call.Type.
finforeads the content’s signature.getClientMediaType()repeats whatever the client sent and never selects the stored format.ext-fileinfois required byleague/mime-type-detection, a Flysystem dependency, so it is present wherever this package is installed.Name. The key is generated. A client filename is never a path segment: it can collide with another user’s object, name a dotfile, or carry a segment the path rules refuse. Keep the original name as data if the application displays it, and escape it on output.
A detected signature names a format; it does not make the rest of the file harmless. Keep uploads out of any directory a web server executes or serves directly, and re-encode images before showing them to other users.
Local disk or S3¶
Use local when every worker serving the application runs on one host
or mounts the same volume. Use s3 (see Storage (S3)) when workers
run on several hosts without a shared volume, or on AWS Lambda, where
local disk does not outlive the execution environment. Application code
is identical for both; only configuration changes.
Both can run side by side. 'default' reads the plain FILESYSTEM_*
keys, and any other connection name reads FILESYSTEM_{NAME}_*:
FILESYSTEM_BACKUPS_DRIVER=s3
FILESYSTEM_BACKUPS_S3_BUCKET=my-app-backups
FILESYSTEM_BACKUPS_S3_REGION=eu-west-1
use Kinetis\Storage\FilesystemFactory;
$backups = FilesystemFactory::fromConfig($config, 'backups');
A named connection is never autowired. Register it once in
bootstrap.php and keep it for the application’s lifetime rather than
building one per request — Appendix: Configuration Reference shows the
registration.
Rules that affect application code¶
Paths stay inside the root. A path that climbs above the root throws
League\Flysystem\PathTraversalDetected, a control character throwsCorruptedPathDetected, and a symlink below the root throwsSymbolicLinkEncountered(fileExists()anddirectoryExists()answerfalseinstead); in each case the operation does not run. An empty key names the root itself, and writing to it throwsUnableToWriteFile. The root is a hard boundary only while this adapter is the sole writer to the tree: a link another writer creates during an operation is not detected.Local writes publish complete files.
write(),writeStream()andcopy()build the content beside the destination and rename it into place, so a reader never sees a short file. Nothing isfsynced; a crash or power loss can leave either version.A failed write may have happened. An
UnableToWriteFileorUnableToCopyFileraised by that final rename can follow a rename that took effect, so the destination may hold either file. Retry only where this code is the sole writer to that path. S3 requests carry the same uncertainty.deleteDirectory()is not atomic. On local disk a symlink anywhere in the tree stops it before anything is deleted; any other failure can leave the tree partially deleted.Prefer
read()andwrite().readStream()buffers the whole file before returning, andwriteStream()reads the caller’s resource on the calling thread without closing it. Both block the thread on disk I/O.One exception family. Every failure implements
League\Flysystem\FilesystemException. Each operation reports its ownUnableTo*type, and a refused path keeps its own type.
Common errors¶
Error |
Cause |
Fix |
|---|---|---|
The container cannot resolve |
|
Set |
|
The local driver has no root |
Set |
|
|
Set a non-empty path; |
|
Unknown |
Use |
|
|
|
|
A path built from request input |
Generate storage keys on the server. |
|
A symlink inside |
Replace the link with a real directory. The configured root itself may be a symlink. |
|
An empty or root-only key |
Build the key before writing. |
See also¶
Appendix: Storage Reference — publication, path and symlink contracts, the per-operation exception table, and S3 failure semantics.
Storage (S3) — the S3 driver.
Routing & Validation —
UploadedFileInterfacebinding and the#[FileSize]and#[FileExtension]rules.Appendix: Configuration Reference — every
FILESYSTEM_*key and the named-connection convention.