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 defaults3disk, ther2disk 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.]