phpdot/storage 问题修复 & 功能扩展

解决BUG、新增功能、兼容多环境部署,快速响应你的开发需求

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

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

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 DiskInterface is bound — a LocalDisk by default. Rebind it to an S3Disk in 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. LocalDisk for a directory; S3Disk for AWS S3, Cloudflare R2, MinIO or DigitalOcean Spaces — the cloud adapter ships in the box.
  • Uploads become records. FilesInterface validates an upload, stores its bytes, and persists a FileRecord — with a draft → published lifecycle, ownership, search, and soft-delete. The metadata store is a FileRepositoryInterface you 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 Disk base and S3Disk. Every Flysystem failure becomes a StorageException.
  • 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: LocalDiskDiskInterface, FilesFilesInterface, and NullFileRepositoryFileRepositoryInterface, 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

GitHub 信息

  • Stars: 0
  • Watchers: 0
  • Forks: 0
  • 开发语言: PHP

其他信息

  • 授权协议: MIT
  • 更新时间: 2026-06-13

承接程序开发

PHP开发

VUE

Vue开发

前端开发

小程序开发

公众号开发

系统定制

数据库设计

云部署

网站建设

安全加固