PHPackages                             rasuvaeff/yii3-filestorage-db - 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. [Database &amp; ORM](/categories/database)
4. /
5. rasuvaeff/yii3-filestorage-db

ActiveLibrary[Database &amp; ORM](/categories/database)

rasuvaeff/yii3-filestorage-db
=============================

Database metadata backend for rasuvaeff/yii3-filestorage: repository, tenant scoping and the deduplication blob ledger

v0.1.0(today)00BSD-3-ClausePHPPHP 8.3 - 8.5CI passing

Since Aug 8Pushed todayCompare

[ Source](https://github.com/rasuvaeff/yii3-filestorage-db)[ Packagist](https://packagist.org/packages/rasuvaeff/yii3-filestorage-db)[ Docs](https://github.com/rasuvaeff/yii3-filestorage-db)[ RSS](/packages/rasuvaeff-yii3-filestorage-db/feed)WikiDiscussions master Synced today

READMEChangelog (1)Dependencies (24)Versions (2)Used By (0)

rasuvaeff/yii3-filestorage-db
=============================

[](#rasuvaeffyii3-filestorage-db)

[![Latest Stable Version](https://camo.githubusercontent.com/a1ddf8fca774edd0b7e9a06d1cebb13c4d5d6b267486d9c2f0f1ac4698f9e659/68747470733a2f2f706f7365722e707567782e6f72672f7261737576616566662f796969332d66696c6573746f726167652d64622f76)](https://packagist.org/packages/rasuvaeff/yii3-filestorage-db)[![Total Downloads](https://camo.githubusercontent.com/92c6e42b355c7b816b5ee7c7c346d4545a32fbfa50015c82f1998dc34373e4cd/68747470733a2f2f706f7365722e707567782e6f72672f7261737576616566662f796969332d66696c6573746f726167652d64622f646f776e6c6f616473)](https://packagist.org/packages/rasuvaeff/yii3-filestorage-db)[![Build](https://github.com/rasuvaeff/yii3-filestorage-db/actions/workflows/build.yml/badge.svg)](https://github.com/rasuvaeff/yii3-filestorage-db/actions/workflows/build.yml)[![Static analysis](https://github.com/rasuvaeff/yii3-filestorage-db/actions/workflows/static-analysis.yml/badge.svg)](https://github.com/rasuvaeff/yii3-filestorage-db/actions/workflows/static-analysis.yml)[![Psalm level](https://camo.githubusercontent.com/68f7f31799f2b93c710b14ba3877072e7fe07ec9d7cee3fdf67e14beab3e1b6f/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7073616c6d2d6c6576656c5f312d626c75652e737667)](https://github.com/rasuvaeff/yii3-filestorage-db/actions/workflows/static-analysis.yml)[![PHP](https://camo.githubusercontent.com/69e4de21755adbc5f5f062c2cb115900480e5ae73445c6af5335580d564386e9/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f646570656e64656e63792d762f7261737576616566662f796969332d66696c6573746f726167652d64622f706870)](https://packagist.org/packages/rasuvaeff/yii3-filestorage-db)[![License](https://camo.githubusercontent.com/6cb285b57819f8de0acfb34923298f4f569f962544e8fe35331da2d163f4e485/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4253442d2d332d2d436c617573652d626c75652e737667)](LICENSE.md)[Русская версия](README.ru.md)

The metadata half of [`rasuvaeff/yii3-filestorage`](https://github.com/rasuvaeff/yii3-filestorage): file records in a database table, optional tenant scoping that no code path can switch off, and the ledger that makes deduplicated bytes safe to delete.

> Using an AI coding assistant? [llms.txt](llms.txt) contains a compact API reference you can share with the model. Projects using the [llm/skills](https://github.com/roxblnfk/skills) Composer plugin also get this package's agent skill synced into `.agents/skills/` automatically on install.

**Status: `0.x`.** The API may still change while the Flysystem and web packages are built against it.

Requirements
------------

[](#requirements)

- PHP 8.3+
- `ext-json`
- `rasuvaeff/yii3-filestorage` ^0.1
- `yiisoft/db` ^2.0 (plus a driver: `yiisoft/db-sqlite`, `-mysql`, `-pgsql`, …)
- `symfony/console` ^6.4 || ^7.0 || ^8.0, `psr/http-factory` ^1.0 and `psr/http-message` ^2.0 — for `filestorage:deduplicate`, which reads objects back through the store
- `yiisoft/db-migration` ^2.1 — **not** `^2.0`, see [Migrations](#migrations)

Installation
------------

[](#installation)

```
composer require rasuvaeff/yii3-filestorage-db
```

This package binds `RepositoryInterface`, `MaintenanceRepositoryInterface`, `ScopedFileResolverInterface` and `BlobLedgerInterface`. Core binds the facade. You still bind a `StoreInterface` — a local `FileSystemStore`, or `rasuvaeff/yii3-filestorage-flysystem` for S3 and friends.

Migrations
----------

[](#migrations)

Register by namespace:

```
// config/common/di/db-migration.php
use Yiisoft\Db\Migration\Service\MigrationService;

return [
    MigrationService::class => [
        'setSourceNamespaces()' => [['Rasuvaeff\\Yii3FilestorageDb\\Migration']],
    ],
];
```

```
./yii migrate:up
```

Three tables: `filestorage_file`, `filestorage_blob` and `filestorage_blob_reservation`.

> **`yiisoft/db-migration` ^2.1 is a hard requirement.** On 2.0.x this exact registration silently found nothing: namespace resolution matched a *neighbouring* namespace as if it were a parent, landed in the core package's directory, and `migrate:up` printed "up-to-date", exited 0 and created no tables. 2.1.0 carries the fix ([\#350](https://github.com/yiisoft/db-migration/pull/350)), and this package's integration suite runs the recipe above on every CI build rather than trusting it.

Configuration
-------------

[](#configuration)

```
// config/common/params.php
return [
    'rasuvaeff/yii3-filestorage-db' => [
        'fileTable' => 'filestorage_file',
        'blobTable' => 'filestorage_blob',
        'blobReservationTable' => 'filestorage_blob_reservation',
        'tablePrefix' => '',
    ],
];
```

The prefix is prepended to all three. Names are validated by typed value objects — `FileTableName`, `BlobTableName`, `BlobReservationTableName` — which the migrations resolve from the container, so the schema and the queries cannot disagree about a name.

Tenant scoping
--------------

[](#tenant-scoping)

Bind `FileScopeProviderInterface` and every statement this package issues gains a `scope_id` predicate. Leave it unbound and the installation is single-tenant: no predicate, no cost.

```
// config/common/di/filestorage.php
use Rasuvaeff\Yii3Filestorage\Repository\FileScopeProviderInterface;

return [
    FileScopeProviderInterface::class => static fn (TenantContext $tenants): FileScopeProviderInterface
        => new class ($tenants) implements FileScopeProviderInterface {
            public function __construct(private TenantContext $tenants) {}

            public function currentScopeId(): ?string
            {
                return $this->tenants->currentId();
            }
        },
];
```

Only your application knows what a tenant is — from `rasuvaeff/yii3-tenancy`, a session, a subdomain — so this package cannot bind it for you without being wrong in every installation that does it differently.

**There is no method that skips the predicate.** Not for downloads, not for maintenance. A signed download has no ambient tenant, which is the point of signing it, and the shortcut everyone reaches for — looking up by id with the filter off — reads any file whose id leaks. Use `ScopedFileResolverInterface`instead: the scope was authenticated when the token was minted, travels inside the HMAC, and is matched as a second predicate.

```
$file = $resolver->findInScope($payload->fileId, $payload->scopeId);
```

`null` is a scope, not the absence of one: it matches `scope_id IS NULL`, which is what an unscoped application's rows carry. An unscoped token is never a wildcard.

Deduplication
-------------

[](#deduplication)

Two people upload the same file. One object on disk, two rows in the database — and the object must not disappear when the first row does. `BlobLedgerInterface`is how that is coordinated; this package is the transactional implementation.

```
$blob = BlobId::create('upload', $contentAddressedPath);

$reservation = $ledger->reserve($blob, $sha256, $size, $expiresAt);  // claim the key
$store->putIfAbsent($upload, $blob->object);                          // publish the bytes
$ledger->commit($reservation, $file);                                 // row + reference, one transaction
```

On any failure between steps, `release($reservation, $deleteAfter)`. Removal is `releaseFile($fileId, $deleteAfter)`.

RuleWhyShared bytes are **never** deleted inside a requestAn object deleted mid-request is one some concurrent add has already decided to reuse. The last release only marks a blob `pending_delete`Only a collector deletes, and only under a leaseExclusive and expiring, so a worker that dies mid-delete is recovered by the next one stealing the lease rather than blocking foreverEvery guard is in the statement that actsA `SELECT` then an `UPDATE` is two moments with a gap, and the gap is where a committed row ends up pointing at deleted bytesOwnership is a `BlobId`, never a content hashA hash is identical across stores, groups and tenants, so a hash-keyed count lets one tenant delete what another is readingThere is no `reference_count` columnA reference *is* a file row with this blob's id, and the guards ask `NOT EXISTS`. A counter is a second source of truth that can drift, and every underflow guard only detects drift after it happensStates are `writing`, `active`, `pending_delete` and `deleting`. A writer may join any of them except the last, where it gets a `BlobBusyException` and retries after the lease ends.

The collection pass:

```
$ledger->expireReservations($now, $deleteAfter);         // sweep abandoned writers first

while ($lease = $ledger->claimForDeletion($now, $now->add($leaseTtl))) {
    try {
        $store->deleteObject($lease->blob->object);
        $ledger->completeDeletion($lease);               // refuses if the blob was revived
    } catch (StoreException) {
        $ledger->abandonDeletion($lease, $retryAfter);   // back off, keep the row
    }
}
```

### Turning it on

[](#turning-it-on)

The protocol above is driven for you by `DeduplicatingStorage`. It is **not**bound by this package: replacing `StorageInterface` is a root-application decision, because core owns that key and two vendor packages claiming one is a `yiisoft/config` `Duplicate key` error by design. So the application opts in:

```
// config/common/di/filestorage.php
use Rasuvaeff\Yii3Filestorage\StorageInterface;
use Rasuvaeff\Yii3FilestorageDb\DedupScope;
use Rasuvaeff\Yii3FilestorageDb\DeduplicatingStorageFactory;

return [
    StorageInterface::class => static fn (
        DeduplicatingStorageFactory $factory,
    ): StorageInterface => $factory->create(
        scope: DedupScope::TenantGroup,
        dedupMaxBytes: 104_857_600,
        deleteGracePeriod: new DateInterval('PT1H'),
    ),
];
```

That override points `StorageInterface` at a facade the factory *builds*, so the factory cannot ask for `StorageInterface` to get the plain one — it would be handed the object it is in the middle of constructing. It asks core for `Storage::class` instead, the concrete id core binds alongside the interface for exactly this. Nothing to configure; it is why this package requires core `^0.1.1`, and the same rule applies to any decorator you write yourself.

`create()` refuses three configurations that would otherwise lose data quietly, and says what to do instead: a store that does not implement `ContentAddressableStoreInterface`; a `BlobLedgerInterface` that is not this package's `DbBlobLedger`, because the guards deduplication rests on are SQL predicates inside the statements that act, and an implementation that keeps its state anywhere else cannot make the same promise; and a ledger on a different connection than the repository — which makes `commit()` two transactions rather than one, so a crash between them leaves a row with no reference or a reference with no row.

Everything the consumer sees is unchanged. `add()` still returns a `File` with its own id, group, description and metadata; two uploads of the same bytes just end up pointing at one object. Above `dedupMaxBytes` an upload silently takes the unique path — the content key *is* the hash, so it cannot be chosen without reading the whole body, and that read is not worth an unbounded second pass.

### How widely bytes are shared

[](#how-widely-bytes-are-shared)

`DedupScope` is a security choice, not a space/CPU dial:

ScopePoolWhen`TenantGroup` *(default)*per tenant, per groupAlways safe. The only one appropriate for untrusted tenants`Tenant`per tenantOne tenant's groups share; tenants stay apart`Global`everythingSaves the most space, and **discloses content existence across tenants**The disclosure is real: whoever uploads a file learns from the write timing, and from a quota that does not move, whether that exact content already existed. Between one tenant's own files that says nothing new. Across tenants it is an oracle — upload a suspected document, see whether it was "already there".

### Enabling it on data that already exists

[](#enabling-it-on-data-that-already-exists)

`filestorage:backfill-hash` (core) computes integrity hashes and stops there — it deliberately does not pretend that existing random paths became shared. Moving them is `filestorage:deduplicate`, which this package ships:

```
./yii filestorage:deduplicate                      # a report, changes nothing
./yii filestorage:deduplicate --apply --limit=500
./yii filestorage:deduplicate --apply --limit=500 --after=
./yii filestorage:gc --orphans --apply             # reclaim what the rows used to point at
```

OptionDefaultNotes`--apply`offWithout it nothing is written`--scope``tenant-group`**Must match** what the application passes to `create()`. A mismatch lands every migrated row on a key no future upload will ever join`--store`the default storeMust implement `ContentAddressableStoreInterface`, or the command refuses before reading anything`--after` / `--limit`— / 1000Cursor and batch size. The command prints the last id it reached`--max-bytes`104857600Rows above it keep their unique object, exactly as a live `add()` wouldPer row it reads the object, hashes it, reserves the content key, publishes with `putIfAbsent()`, and repoints the row through `commit()` — the same protocol a live `add()` uses, so a crash anywhere leaves either the old row or the new one, never a row pointing at nothing. A second run over the same range is a no-op: a row already at its content key is recognised and skipped.

**The old object is left behind on purpose.** It becomes an orphan the moment its row is repointed, and `filestorage:gc --orphans --apply` reclaims it once in-flight reads have drained. Deleting it inside the migration would race the readers still holding the old path.

**The tenant scope is the ambient one.** Rows come from `DbRepository`, which filters by whatever `FileScopeProviderInterface` reports, and the content key mixes that same scope in — so a multi-tenant installation runs this once per tenant, with the tenant established the way its other CLI jobs establish it. The scope in force is printed before any work starts.

**Under tenancy the reclaim step moves.** `filestorage:gc --orphans` refuses while a `FileScopeProviderInterface` is bound — a tenant-filtered set of rows cannot prove an object unreferenced, so the sweep would classify every other tenant's objects as orphans. Run the migration once per tenant as above, then the sweep **once**, from a maintenance entry point that leaves the scope provider unbound. The command says so in its closing line rather than leaving you to discover it at the refusal.

Maintenance
-----------

[](#maintenance)

`DbRepository` implements `MaintenanceRepositoryInterface`, so a long job pages by id and resumes where it stopped:

```
$afterId = null;
do {
    $page = iterator_to_array($repository->files($afterId, limit: 500), false);
    foreach ($page as $file) {
        // …
        $afterId = $file->id;
    }
} while ($page !== []);
```

The cursor keeps the tenant predicate.

Storage format
--------------

[](#storage-format)

DecisionWhyTimestamps are `Y-m-d\TH:i:s.uP` text, normalised to UTCMicroseconds survive (a truncating column would break `File`'s round-trip contract), and the ledger compares deadlines as strings — which only agrees with chronological order when every row carries the same offset`metadata` is JSON in a text columnThe shape is a flat `array` and nothing queries inside it, so a native JSON column would buy nothing and cost portability`save()` is a scoped update falling through to an insert, not an upsertAn upsert matches on the primary key alone, so another tenant's row would be silently taken over. Ids are unguessable, but "unguessable" is not a boundaryA malformed row is an exception, not a coerced `File``InvalidFileRowException`. Coercing until `File::create()` stops refusing turns a corrupted row into a plausible record pointing at the wrong objectThe blob primary key is `sha256(store:path)`The pair itself exceeds MySQL's index key length at a 512-character path, and a derived key makes two writers racing to create one blob collide on the primary key instead of producing two rowsExamples
--------

[](#examples)

Runnable, self-contained, no server needed — see [`examples/`](examples/).

Development
-----------

[](#development)

No PHP or Composer on the host; everything runs in Docker.

```
make build            # validate, normalize, require-checker, cs, psalm, test
make test-integration # the documented migration recipe, executed
make cs-fix
make mutation
make release-check
```

License
-------

[](#license)

BSD-3-Clause. See [LICENSE.md](LICENSE.md).

###  Health Score

38

—

LowBetter than 83% of packages

Maintenance100

Actively maintained with recent releases

Popularity0

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity40

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

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.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

0d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/b0812d5572a7041dfe36e222d295b2e6dc55833a605350fcde58a51a5965ed30?d=identicon)[rasuvaeff](/maintainers/rasuvaeff)

---

Top Contributors

[![rasuvaeff](https://avatars.githubusercontent.com/u/1352718?v=4)](https://github.com/rasuvaeff "rasuvaeff (2 commits)")

---

Tags

databasededuplicationfilestoragemetadatamigrationsphptenantyii-dbyii3databasedbyii3file storagededuplication

###  Code Quality

Static AnalysisPsalm, Rector

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/rasuvaeff-yii3-filestorage-db/health.svg)

```
[![Health](https://phpackages.com/badges/rasuvaeff-yii3-filestorage-db/health.svg)](https://phpackages.com/packages/rasuvaeff-yii3-filestorage-db)
```

###  Alternatives

[flow-php/flow

PHP ETL - Extract Transform Load - Data processing framework

86337.5k](/packages/flow-php-flow)[tempest/framework

The PHP framework that gets out of your way.

2.3k37.6k19](/packages/tempest-framework)[symfony/symfony

The Symfony PHP framework

31.4k87.4M2.2k](/packages/symfony-symfony)[guzzlehttp/psr7

PSR-7 message implementation that also provides common utility methods

7.9k1.1B4.3k](/packages/guzzlehttp-psr7)[kreait/firebase-php

Firebase Admin SDK

2.4k46.0M90](/packages/kreait-firebase-php)[shopware/platform

The Shopware e-commerce core

3.4k1.5M3](/packages/shopware-platform)

PHPackages © 2026

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