dejwcake/craftable-media
Media library and file management for the Craftable admin panel: upload, browse, organize, and attach images and files to your Eloquent models with a ready-made admin UI, keeping media handling consistent across your Laravel application.
Where the package actually sits. This is a fork of brackets/media (namespace Brackets\Media, composer dejwcake/craftable-media) that wraps spatie/laravel-medialibrary and adds Craftable-flavoured pieces: a fluent MediaCollection builder, upload/private-file HTTP controllers, and a LocalUrlGenerator. Practically, that means the spatie API you probably already know (addMediaCollection, addMedia, getMedia, getFirstMediaUrl) is the substrate; this package's value-add is collection rules declared fluently on the model and prebuilt request-driven endpoints. Read the spatie docs first, then read src/HasMedia/MediaCollection.php to see what the fluent builder adds on top.
Minimal steps.
composer require dejwcake/craftable-media — pin an exact version (see Gotchas; the version line is ambiguous).media table with its polymorphic columns and indexes is the durable thing you're committing to. Confirm the index shape matches how you'll query.class Page extends Model implements \Brackets\Media\HasMedia\HasMediaCollections
{
use \Brackets\Media\HasMedia\HasMediaCollectionsTrait;
}
registerMediaCollections() — disk, max file count, max size, accepted MIME types. Keep the declaration next to the model, not in a config file.config/filesystems.php, then verify upload → retrieve → URL end-to-end against the real disk driver, not a fake.First use case. A single images collection on one low-risk model (Page/Article), local disk, no conversions. Get a file in via the package's upload controller or a direct addMedia() call, then render getFirstMediaUrl('images') in Blade. Only after that is boring should you add conversions or a private disk. Budget roughly half a day for the first integration; the trait-plus-service-provider shape is a familiar Laravel pattern and needs no new mental model.
Declare collections fluently on the model, and name them once. The MediaCollection builder carries disk, limits, accepted types, and permissions. The recurring mistake is scattering the collection string 'images' across controllers, Blade, and tests. Put a const COLLECTION_IMAGES = 'images' on the model and reference it everywhere — renaming a collection later is a data migration, not a refactor.
Two integration paths; pick one deliberately.
src/Http/Controllers/) for add/update/delete. Fastest to wire, gives you a consistent front-end contract across all models. The cost is that the controller's request shape and authorization model become your external API; read the controller source before exposing it, since there is no published request-schema doc.Mirror collection rules in FormRequest validation. The declared max size and accepted types are storage-side guardrails, not validated user input. Duplicate them in the FormRequest so users get a field-level error instead of a rejected upload after the fact. Keep one list — if the accepted MIME types live in two places, they will drift.
Auto-processing hooks are convenient and order-sensitive. AutoProcessMediaTrait hooks saving to process media automatically. That's ergonomic for a simple "upload an image on save" flow, but it fires on every save of the model, including ones that didn't touch media, and it runs before the model necessarily has an ID on create. If your processing is expensive or your create path is unusual (upserts, saveQuietly, batch imports), use ProcessMediaTrait explicitly instead of the auto hook. Prefer saveQuietly() in seeders and imports so the hook doesn't try to process a nonexistent file.
Push conversions to the queue. Conversions are CPU-bound. If they run in-request, large uploads turn into latency spikes and worker starvation. Configure a queue and a worker for the media queue specifically; treat "queue worker running" as a hard prerequisite for any collection with conversions, and document it in your deploy checklist rather than in a wiki nobody reads.
Private files belong behind a route, not a URL. The private-file controller exists precisely so documents can be served through application authorization instead of a public disk URL. Use a separate disk for private collections, and enforce per-collection permissions in your own middleware/policy — the declared permissions are descriptive, they don't enforce themselves. Never hand out a raw storage path for a private collection.
Craftable integration is the happy path. If you're already on a Craftable admin panel, the media form field and the controllers drop in with no extra wiring. If you're on stock Laravel — which is likely given a 0-star fork — treat this as a standalone spatie wrapper and expect to build the admin UI yourself.
Testing. Use Storage::fake() for the disk, assert on collection membership and file counts rather than on URLs, and keep one integration test that hits a real driver. Feature tests where several real classes collaborate (upload → process → retrieve), unit tests for isolated generation logic.
Confirm the fork's identity and version semantics before you commit. Version 2.0.1 is far below the release line of the widely used package with this class surface, and it's a fork of brackets/media. That means: no upstream changelog to diff against, uncertain support commitment, and unknown breaking-change surface in 2.0.0. Media is a sticky dependency — once rows exist in media and objects exist on a disk, swapping packages means data migration plus URL rewrites for anything already embedded. Resolve the identity question before this becomes load-bearing; if it's internal, name an owner.
Check the PHP/Laravel constraints in composer.json, not the README. The package's own dev conventions mention PHP ^8.5, Laravel 13, PHPUnit 13, Testbench 11. Those may be dev-only requirements, but if they leak into require, this package won't install on a normal Laravel 11/12 app. Verify against your actual app version before designing around it.
Container resolution outside a bootstrapped app will bite you. 2.0.1 replaced app() with Container::getInstance()->make() in MediaCollection, ProcessMediaTrait, and LocalUrlGenerator. Behaviour is equivalent inside a bootstrapped Laravel app (HTTP, queue, Octane), but if you call these classes from a bare unit test, a standalone script, or a test suite that rebinds the container, confirm your fakes still resolve the same bindings. Run your own test suite once against 2.0.1 rather than trusting "nothing to do when upgrading."
Disk misconfiguration fails silently. A wrong disk name, missing driver credentials, or bad permissions surfaces as broken image URLs — not an exception. Users find it before you do. Put one end-to-end smoke check in CI: upload a file, retrieve it, assert a 200 from the generated URL against the real driver. This single check mitigates most of the failure modes in this package (disk config, conversions leaving records pending, partial writes).
Orphans are the long-run storage problem. Deleting a model doesn't necessarily delete its conversions; deleting media doesn't necessarily remove the object from the disk, and vice versa. Budget a periodic reconciliation job early — compare media rows to disk objects and flag divergence. Also watch for conversion records stuck in a pending state with no alert; add a metric or scheduled check.
Don't serialize media objects across queue boundaries. Passing a Media instance into a job payload is a classic source of stale paths and missing conversions. Pass the ID and re-resolve inside the job.
Debugging checklist, in order: does the row exist in media (right model_type/model_id, right collection_name)? Does the object exist on the configured disk at the expected path? Does generated_conversions list what you expect? Is the queue worker running? Is config/filesystems.php pointing at the disk you think it is? Most "the image is broken" reports resolve at step two.
Extension points worth knowing. The URL generator and filesystem classes are bound in the container — you can swap LocalUrlGenerator for an S3/CDN-aware implementation rather than rewriting call sites. MediaCollection is a fluent builder, so a project-local subclass or a small helper that returns preconfigured collections (e.g. imageCollection(), documentCollection()) keeps disk and limit conventions consistent across models instead of being reinvented per feature. If you override the controllers, do it via routes/config rather than editing vendor files — this is a 0-star fork; you will be upgrading blind and will want a clean diff.
How can I help you explore Laravel packages today?