Multi-tenancy
Multi-tenancy
Multi-tenancy, buckets and usage
Laracrate is multi-tenant aware out of the box. Every file carries a tenant_* morph (see the Data model section), so a single deployment can serve many organizations, workspaces, or accounts while keeping their files cleanly partitioned. On top of that scope you can give individual tenants their own storage bucket and measure exactly how much each one consumes, without writing a single hand-rolled SUM() query.
If you run a single-tenant app, leave tenant_* null and skip this section. Everything below is opt-in.
Scoping files to a tenant
The tenant morph is set automatically when you add a file through your model. How the tenant is resolved is up to you (it comes from resolveFileTenant() on the HasFiles trait, covered in the Working with files from your models section). Once files are scoped, you can query them with the forTenant scope on the File model:
use EduLazaro\Laracrate\Models\File; $files = File::forTenant($organization)->get();
The scope matches on tenant_type (the tenant's morph class) and tenant_id (its primary key).
Per-tenant buckets
By default all tenants share the disks you declare in config/filesystems.php. A tenant bucket overrides one of those disks for one tenant. This is the foundation for bring-your-own-account (BYOA) setups, data-residency or compliance requirements, and cost attribution, where a tenant's bytes land in a bucket you can bill or audit separately.
Granularity is per disk, not per collection. If your config exposes three disks (document, media, attachment), a tenant can activate a dedicated bucket for any subset of them independently. The disk you override is called the base_disk.
Declare a tenant bucket by creating a TenantBucket row:
use EduLazaro\Laracrate\Models\TenantBucket; TenantBucket::create([ 'tenant_type' => $organization->getMorphClass(), 'tenant_id' => $organization->getKey(), 'base_disk' => 'document', // the disk in filesystems.php this overrides 'bucket' => 'acme-documents', // bucket name that replaces base_disk's 'public_url' => 'https://cdn.acme.example', // optional, overrides the base disk url 'is_active' => true, 'label' => 'acme-documents (eu-west)', // optional, for your admin UI ]);
TenantBucket is stored in the laracrate_tenant_buckets table and exposes a tenant() morph relation back to the owning model. Its columns:
| Column | Type | Purpose |
|---|---|---|
tenant_type, tenant_id |
string, bigint | The tenant this bucket belongs to. |
base_disk |
string | The config/filesystems.php disk this row overrides. |
bucket |
string | Bucket name that replaces the one from base_disk. |
public_url |
string, nullable | Overrides the url of the base disk if set. |
credentials |
encrypted array, nullable | BYOA overrides for key, secret, endpoint, region, driver. Cast encrypted:array (APP_KEY). |
is_active |
boolean | Inactive buckets are not resolved and throw if referenced. |
label |
string, nullable | Human label for your admin UI. |
There is a unique constraint on (tenant_type, tenant_id, base_disk), so a tenant has at most one bucket per base disk.
Two deployment models are supported:
- SaaS, single account. Your R2/S3 credentials live in
.envand the base disk config. Each tenant bucket only setsbucket(and optionallypublic_url); everything else (key, secret, endpoint, region, driver) is inherited frombase_disk. - BYOA, tenant brings their own account. Put the tenant's
key,secret,endpoint,region, anddriverincredentials. It is stored encrypted with your APP_KEY and merged on top of the inherited config.
How a file picks up its bucket
When you add a file, the HasFiles trait calls resolveTenantBucketDisk(). If the resolved tenant has an active TenantBucket for the collection's base_disk, the file is stored with disk = "tb:{id}" (the bucket's id), otherwise it keeps the plain disk name. You never write tb:{id} by hand.
At read or write time, StorageManager::diskFor() (and the lower-level resolveDisk()) recognizes the tb: prefix, loads the TenantBucket, builds the config via TenantBucket::toDiskConfig(), and returns the right Storage::build() filesystem. The merge cascade is: base disk config, then bucket (and public_url as url), then the BYOA credentials overrides. Plain disk names fall straight through to Storage::disk(). Every internal operation (writeBinary, moveServerSide, batchDelete, presigned uploads, S3 client lookup) routes through this resolution, so dedicated buckets work everywhere transparently.
If a file references a bucket that no longer exists or has is_active = false, resolution throws a RuntimeException rather than silently falling back to the shared disk.
Usage accounting
To enforce quotas or bill by storage, use UsageReporter, resolved from the container. Each query is a single grouped SUM(size) / COUNT(*), so you get totals plus per-collection and per-type breakdowns without scanning rows in PHP.
use EduLazaro\Laracrate\Services\UsageReporter; $stats = app(UsageReporter::class)->forTenant($organization); $stats->human(); // "1.42 GB" $stats->totalFiles; // 312 $stats->byCollection['gallery']; // ['bytes' => 18234112, 'files' => 45] if ($stats->exceeds(5 * 1024 ** 3)) { // tenant is over its 5 GB quota }
UsageReporter has four entry points, each returning a UsageStats:
| Method | Scope |
|---|---|
forTenant(Model $tenant, bool $excludeTrashed = false) |
All files belonging to a tenant. |
forCreator(Model $creator, bool $excludeTrashed = false) |
All files created by a given model. |
forCollection(string $collection, ?Model $tenant = null, bool $excludeTrashed = false) |
One collection, optionally narrowed to a tenant. |
global(bool $excludeTrashed = false) |
Whole system. This can be a heavy scan on large tables, so run it offline or behind a cache. |
By default counts include variants and soft-deleted files, since both still occupy real bytes in the bucket. Pass excludeTrashed: true to drop soft-deleted files from the totals.
The UsageStats value object
UsageStats (EduLazaro\Laracrate\Support\UsageStats) is an immutable snapshot. Its readonly properties:
| Property | Type | Notes |
|---|---|---|
totalBytes |
int | Sum of size across the scope. |
totalFiles |
int | File count across the scope. |
byCollection |
array | ['gallery' => ['bytes' => int, 'files' => int], ...] |
byType |
array | ['image' => ['bytes' => int, 'files' => int], ...] |
And its methods:
| Method | Returns |
|---|---|
kilobytes() / megabytes() / gigabytes() |
float, total in that unit |
human(int $precision = 2) |
string like "1.42 GB", "234 MB", "12 KB" |
exceeds(int $quotaBytes) |
bool, true when over the quota |
remaining(int $quotaBytes) |
int bytes left, negative if over |
percentageOf(int $quotaBytes) |
float, 0 to 100+ |
toArray() |
array with total_bytes, total_files, by_collection, by_type |
Store your per-tenant limit however you like (a quota_bytes column on your tenant model is a common choice) and gate uploads with exceeds():
$stats = app(UsageReporter::class)->forTenant($organization); abort_if( $stats->exceeds($organization->quota_bytes), 403, 'Storage quota exceeded.' );
Cached usage counters and recompute
For collections where you want a live counter without aggregating on every request, Laracrate can maintain per-fileable totals (file count and byte size) in the laracrate_folderables table, kept up to date by the file observer. To enable this, set track_usage on the collection in config/laracrate.php.
Because observers can miss writes (manual imports, restores from backup, a failed event), the laracrate:recompute-usage command rebuilds those counters from the source of truth in laracrate_files. It is idempotent and safe to run on a schedule.
php artisan laracrate:recompute-usage # every collection with track_usage enabled php artisan laracrate:recompute-usage drive # only the "drive" collection php artisan laracrate:recompute-usage --dry-run # print deltas without writing
It aggregates top-level files (no parent_id) per (fileable_type, fileable_id), updates each laracrate_folderables row, stamps last_recomputed_at, and resets orphaned rows (files all gone) to zero. See the Artisan commands section for the full command list.