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.
Architecture fit. This release moves the package from a PHP-centric media handler to an edge-friendly one. Two capabilities matter architecturally: redirect serving (serving.mode = redirect) and three-step direct uploads. Both keep the package's existing shape — signature and expiry checks still run in PHP, records still belong to the application, and the completion response is identical to an ordinary upload. That is a genuinely good fit: the package stays a policy and metadata layer rather than becoming a file pipeline. The DirectUploadPolicyInterface and bindings.upload_url_signer extension points mean application-specific rules (who may upload, where, with what limits) live in our code, not in a fork — a meaningful reduction in long-term coupling.
Integration feasibility. High for object storage, moderate for direct uploads. Redirect serving requires an S3-compatible disk plus league/flysystem-aws-s3-v3; direct uploads require a disk that can mint presigned PUT URLs, which the notes scope to S3 on Laravel 11+. If we run Laravel 10 or a non-S3 disk, direct uploads are simply unavailable — not broken, just closed. Local disks still stream as before, so we can adopt incrementally: redirect serving first, direct uploads later. The policy and signer bindings are optional overrides, which keeps the default path short.
Technical risk. Moderate, concentrated in a few places. First, object storage does not enforce declared size or content type on a signed PUT — the package compensates by re-checking real size and sniffing the real type from the first bytes at completion, then deleting failures. That compensating control is now load-bearing for security; we should verify it behaves as documented rather than take it on trust. Second, incomplete uploads are a new class of garbage: spray-media:prune-pending must actually be scheduled, or storage costs drift upward silently. Third, direct uploads bypass our application's request path, so any per-request middleware (rate limiting, quota, WAF rules) no longer applies to the bytes themselves — only to steps 1 and 3. Fourth, the token is encrypted and single-use, and completes only through the issuing policy; that is sound, but it makes policy binding configuration errors a functional failure mode (uploads issued by one policy failing at completion).
Key questions. Which Laravel version and disks are actually in our fleet today? Does the completion check re-read the object from the disk, and does it delete on every failure path including timeouts? What are the TTLs for upload URLs and tokens, and are they configurable? Does the prune command have a grace period that could delete an upload currently in flight? Can the policy enforce per-tenant quotas, or only size and type limits? What is the audit story for a completed direct upload — do we still get the same record shape and events as an ordinary upload?
Stack fit. Fits a Laravel API backend on an S3-compatible store (AWS S3, Cloudflare R2, MinIO). R2 is the cost-attractive option and is named explicitly in the notes with a full README example. If the product has any CDN or edge delivery today, redirect serving slots in naturally: the client gets a short-lived bucket URL and the bytes never touch PHP, which removes the single largest PHP-side scaling cost in a media package.
Migration path. Genuinely low-friction and correctly sequenced by the release itself. No breaking changes, no new migration, all features dark by default. Recommended order: (1) upgrade the package and confirm ordinary uploads still work unchanged; (2) point SPRAY_MEDIA_DISK at the object store and keep streamed serving — validate that reads, dispositions and content types survive the disk swap; (3) flip SPRAY_MEDIA_SERVE_MODE=redirect and confirm signed links still reject tampered and expired requests; (4) enable SPRAY_MEDIA_DIRECT_UPLOADS=true, implement DirectUploadPolicyInterface, and wire the completion endpoint into the client; (5) schedule the prune command. Steps 2–3 are reversible with an env change; steps 4–5 are the real commitment.
Compatibility. Two constraints to check before committing. Laravel 11+ is required for the temporary-upload-URL disk capability that direct uploads depend on; older versions must stay on the ordinary upload path. Published configs need the new bindings keys added only if we intend to swap the defaults — the serving and direct_uploads sections come from package defaults, so an old published config will not silently inherit them and must be reconciled deliberately. Existing stored media needs no backfill; serving mode is a read-path decision, not a data migration.
Sequencing. Treat redirect serving and direct uploads as two separate releases on our side, not one. Redirect serving is a low-risk bandwidth win with an env-var rollback. Direct uploads change the client contract (three calls instead of one), add a background job (prune), and move a security control into the package — that deserves its own rollout with its own rollback plan.
Maintenance. Small but non-zero additions: a scheduled artisan command, two new optional bindings, and a dependency on league/flysystem-aws-s3-v3 for S3 disks. The prune command must be added to the scheduler and monitored — an unscheduled prune is invisible until the storage bill arrives. Published configs drift from package defaults, so config reconciliation becomes a recurring upgrade step rather than a one-time one.
Support. Support load shifts rather than shrinks. Ordinary upload failures are familiar territory; direct-upload failures are not. Expect tickets around "upload succeeded but the file is not there" (step 3 never called, or policy refused at completion), expired tokens, and CORS or bucket-policy misconfiguration on the presigned PUT — the last is a classic and will land on whoever owns the bucket. The policy interface helps here: refusal reasons are ours to define, so we can make them diagnosable in our own vocabulary.
Scaling. Clear improvement. Redirect serving removes PHP and the app servers from the byte path entirely, which is the meaningful win for large files and for concurrency. Direct uploads remove PHP from the inbound path too. The remaining PHP work is signature checks, metadata writes, and policy evaluation — small, cacheable, horizontally scalable. The package's own scaling profile stops being the bottleneck; the bucket, CDN, and egress bill become the things to watch.
Failure modes. Enumerate these before enabling: bucket unreachable (redirect issues a dead link, or upload URL cannot be signed); completion check rejects a legitimate file due to a too-strict type sniff or an off-by-one on size (needs a clear error and a retry path, since failures are deleted); token expiry or replay between steps 2 and 3 (client must be able to restart cleanly); policy binding mismatch causing every completion to fail; prune running too aggressively against in-flight uploads; orphaned objects from clients that PUT but never complete; and signature verification regressions after the serving-mode switch. The delete-on-failure behaviour is correct for security but means failures are unrecoverable — the client contract must treat an upload as atomic.
Ramp-up. Low for redirect serving — one env var and a dependency. Moderate for direct uploads: the team must understand the three-call flow, implement and test a policy, configure bucket CORS and presigned URL permissions, schedule the prune job, and handle the new failure states in the client. Budget a short spike against R2 or MinIO to validate the policy interface and the completion checks before committing to a sprint. Documentation is adequate — the README now carries worked examples including R2 — but the security argument rests on the package's completion-time validation, so that specific behaviour should be independently verified rather than assumed.
How can I help you explore Laravel packages today?