sarkis-sh/spray-media
Spray Media is a Laravel package for managing uploaded media and attaching files to Eloquent models. It offers a simple foundation for storing, retrieving, and organizing images and documents, with configurable storage and straightforward model integration.
Start with the README sections "Object Storage" and "Direct uploads" — they contain a full worked example including a Cloudflare R2 disk. Then check config/spray-media.php (or the package's published config) for the serving and direct_uploads sections that ship as defaults in v1.3.0.
1. Nothing changes until you opt in. Ordinary uploads (POST /api/media-items) keep working on whatever disk is configured. The minimum to try the new features:
SPRAY_MEDIA_DISK=r2 # any S3-compatible disk
SPRAY_MEDIA_SERVE_MODE=redirect
SPRAY_MEDIA_DIRECT_UPLOADS=true
composer require league/flysystem-aws-s3-v3 # required for any S3-compatible disk
php artisan config:clear
Direct uploads additionally require a disk that can mint temporary upload URLs — an S3 disk on Laravel 11+.
2. First use case — offload bandwidth. Point SPRAY_MEDIA_DISK at R2/S3, set serving.mode = redirect, and existing signed file links keep checking signature and expiry but now answer 302 to a short-lived disk URL. Nothing else in your app changes: same Content-Type, same inline/attachment disposition. Confirm with a single file that a browser still previews inline and downloads as attachment.
3. Second use case — large uploads. Wire a client to the three-step flow:
POST /api/media-items/direct-uploads { filename, mime_type, size } -> { upload_url, token }
PUT <upload_url> <raw bytes>
POST /api/media-items/direct-uploads/complete { token } -> media item
The response of step 3 is identical to an ordinary upload, so your existing "attach media to a model" code path keeps working.
4. Schedule the cleanup before you enable direct uploads in production — abandoned uploads otherwise sit in the bucket forever:
// routes/console.php or bootstrap/app.php withSchedule(...)
Schedule::command('spray-media:prune-pending')->hourly();
Treat redirect mode as a deployment/config decision, not a code branch. Keep the same URL you hand to users (the signed package URL); the mode only changes how the bytes arrive. Good fit for: public marketing assets, large video/PDF, anything where PHP-FPM egress is the bottleneck. Keep streaming for local disks (automatic) and for cases where you must inspect or transform bytes on the way out — redirect hands the file straight from bucket to client.
All meaningful control lives in DirectUploadPolicyInterface, bound as bindings.direct_upload_policy. Implement it when you need per-tenant folders, quota/entitlement checks, or per-kind limits:
// config/spray-media.php
'bindings' => [
'direct_upload_policy' => App\Media\TenantUploadPolicy::class,
'upload_url_signer' => App\Media\S3UploadUrlSigner::class, // optional
],
The policy is the right place for:
PUT goes straight to the bucket and never touches your middleware.tenant_id, uploaded_by, a "kind", etc., so the returned media item already carries your metadata.Sketch of the day-to-day usage (fill method signatures from the interface the package ships):
final class TenantUploadPolicy implements DirectUploadPolicyInterface
{
public function authorize(array $context): bool
{
return auth()->check() && tenant()->withinQuota($context['size']);
}
public function directory(array $context): string
{
return "tenants/".tenant()->id."/".$context['kind'];
}
public function attributes(array $context): array
{
return ['tenant_id' => tenant()->id, 'kind' => $context['kind']];
}
}
Keep the token client-side only, and resend mime_type / size consistently: step 3 compares the file's real size to the declared size and sniffs the real type from the first bytes. Drive the UI from the step-3 result — it is the only point at which the item exists.
const start = await api.post('/api/media-items/direct-uploads', file.meta); // filename, mime_type, size
await fetch(start.upload_url, { method: 'PUT', body: file, headers: { 'Content-Type': file.type } });
const item = await api.post('/api/media-items/direct-uploads/complete', { token: start.token });
Storage::fake() still works for direct uploads; for redirect mode assert on the status and Location header rather than assertStreamedContent, and assert Content-Type / Content-Disposition separately since they are now carried by the redirect response.
serving.mode and direct_uploads fall back to package defaults, so a stale published config can look like "the feature didn't ship". If you published the config before v1.3.0, only the new bindings keys need adding — the serving and direct_uploads sections come from defaults. php artisan config:clear after toggling env vars.prune-pending is not optional. Without it, failed or abandoned direct uploads accumulate on the disk. Hourly is the documented cadence; make sure the scheduler actually runs in each environment (schedule:list).bindings.direct_upload_policy (or renaming/moving the class) while uploads are mid-flight will strand them. Deploy policy changes with a cache/config clear and expect a short window of failed completions.PUT. A file failing the check is deleted; one passing is moved into place within the disk, so the final path is not necessarily the URL the client PUT to — never persist the upload URL as the item's location.302 target is a short-lived disk URL, visible to clients and in logs/proxies. That's fine for R2/S3 public-style access, but if your bucket hostname is sensitive, use a custom domain on the disk.302 rather than a stream. If you need per-request authorization on downloads, keep it in the signed-URL generation, not in response decoration.bindings.upload_url_signer if you need custom signing (non-S3 gateways, presigned POST instead of PUT), and DirectUploadPolicyInterface for anything domain-specific. Prefer the policy over forking the controllers — it's called on both the "before URL" and "after arrival" paths, so refusal logic belongs there rather than in your own controller wrapper.league/flysystem-aws-s3-v3 surfaces as a Flysystem adapter error at first use, not at boot — check it first if SPRAY_MEDIA_DISK=r2 "does nothing".How can I help you explore Laravel packages today?