PHPackages                             phpdot/storage - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [File &amp; Storage](/categories/file-storage)
4. /
5. phpdot/storage

ActiveLibrary[File &amp; Storage](/categories/file-storage)

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.

00PHP

Since Jun 13Pushed todayCompare

[ Source](https://github.com/phpdot/storage)[ Packagist](https://packagist.org/packages/phpdot/storage)[ RSS](/packages/phpdot-storage/feed)WikiDiscussions main Synced today

READMEChangelogDependenciesVersions (1)Used By (0)

phpdot/storage
==============

[](#phpdotstorage)

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`](https://flysystem.thephpleague.com/), 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
--------

[](#contents)

- [Install](#install)
- [Quick Start](#quick-start)
- [Why phpdot/storage](#why-phpdotstorage)
- [Architecture](#architecture)
- [Choosing the Disk](#choosing-the-disk)
- [The Disk API](#the-disk-api)
- [Uploads](#uploads)
- [Managed Files](#managed-files)
- [DI Wiring](#di-wiring)
- [Development](#development)

Install
-------

[](#install)

```
composer require phpdot/storage
```

RequirementVersionPHP&gt;= 8.3ext-fileinfo\*league/flysystem^3.0league/flysystem-local^3.0league/flysystem-aws-s3-v3^3.0phpdot/packageoptional — auto-binds the services via attribute scanning (with phpdot/container)phpdot/consoleoptional — provides the `storage:purge-drafts` commandThe AWS SDK and Guzzle arrive with the S3 adapter, so cloud storage works out of the box.

Quick Start
-----------

[](#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
------------------

[](#why-phpdotstorage)

- **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
------------

[](#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
-----------------

[](#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://.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
------------

[](#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 &amp; listing**

Method`files(path = '', deep = false): list`File paths under `path`.`directories(path = '', deep = false): list`Subdirectory paths under `path`.`list(path = '', deep = false): iterable`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
-------

[](#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
-------------

[](#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
---------

[](#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](#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
-----------

[](#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
-------

[](#license)

MIT — see [LICENSE](LICENSE).

###  Health Score

20

—

LowBetter than 13% of packages

Maintenance65

Regular maintenance activity

Popularity0

Limited adoption so far

Community2

Small or concentrated contributor base

Maturity11

Early-stage or recently created project

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

### Community

Maintainers

![](https://www.gravatar.com/avatar/62e82421bda4b5d6ba9a47ba6d88caca060dcd0d1a2862f351f3a97657385db0?d=identicon)[phpdot](/maintainers/phpdot)

### Embed Badge

![Health badge](/badges/phpdot-storage/health.svg)

```
[![Health](https://phpackages.com/badges/phpdot-storage/health.svg)](https://phpackages.com/packages/phpdot-storage)
```

###  Alternatives

[glicer/sync-sftp

Sync local files with ftp server

212.7k](/packages/glicer-sync-sftp)[venveo/craft-compress

Create smart zip files from Craft assets on the fly

124.7k](/packages/venveo-craft-compress)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
