Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Spray Media Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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();

Implementation Patterns

Redirect serving

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.

Direct uploads with a policy

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:

  • Refusing early (before a URL is issued) — auth/plan/quota checks. This is your only authorization hook for step 2, since the PUT goes straight to the bucket and never touches your middleware.
  • Refusing late (once the file has arrived) — e.g. content checks that need real bytes.
  • Choosing the folder — return a per-tenant or per-kind prefix so bucket layout mirrors your domain.
  • Adding record attributes — stamp tenant_id, uploaded_by, a "kind", etc., so the returned media item already carries your metadata.
  • Per-kind limits — give avatars 2 MB/image-only and attachments 100 MB, resolved from the request context your policy receives.

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']];
    }
}

Client flow

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 });

Testing

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.

Gotchas and Tips

  • Off by default, and silent when misconfigured. 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.
  • No migration, no DB row until step 3. Pending uploads are invisible to every list/query in your app. Don't build a "uploads in progress" screen from the media table; track tokens client-side.
  • 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).
  • Tokens are encrypted and good once. Do not retry step 3 after a network hiccup with the same token — issue a fresh upload instead. Also treat the token as a secret: it is bearer-equivalent for that one completion.
  • Binding changes invalidate in-flight uploads. A token completes only through the policy that issued it, so swapping 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.
  • Validation happens at completion, not upload. Object storage does not enforce size or type on a signed 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.
  • Redirect mode leaks bucket hostnames. The 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.
  • Don't rely on response middleware for redirects. Anything that adds headers, does access logging on the body, or enforces byte-range behaviour will behave differently when the payload is a 302 rather than a stream. If you need per-request authorization on downloads, keep it in the signed-URL generation, not in response decoration.
  • Extension points worth knowing: 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.
  • Missing 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".
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator
equipc/laravel-email-database-log
adedsec/nova-translated-labels