← Laracrate 14 / 17

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 .env and the base disk config. Each tenant bucket only sets bucket (and optionally public_url); everything else (key, secret, endpoint, region, driver) is inherited from base_disk.
  • BYOA, tenant brings their own account. Put the tenant's key, secret, endpoint, region, and driver in credentials. 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.