Storage & file handling
Where files live in the platform — private recordings, the streaming playback route, generated tenant branding CSS, and retention.
Storage & file handling
Files fall into two very different buckets: private business data (call recordings) that must never be publicly reachable, and generated public assets (per-tenant branding CSS) that browsers fetch anonymously. This page covers both, plus the retention job that cleans up.
Who this is for
Developers touching uploads/downloads, and hosting admins sizing/mounting storage on cPanel.
Difficulty: Medium · Time: about 10 minutes.
Private recordings (never public)
Mobile recordings are uploaded to the private local disk (Laravel's local disk rooted at storage/app/private, outside the web root):
- Upload:
POST /api/v1/mobile/recordings(multipart, device-token authenticated).- Max size 20 MB (
RecordingController::MAX_KB = 20480). - Content must be audio/* — server-side mime detection with a client-declared-mime fallback for containers PHP cannot sniff (e.g. some
.amr/.m4a). - The call is resolved within the requesting tenant (a call from another workspace 404s with
CALL_NOT_FOUND). - Excluded numbers are a hard backstop — audio for an excluded number is rejected (422
NUMBER_EXCLUDED). - Re-uploading for the same call is idempotent: the existing stored recording is returned with
duplicate: true.
- Max size 20 MB (
- Layout:
recordings/{tenant_id}/{lead_call_id}/…. - Playback:
GET /api/v1/mobile/recordings/{id}streams the file (RecordingController::show).- No public URL exists by design.
- The
CallRecordingmodel is tenant-scoped, so another tenant's id is a 404. - A purged/expired recording returns 410 GONE; a missing file returns 404
FILE_MISSING. - If transcription is configured for the workspace, a new upload dispatches
TranscribeCall(the same gate as the manual web action).
Retention
- Each recording gets
expires_at = now + 30 days(RETENTION_DAYS = 30). php artisan leadhub:purge-recordingsruns daily at 03:15 (see Queues, jobs & scheduler):- deletes files past expiry from the disk;
- keeps the metadata row, marked expired with
pathset to null, so call history/reporting stays intact; - supports
--dryto preview candidates.
Public branding CSS (generated per tenant)
The white-label theme is a generated stylesheet, not a database-driven runtime paint:
App\Services\SettingsServicewritespublic/tenant/{id}/branding.csswhen branding/settings change (public/ must be writable; a write failure is logged as a warning and never breaks saving).App\Http\Middleware\InjectTenantBrandinginjects the stylesheet into the tenant panel's pages (registered on the admin panel, including persistent/Livewire requests), with a?v=cache-buster from the file's mtime.- These files are public by design — they contain only colors/theme tokens, never secrets.
public/tenant and public/storage must be writable On cPanel installs set permissions so the web user can write public/tenant/ (branding generation) and create the public/storage symlink.
The public storage symlink
Some uploaded assets are served through /storage/..., which requires public/storage → storage/app/public:
- Super Admin → System (
App\Filament\SuperAdmin\Pages\SystemHealth) has a storage link action that runsphp artisan storage:linkin-app and reports success/already-exists/failure — the shared-hosting-friendly way to fix a missing symlink. - From a terminal the same thing is just
php artisan storage:link.
Backups
php artisan leadhub:backup runs nightly at 02:00 if enabled (GeneralSettings::auto_nightly_backup, or leadhub.backups.auto_nightly in config). Backup scope and restore steps are covered in Backups & restore.
Related guides
Was this page helpful?
Thanks — noted. ✦