Storage — Developer Guide
Capacity is module-entitlement metadata, not a Plans/Features/limits subsystem. Allowance is derived from entitled catalog slugs in config/storage.php.
Components
| Piece | Role |
|---|---|
config/storage.php | Free bytes + pack slug → bytes map + upload policy platform caps |
App\Services\Storage\WorkspaceStorageService | Allowance, used bytes, assertCanStore, pack exclusivity |
App\Services\Storage\WorkspaceUploadPolicy | Per-file type/size policy from storage.upload_policy |
App\Support\StorageUploadPolicy | Defaults + normalize for the tenant setting |
FileUploadService | Disk store/delete/url (unchanged) |
GET /api/tenant/v1/storage/usage | Settings usage summary + upload_policy client hints (module:storage + storage.view) |
Catalog
| Slug | Billable | Role |
|---|---|---|
storage | No | Free base — 1 GiB |
storage-10 … storage-1000 | Yes | Mutually exclusive capacity packs (total allowance) |
Hard dependency: every pack → storage (module_dependencies). Permissions: storage.view, storage.manage (upload policy edits). Catalog version 1.1.0 adds upload policy settings.
Allowance resolution
- If any active
storage-*pack is entitled → that pack’s bytes - Else if
storageentitled →free_bytes(1 GiB) - Else →
0→STORAGE_REQUIRED
Enforcement points
Call WorkspaceUploadPolicy::assertAllowed (or channel-specific variants) then WorkspaceStorageService::assertCanStore($tenant, $incomingBytes) before storing on content uploads (Chat, Expenses, Help Desk, Documents, Feedback, Knowledge Base, Lead imports, WhatsApp, Tasks).
Do not gate UserAvatarService or branding uploads in TenantSettingService.
Team Chat install (provisionModuleWorkspace) companion-installs free storage when missing. Grandfather migrations cover existing workspaces (Team Chat–scoped, then expanded to all tenants).
Used bytes
Sum of:
chat_message_attachments.size_bytesfeedback_attachments.size_bytesexpense_attachments.size_bytes- Help Desk ticket + note attachments
- Knowledge Base article attachments
documents.size_bytes(non-trashed Documents module rows)whatsapp_message_attachments.size_bytes(WhatsApp Cloud media)task_attachments.size_bytes+task_note_attachments.size_byteslead_imports.file_size- Disk size of import
error_report_path/failed_records_pathwhen present
Branding and avatars use separate disks and are never counted. Soft-deleted Documents are excluded from the sum until restored.
Pack exclusivity
ModuleSubscriptionService::install calls assertCanInstallPack (lazy-resolved to avoid DI cycles). Rejects a second pack while another is active / trial / pending with STORAGE_PACK_CONFLICT.
Upgrade UX: cancel current pack → purchase new pack (existing Marketplace flows).
Marketplace module detail also returns active_storage_pack and storage_pack_conflict for pack SKUs.
Catalog registration
Migrate-only via DefaultModuleRegistrar::ensureModule:
2026_08_13_220700_register_storage_modules2026_08_13_220710_add_storage_pack_dependencies2026_08_13_220720_add_storage_permissions2026_08_13_220730_grandfather_storage_for_team_chat_workspaces2026_08_13_233733_*(expand grandfather to all workspaces)
Mirror rows + dependencies in CatalogSeeder for local/CI.
Disks
See object-storage.md. Production: content on FILESYSTEM_UPLOADS_DISK=s3 (Wasabi); branding/avatars on public VPS disk.
Tests
- Pest:
tests/Feature/Storage/WorkspaceStorageTest.php - Playwright:
npm run test:e2e:storage(Storage tab gated without entitlement)