These docs are a work in progress and may not be fully up to date. Some pages may contain internal notes for our team.
Skip to Content

Backend — storage

Cross-reference: For the R2 bucket topology, CDN domain, cache TTLs, and the end-user payload fetch chain, see ../../architecture/storage-and-cdn.md.

The backend uses Laravel’s Flysystem abstraction (Storage facade) with two disks: local for private ephemeral files and r2 for all production object storage. All user-visible assets — location images, user avatars, and published finder payloads — are written to the r2 disk and served via a Cloudflare CDN URL, not directly from R2.

Disk configuration

config/filesystems.php declares four disks. The two that matter for production are:

r2 disk

'r2' => [ 'driver' => 's3', 'key' => env('R2_ACCESS_KEY_ID'), 'secret' => env('R2_SECRET_ACCESS_KEY'), 'region' => 'auto', 'bucket' => env('R2_BUCKET'), 'endpoint' => env('R2_ENDPOINT'), 'use_path_style_endpoint' => true, 'throw' => true, ],

config/filesystems.php:60-69

Key points:

  • Driver: s3 — uses the AWS SDK’s S3 client, which Flysystem wraps. Cloudflare R2 is S3-compatible.
  • Region: hard-coded to 'auto' — Cloudflare resolves the nearest data center; no AWS region string is needed.
  • Path-style endpoint: use_path_style_endpoint => true — required for R2 because R2 does not support virtual-hosted-style (bucket.endpoint) addressing.
  • throw => true — unlike the default s3 disk, the r2 disk re-throws exceptions from the S3 client. Upload failures surface as 500 responses rather than silent no-ops.

public disk (local dev / fallback)

'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), 'url' => env('APP_URL').'/storage', 'visibility' => 'public', 'throw' => false, ],

config/filesystems.php:41-48

Used in local development when FILESYSTEM_DISK=public. The symlink public/storage → storage/app/public is created by php artisan storage:link. Not used in production.

Active disk selection

'default' => env('FILESYSTEM_DISK', 'local'),

config/filesystems.php:16

All production code calls Storage::disk('r2')->... explicitly — the default disk setting is not relied upon for R2 writes.

Object types stored

Four distinct object types are written to R2. All are publicly readable via the CDN URL; none are written with ACL headers that would make them accessible at the R2 endpoint directly (R2 buckets are configured as private; public access is via Cloudflare’s CDN in front of the bucket).

1. Versioned finder payload

Key pattern: finders/{token}/v/{hash}.json

Example: finders/abc123xyz/v/3f8a1b2c4d5e.json

Written by ExternalController::syncFinderDetails (app/Http/Controllers/ExternalController.php:191-196) when the builder publishes a finder. The {hash} is a 12-character sha1 prefix computed from the payload content (excluding created and published_at timestamps). Cache-Control: public, max-age=31536000, immutable — these objects are never mutated; a new hash means a new file.

2. Pointer file

Key pattern: finders/{token}/config.json

Example: finders/abc123xyz/config.json

Written alongside the versioned payload by the same syncFinderDetails call (ExternalController.php:197-201). Contains {"hash": "...", "published_at": "..."}. Cache-Control: public, max-age=60 — the widget fetches this first on every page load to discover the current hash, then fetches the immutable versioned payload. The 60-second TTL means new publishes reach end users within ~1 minute without requiring cache purges.

3. Location images

Key pattern: workspaces/{workspace_id}/locations/{location_id}/{uuid}.{ext}

Example: workspaces/12/locations/447/a1b2c3d4-e5f6-....webp

Written by LocationController::uploadImage (app/Http/Controllers/LocationController.php:271-278). The UUID prevents filename collisions when an image is replaced. The previous image key is deleted from R2 immediately after the new key is written (LocationController.php:295-300). Accepted MIME types: jpg, jpeg, png, webp, gif (max 2 MB). Cache-Control: public, max-age=31536000, immutable — the UUID in the key guarantees immutability.

4. User avatars

Key pattern: avatars/{user_id}/{uuid}.{ext}

Example: avatars/88/d4e5f6a7-b8c9-....png

Written by AccountSecurityController::uploadAvatar (app/Http/Controllers/AccountSecurityController.php:34-39). The previous avatar key is deleted when a new one is uploaded (AccountSecurityController.php:41-43). Accepted MIME types: jpg, jpeg, png, webp (max 1 MB). No explicit Cache-Control header is set for avatars — the CDN applies its default TTL.

Storage facade usage

All writes and deletes use the Storage::disk('r2') explicit-disk form — never the default-disk Storage::put() shorthand. This makes the target disk unambiguous regardless of the FILESYSTEM_DISK env var.

Write pattern:

Storage::disk('r2')->put( $key, file_get_contents($file->getRealPath()), [ 'ContentType' => $file->getMimeType(), 'CacheControl' => 'public, max-age=31536000, immutable', ] );

LocationController.php:271-278

Delete pattern:

Storage::disk('r2')->delete($key);

LocationController.php:585 (via deleteLocationImageObject) / AccountSecurityController.php:42

Delete failures are caught and logged as warnings — they do not propagate to the caller:

private function deleteLocationImageObject(string $key, array $context = []): void { try { Storage::disk('r2')->delete($key); } catch (\Throwable $e) { Log::warning('R2 location image delete failed', ...); } }

LocationController.php:582-590

Upload failures in uploadImage are caught and returned as a 500 JSON response; they are not silently swallowed (LocationController.php:279-288).

CDN URL construction

The public-read URL for any R2 object is assembled by the helper publicCdnUrl:

private function publicCdnUrl(string $key): string { return rtrim(config('services.cdn.base_url'), '/') . '/' . ltrim($key, '/'); }

LocationController.php:577-580

The same pattern appears inline in AccountSecurityController.php:47 and ExternalController.php:193.

services.cdn.base_url is set via the CDN_BASE_URL env var (resolved in config/services.php). In production this is the Cloudflare distribution domain in front of the R2 bucket — e.g., https://cdn.dropafinder.com. The constructed URL is stored in the image_url column on locations (or avatar_url on users) and returned directly in API responses; the backend never signs or proxies the CDN URL at read time.

Local development

Set FILESYSTEM_DISK=local (the default when the env var is absent) or FILESYSTEM_DISK=public in .env.local. Images and payloads write to storage/app/public/ and are served at APP_URL/storage/... via the storage:link symlink. The R2 credentials are not required for local development unless you are specifically testing R2 writes.

🔴 [NEEDS CLARIFICATION: Is there a development R2 bucket for staging/pre-production testing, or do developers always use the local disk? Affects how env vars are managed in non-production deployments.]