Skip to content

Object Storage (Wasabi / S3-Compatible)

Production file uploads use Laravel’s filesystem abstraction with an S3-compatible backend (Wasabi by default). Local development continues to use the public disk. Switching backends requires environment variables only — no code changes.

Architecture

ConcernBehavior
Default diskFILESYSTEM_DISK (public locally, s3 in production)
Uploads diskFILESYSTEM_UPLOADS_DISK (defaults to FILESYSTEM_DISK) — imports, attachments, exports
Branding diskFILESYSTEM_BRANDING_DISK (defaults to uploads disk) — logo/favicon only
Upload entrypointApp\Services\Storage\FileUploadService
DB valuesRelative object keys only (e.g. branding/logos/….png)
Public URLsAlways via FileUploadService::url() / Storage::disk(…).url()
Private downloadsPrefer temporaryUrl() when the driver supports it
Tenant isolationKey prefixes tenants/{tenant_uuid}/… on a shared disk

Stancl’s FilesystemTenancyBootstrapper remaps only the private local disk. Uploads stay on the shared public / s3 disk so object keys match across environments and after storage:migrate-to-s3.

Object key layout

text
branding/logos/
branding/favicons/
tenants/{tenant_uuid}/branding/logos/
tenants/{tenant_uuid}/branding/favicons/
tenants/{tenant_uuid}/leads/          # future modules
tenants/{tenant_uuid}/tasks/
tenants/{tenant_uuid}/attachments/
central/logos/
central/branding/
central/users/
exports/
imports/
temp/

Legacy tenant-logos/ keys may still exist on disk until rewritten; new admin uploads use tenants/{uuid}/branding/logos/.

Local development

env
FILESYSTEM_DISK=public
bash
php artisan storage:link

URLs resolve as {APP_URL}/storage/{key}.

Optional override (defaults to FILESYSTEM_DISK):

env
FILESYSTEM_UPLOADS_DISK=public

Production (Wasabi)

env
FILESYSTEM_DISK=s3

AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=your-bucket
AWS_ENDPOINT=https://s3.us-east-1.wasabisys.com
AWS_URL=https://s3.us-east-1.wasabisys.com/your-bucket
AWS_USE_PATH_STYLE_ENDPOINT=false

Never hardcode Wasabi hostnames in application code. Set AWS_ENDPOINT / AWS_URL per region and bucket.

The same s3 disk works with AWS S3, Cloudflare R2, MinIO, and DigitalOcean Spaces — change endpoint/URL/credentials only.

Branding on local disk (optional split)

Keep logos/favicons on the API server while other uploads stay on S3:

env
FILESYSTEM_DISK=s3
FILESYSTEM_BRANDING_DISK=public
APP_URL=https://your-api-domain.com
bash
php artisan storage:link
php artisan config:clear

Branding URLs resolve as {APP_URL}/storage/{key}. After switching branding from S3 → public, re-upload logo/favicon (existing S3 keys will not resolve on the public disk). Ensure the web server serves /storage and storage/app/public is persisted across deploys (shared volume if multi-instance).

Bucket recommendations

TopicGuidance
VisibilityWhen branding stays on S3: public-read for logos/favicons. Keep private modules/exports private and serve via temporary URLs.
Bucket policyAllow s3:GetObject for public prefixes if objects are public; never grant public PutObject / DeleteObject.
CORSAllow SPA origins (FRONTEND_URL + admin hosts) for GET (and PUT only if you later introduce direct browser uploads).
CredentialsIAM / Wasabi keys with least privilege on this bucket only; never expose in the SPA.
VersioningOptional on the bucket for recoverability of branding assets.

Required when branding (or uploads) use the public disk. Not required for S3-only branding. Keep the symlink for local/public disk usage.

Migrating existing local files

After pointing production at S3, copy existing storage/app/public objects:

bash
# Preview
php artisan storage:migrate-to-s3 --dry-run

# Copy (idempotent — skips keys that already exist)
php artisan storage:migrate-to-s3

# Force re-upload
php artisan storage:migrate-to-s3 --force

Options: --source=public (default), --destination=s3 (default).

Database paths are already relative keys and do not need rewriting when directory structure is preserved.

Application usage

php
use App\Services\Storage\FileUploadService;

$path = $uploads->store($file, FileUploadService::tenantBrandingDirectory($tenantId, 'logos'));
$url = $uploads->url($path);
$uploads->delete($path);

Controllers must not call store() / Storage::disk('public') directly for user uploads.

Security

  • Branding validation rejects SVG and non-image MIME types (UploadBrandingAssetRequest / tenant equivalent).
  • Filenames are generated (uuid_timestamp.ext); client filenames are never trusted as object keys.
  • Paths with .. or absolute URLs are never deleted through FileUploadService::delete().

Deploy checklist

  1. Create Wasabi (or other) bucket + access key.
  2. Set FILESYSTEM_DISK=s3 and AWS_* on the app server / secrets store.
  3. Optional: set FILESYSTEM_BRANDING_DISK=public, run php artisan storage:link, and confirm APP_URL is the public HTTPS API origin.
  4. Deploy code with league/flysystem-aws-s3-v3.
  5. Run php artisan storage:migrate-to-s3 once (or --dry-run first) when migrating non-branding (or all) local objects to S3.
  6. Smoke-test Central + tenant logo/favicon upload, replace, delete, and public bootstrap URLs (curl -I the returned logo_url).
  7. Confirm SPA img tags receive absolute URLs from the API (no hardcoded /storage paths).
  • settings/settings-production.md
  • settings/tenant-settings-production.md
  • architecture/platform-production-runbook.md

Official documentation for the SaleOS SaaS Platform.