phpdot/storage
Composer 安装命令:
composer require phpdot/storage
包简介
Coroutine-safe file storage for the PHPdot ecosystem: local and S3-compatible (AWS S3, Cloudflare R2, MinIO) over league/flysystem, with validated uploads, optional file metadata, drafts and soft-delete.
README 文档
README
Coroutine-safe file storage for the PHPdot ecosystem. Inject DiskInterface to read, write, stream, list, and sign URLs on a local directory or an S3-compatible bucket (AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces) — or inject FilesInterface to turn uploads into validated, tracked records with a draft lifecycle and audit-safe deletes. Backends are chosen by binding the interface in your container; there are no config files and no named disks.
$path = $disk->putFile('avatars', $request->file('avatar')); // raw bytes $record = $files->upload($request->file('avatar'))->store(); // tracked record
Disks are a thin layer over league/flysystem, fenced behind the Disk base class so every Flysystem failure surfaces as a StorageException and no Flysystem type leaks into your code. Under Swoole, the S3 disk talks through a coroutine HTTP handler that opens one connection per request.
Contents
- Install
- Quick Start
- Why phpdot/storage
- Architecture
- Choosing the Disk
- The Disk API
- Uploads
- Managed Files
- DI Wiring
- Development
Install
composer require phpdot/storage
| Requirement | Version |
|---|---|
| PHP | >= 8.3 |
| ext-fileinfo | * |
| league/flysystem | ^3.0 |
| league/flysystem-local | ^3.0 |
| league/flysystem-aws-s3-v3 | ^3.0 |
| phpdot/package | optional — auto-binds the services via attribute scanning (with phpdot/container) |
| phpdot/console | optional — provides the storage:purge-drafts command |
The AWS SDK and Guzzle arrive with the S3 adapter, so cloud storage works out of the box.
Quick Start
use PHPdot\Storage\Driver\LocalDisk; // In an app you inject DiskInterface — see "DI Wiring". Wired by hand here: $disk = new LocalDisk(__DIR__ . '/storage'); $disk->put('notes/hello.txt', 'Hi there'); echo $disk->get('notes/hello.txt'); // 'Hi there' $disk->copy('notes/hello.txt', 'notes/copy.txt'); foreach ($disk->files('notes') as $path) { echo $path; // 'notes/hello.txt', 'notes/copy.txt' }
Why phpdot/storage
- Swap the backend by binding. One
DiskInterfaceis bound — aLocalDiskby default. Rebind it to anS3Diskin your container to point at the cloud, and every consumer is untouched. The same model the cache package uses to switch its driver. - Local and the cloud.
LocalDiskfor a directory;S3Diskfor AWS S3, Cloudflare R2, MinIO or DigitalOcean Spaces — the cloud adapter ships in the box. - Uploads become records.
FilesInterfacevalidates an upload, stores its bytes, and persists aFileRecord— with a draft → published lifecycle, ownership, search, and soft-delete. The metadata store is aFileRepositoryInterfaceyou bind; the default keeps the package database-free. - Coroutine-safe under Swoole. A disk is a stateless singleton built without I/O. The S3 disk uses a Swoole HTTP handler that opens a fresh connection per request, so concurrent calls stay isolated.
- One dependency, well-fenced. league/flysystem and the AWS SDK live only inside the
Diskbase andS3Disk. Every Flysystem failure becomes aStorageException. - Strict.
declare(strict_types=1)throughout, PHPStan level 10 with strict rules, zero ignored errors.
Architecture
src/
├── Contract/
│ ├── DiskInterface.php the bytes API — read · write · stream · upload · list · url
│ ├── FilesInterface.php the managed-files API — validate-and-store, lifecycle, search
│ ├── FileRepositoryInterface.php metadata persistence (you implement; null by default)
│ └── FileValidatorInterface.php one upload-validation rule
├── Disk.php abstract base: every disk operation, over a flysystem Filesystem
├── Driver/
│ ├── LocalDisk.php #[Singleton] #[Binds(DiskInterface)] — the default; a local directory
│ ├── S3Disk.php S3-compatible (AWS S3, R2, MinIO, Spaces) — the only AWS-SDK boundary
│ └── Http/CoroutineHandler.php a Swoole coroutine transport for the AWS SDK
├── Files.php #[Singleton] #[Binds(FilesInterface)] — bytes + metadata + lifecycle
├── UploadBuilder.php the fluent upload returned by Files::upload()
├── UploadedFile.php a multipart upload — Swoole's $request->files entry
├── Repository/
│ └── NullFileRepository.php #[Binds(FileRepositoryInterface)] — the no-op default (no database)
├── Validation/ MimeType · FileSize · Extension · ImageDimensions · ValidatorPipeline
├── Path/
│ └── PathGenerator.php filename patterns for ->name() — e.g. {date}/{uuid}.{ext}
├── Command/
│ └── PurgeExpiredDraftsCommand.php storage:purge-drafts
├── Value/
│ ├── Attributes.php one listing entry — a file or a directory
│ ├── FileRecord.php a stored file's metadata
│ ├── FileFilter.php search criteria
│ └── FilePage.php a page of records
└── Exception/
├── StorageException.php base — catch this for anything from the package
└── ValidationException.php an upload failed validation (carries the rule failures)
Two layers, one package: inject DiskInterface for raw bytes (the bound disk — LocalDisk unless you rebind it), or FilesInterface for managed files, which sits on a disk plus a FileRepositoryInterface. flysystem and the AWS SDK never escape Disk / S3Disk; under Swoole the S3 client is handed the CoroutineHandler.
Choosing the Disk
The default. DiskInterface resolves to a LocalDisk rooted at ./storage. Inject it and go.
Configure the local disk — rebind it with your own root and public URL:
use PHPdot\Storage\Contract\DiskInterface; use PHPdot\Storage\Driver\LocalDisk; // in your app's container bindings DiskInterface::class => fn () => new LocalDisk( root: __DIR__ . '/../storage/app', publicUrl: env('APP_URL') . '/storage', ),
Use the cloud — bind an S3Disk instead. Nothing else in your code changes:
use PHPdot\Storage\Driver\S3Disk; // AWS S3 DiskInterface::class => fn () => new S3Disk( bucket: env('AWS_BUCKET'), region: env('AWS_REGION', 'us-east-1'), key: env('AWS_ACCESS_KEY_ID'), secret: env('AWS_SECRET_ACCESS_KEY'), ), // Cloudflare R2 DiskInterface::class => fn () => new S3Disk( bucket: env('R2_BUCKET'), region: 'auto', endpoint: env('R2_ENDPOINT'), // https://<account>.r2.cloudflarestorage.com key: env('R2_ACCESS_KEY_ID'), secret: env('R2_SECRET_ACCESS_KEY'), pathStyle: true, ),
S3Disk parameters:
| Parameter | |
|---|---|
bucket |
Bucket name. Required. |
region |
Defaults to 'us-east-1'; Cloudflare R2 uses 'auto'. |
endpoint |
Custom endpoint for any S3-compatible store (R2, MinIO, Spaces). |
key · secret |
Static credentials. Omit both to use the AWS default chain (env vars, IAM role). |
prefix |
Key prefix applied to every path on the disk. |
pathStyle |
true for path-style stores such as MinIO. |
visibility · publicUrl |
Default object ACL, and a base URL (CDN or public bucket) for url(). |
requestChecksum · responseChecksum |
'when_supported' or 'when_required'. Defaults to 'when_required' when an endpoint is set. |
S3-compatible stores often reject the CRC32 request checksums the AWS SDK sends by default, so a custom endpoint defaults both checksum modes to when_required; pass either parameter to override. When the Swoole extension is loaded, S3Disk uses a coroutine HTTP handler automatically — one connection per request, no configuration — and the SDK's default transport everywhere else.
The Disk API
Inject DiskInterface and call any of:
Read
| Method | |
|---|---|
get(path): string |
The file's contents. |
readStream(path): resource |
A read stream — O(1) memory for large files. |
exists(path): bool · missing(path): bool |
Whether the file is (not) there. |
Write
| Method | |
|---|---|
put(path, contents): void |
Write a string, creating parent directories. |
writeStream(path, resource): void |
Write from a stream — O(1) memory. |
putFile(directory, UploadedFile, ?visibility): string |
Stream an upload in under a hashed name; returns the stored path. |
putFileAs(directory, UploadedFile, name, ?visibility): string |
Stream an upload in under an explicit name. |
delete(path): void |
Delete a file. |
copy(from, to): void · move(from, to): void |
Copy / move within the disk. |
Metadata
| Method | |
|---|---|
size(path): int |
Bytes. |
lastModified(path): int |
Unix timestamp. |
mimeType(path): string |
Detected MIME type. |
checksum(path): string |
Content checksum (MD5 by default on local disks). |
Visibility
| Method | |
|---|---|
visibility(path): string |
'public' or 'private'. |
setVisibility(path, visibility): void |
Set it explicitly. |
makePublic(path): void · makePrivate(path): void |
The two common cases. |
URLs
| Method | |
|---|---|
url(path): string |
A public URL. For local disks set publicUrl; for S3 it derives from the endpoint/bucket or publicUrl. |
temporaryUrl(path, DateTimeInterface $expiresAt): string |
A presigned, expiring URL. S3 supports it; local disks throw a StorageException. |
Directories & listing
| Method | |
|---|---|
files(path = '', deep = false): list<string> |
File paths under path. |
directories(path = '', deep = false): list<string> |
Subdirectory paths under path. |
list(path = '', deep = false): iterable<Attributes> |
A lazy listing of files and directories. |
makeDirectory(path): void · deleteDirectory(path): void |
Create / remove a directory. |
A missing file, a denied bucket, or an unreachable endpoint throws a StorageException carrying the underlying Flysystem error as its previous.
Transferring between disks. copy()/move() work within one disk. To move bytes between backends, hold both disks and stream:
$stream = $s3->readStream('invoices/2026.pdf'); $local->writeStream('invoices/2026.pdf', $stream); // download s3 → local fclose($stream);
Uploads
A multipart upload arrives as a Swoole temp file. Your HTTP layer wraps it in an UploadedFile (via UploadedFile::fromArray($request->files['avatar'])), and the disk streams it onto storage:
$path = $disk->putFile('avatars', $request->file('avatar')); // hashed name $path = $disk->putFileAs('avatars', $request->file('avatar'), 'me.png'); // explicit name $path = $disk->putFile('avatars', $request->file('avatar'), 'public'); // + visibility
An invalid upload (a non-zero PHP error code, or a vanished temp file) throws a StorageException before anything is written.
UploadedFile |
|
|---|---|
fromArray(array): self |
Build from a Swoole $request->files[...] entry. |
path() |
The temp path Swoole wrote to. |
clientName() · clientExtension() |
Original filename, and its lower-cased extension. |
clientMimeType() |
The client-supplied MIME type (advisory). |
detectedMimeType() |
The MIME type sniffed from the file's content (finfo); application/octet-stream when it can't be read. |
size() · error() · isValid() |
Byte size, the UPLOAD_ERR_* code, and whether the upload is usable. |
hashName() |
A random, collision-proof name that keeps the client extension. |
stream() |
A read stream over the temp file. |
list() yields Attributes value objects:
Attributes |
|
|---|---|
path |
Full path of the entry. |
isFile · isDirectory() |
Which kind of entry it is. |
fileSize |
Bytes for a file, null for a directory. |
lastModified |
Unix timestamp, or null. |
visibility |
'public', 'private', or null. |
Managed Files
Inject FilesInterface and an upload becomes a tracked FileRecord — validated, owned, searchable, with a draft lifecycle and soft-delete. The metadata lives behind a FileRepositoryInterface; the default NullFileRepository persists nothing, so the metadata features come alive once you bind a real repository.
use PHPdot\Storage\Validation\MimeTypeValidator; $record = $files->upload($request->file('avatar')) ->reference('user', $userId) // owner — so you can find it again ->tags(['kind' => 'avatar']) ->visibility('public') ->name('{date}/{uuid}.{ext}') // optional — default is a random hashed name ->validate(new MimeTypeValidator(['image/png', 'image/jpeg'])) ->asDraft('+24 hours') // omit → stored published ->store(); // validate → write bytes → persist record echo $record->id; // assigned by your repository
FilesInterface |
|
|---|---|
upload(UploadedFile): UploadBuilder |
Begin a fluent upload; ->store() returns a FileRecord. |
publish(id): FileRecord |
Promote a draft to permanent. |
delete(id): void · restore(id): FileRecord |
Soft-delete and reverse it. |
forceDelete(id): void |
Remove the bytes and the record permanently. |
find(id): ?FileRecord |
The record, or null when it's unknown or soft-deleted. |
search(FileFilter): FilePage |
Paginated records — e.g. one owner's drafts. |
url(id) · temporaryUrl(id, $expiresAt) |
Public URL, or signed by visibility. |
purgeExpiredDrafts(): int |
Hard-delete every expired draft. |
Drafts. Store an upload ->asDraft('+24 hours') to show a user their attachments before they submit, then publish() on submit. A draft that's never published lapses at its expiresAt; the storage:purge-drafts command (provided with phpdot/console) sweeps lapsed drafts — bytes and record — on a schedule.
Soft-delete. delete() moves the bytes to a fresh, secret, private path — so the live URL stops resolving — marks the record deletedAt (keeping its prior visibility for restore), and hides the file from find() and url(). restore() reverses it; forceDelete() removes the bytes and the record permanently. Id-based operations require a real repository; with the null default, use DiskInterface directly for raw bytes.
Validation runs before any bytes are written. Rules implement FileValidatorInterface; bind a base ValidatorPipeline for global rules and add per-upload rules with ->validate(...). A failure throws ValidationException carrying every rule failure.
| Validator | |
|---|---|
MimeTypeValidator(allowed) |
Allowed MIME types, checked against the file's real content (finfo). |
FileSizeValidator(maxBytes, minBytes = 0) |
Size bounds (maxBytes = 0 disables the ceiling). |
ExtensionValidator(allowed) |
Allowed, case-insensitive extensions. |
ImageDimensionsValidator(maxW, maxH, minW, minH) |
Pixel bounds; a non-image fails. |
Naming. A stored file gets a random hashed name by default. Pass ->name('{date}/{uuid}.{ext}') for a PathGenerator pattern. Placeholders: {name} {safeName} {ext}; dates {date} {today} {year} {month} {day} {hour} {minute} {second}; {uuid}; {random} / {random:16}; {hash}. Add your own with new PathGenerator($pattern, ['tenant' => fn () => …]).
The record. FileRecord is immutable: id · path · originalName · mimeType · size · checksum · visibility · originalVisibility · reference · referenceId · tags · isDraft · expiresAt · createdAt · deletedAt. Your repository maps it to and from your store.
The repository. Implement FileRepositoryInterface (save · find · delete · search · expiredDrafts) against MongoDB (phpdot/mongodb), SQL (phpdot/database), or anything, and bind it:
use PHPdot\Storage\Contract\FileRepositoryInterface; FileRepositoryInterface::class => fn () => new MongoFileRepository(/* ... */),
DI Wiring
With phpdot/package the bindings are auto-discovered: LocalDisk → DiskInterface, Files → FilesInterface, and NullFileRepository → FileRepositoryInterface, all as #[Singleton]s — so injecting any contract works with no setup. Rebind DiskInterface (see Choosing the Disk) for a different root or the cloud, and rebind FileRepositoryInterface to enable persistence; S3Disk and your repository carry no attributes, so you wire them on purpose.
use PHPdot\Storage\Contract\FilesInterface; use PHPdot\Storage\UploadedFile; final class AvatarController { public function __construct( private readonly FilesInterface $files, ) {} public function upload(UploadedFile $avatar, int $userId): string { $record = $this->files->upload($avatar) ->reference('user', $userId) ->validate(new MimeTypeValidator(['image/png', 'image/jpeg'])) ->store(); return (string) $record->id; } }
Without phpdot/container the classes are plain PHP — construct LocalDisk/S3Disk, a Files, and your repository directly.
Development
composer test # PHPUnit composer analyse # PHPStan level 10, strict rules composer cs-check # PHP-CS-Fixer (dry run) composer check # all three
The Swoole CoroutineHandler test runs when the Swoole extension is present; it skips otherwise.
License
MIT — see LICENSE.
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 2
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-06-13