awcodes/filament-curator
Filament Curator is a media picker and manager plugin for Filament Admin, offering a full media library UI with Glide-signed image URLs and a one-command installer. Supports Filament 3, 4, and 5. Note that it does not work with Spatie Media Library.
Architecture fit
Media Eloquent model, one resource, one form field (CuratorPicker), one table column (CuratorColumn), and a Blade component (<x-curator-glider>). It introduces no queue, no service, no daemon, and no external dependency beyond league/glide for image transforms. For a TPM this is the most important characteristic: the integration surface is narrow, the reversal cost is low, and it does not reshape the application's domain model.media table plus foreign keys on consuming models. Single relations are belongsTo(Media::class, 'featured_image_id'); multi-media is a belongsToMany with a pivot order column. That is a conventional, inspectable schema — any competent Laravel team can query, back up, and migrate it without the package.Integration feasibility
composer require, php artisan curator:install, register CuratorPlugin on the panel, add CuratorPicker fields and model relations. The curator:install command handles token generation, which removes the most common class of setup error.plugin.css must be imported and its Blade views added to Tailwind @source in the theme CSS. Without this, the picker renders unstyled. Teams on a stock panel will hit this on day one.<x-curator::modals.modal /> to the layout before </body>. Panels users do not need this. Knowing which mode the project is in determines whether this is a zero-step or a one-step task.maxWidth, minSize, maxSize, rules, acceptedFileTypes, disk, visibility, directory, imageCropAspectRatio, plus multiple() and relationship(). Existing FileUpload knowledge transfers, so developer ramp-up is measured in hours, not days.PathGenerator is an interface you can implement (DefaultPathGenerator, DatePathGenerator, UserPathGenerator ship in-box), and Curation::presets() registers reusable crop/format/quality presets. This is enough to satisfy typical multi-tenant and per-user storage layouts without forking.tenantAware() defaults to true on the picker. For a multi-tenant product this is a feature, but it must be verified against however tenancy is actually implemented in the panel — default-on behavior that silently scopes media is exactly the kind of thing that produces "the image disappeared" tickets.Technical risk
CURATOR_GLIDE_TOKEN is written to the local .env, which is gitignored. Every environment — teammate checkout, CI, staging, production — needs its own value, and the README is explicit that a missing variable breaks both URL generation and the media route. The value need not match across environments, which contains the blast radius, but it must be present. Mitigation is trivial and mandatory: add CURATOR_GLIDE_TOKEN= to .env.example and to the secret manager before the first deploy.curator:token overwrites the value, so any URL that outlived its request — cached HTML, CDN copies, sent emails, URLs pasted into stored rich content — starts returning 403. If config is cached, php artisan config:clear is required. This is a real failure mode with a real user-visible symptom, and it turns token rotation into a change-management event rather than a routine operation. Treat it like a key rotation with a defined communication and cache-invalidation window.MimeType list minus executable/script-serving content (text/html, text/javascript, application/xml, application/x-httpd-php, application/octet-stream, and similar), because media is served from your own origin and, on the public disk, sits inside the document root under the storage symlink. Only SVG is sanitized; anything else opted back in is stored and served verbatim. The route sets X-Content-Type-Options: nosniff and forces Content-Disposition: attachment for restricted types, but those headers do not apply to direct storage symlink access. The safe posture is: keep defaults, do not re-enable HTML/XML/JS, and if a business requirement forces it, serve from a private disk. This deserves a one-line security sign-off, not a full review.force on the Glider component is documented as necessary for cloud disks but with "performance implications." Any plan that routes all production image traffic through the app rather than a CDN edge should be load-tested before launch, not after.CuratorColumn on a relationship will N+1 unless the table query eager-loads (->with(['featured_image', 'product_pictures']) or modifyQueryUsing). Cheap to fix, easy to forget, and invisible until a list view gets slow.Key questions
public with the storage symlink, or a private/cloud disk? The answer determines whether the documented header protections actually apply.tenantAware() default-on align with the existing tenancy model?Stack fit
srcset and WebP output without adopting a full image CDN (Imgix, Cloudinary). It is a poor match for teams that have already committed to one of those, since it duplicates the transform layer.Migration path
FileUpload + a string column is a moderate data migration: you must move existing files into Curator's configured disk/directory, create Media rows, and rewrite FK columns to point at media IDs. PathGenerator selection matters here — choosing DatePathGenerator or UserPathGenerator after the fact means the existing files land in the wrong place. Decide the path generator before writing the migration, not after.PathGenerator rather than fighting the config.media table and FKs are plain schema, and files live on a normal disk. Exiting Curator means writing a backfill to whatever comes next — annoying, but bounded and non-destructive. This low lock-in is the strongest argument for approving it.Compatibility
Sequencing
@source entries. Kill or proceed here; do not proceed on optimism.CURATOR_GLIDE_TOKEN= to .env.example and to every environment's secret store before any feature work. Decide the path generator. Document the config-cache step. This is a 30-minute task that prevents a production incident.CuratorPlugin with label/navigation/group, add a single CuratorPicker on one resource, wire the belongsTo relation, add a CuratorColumn with eager loading. Ship it behind a feature flag if the admin is user-facing.belongsToMany + pivot order where galleries are needed; add AttachCuratorMediaPlugin to any RichEditor fields. Register curation presets for the handful of sizes the design system actually uses — resist inventing a preset per screen.<x-curator-glider> with srcset/sizes in Blade views, confirm lazy loading defaults are acceptable, load-test the media route if traffic is nontrivial, and decide on CDN vs. app-served.Media rows, rewrite FKs, verify no orphaned references, run the N+1 check on every list view touched.Maintenance
PathGenerator implementations in-house, and accepting that a fork is a plausible long-term outcome. For a panel plugin with this narrow a surface, that is an acceptable risk — for a payments or auth library it would not be.Support
Scaling
media table with a few hundred thousand rows is unremarkable for Postgres/MySQL with sensible indexing on the FK columns consuming it.srcset support is a genuine scaling asset: serving appropriately sized images rather than one large original is the single biggest lever on bandwidth and perceived performance.tenantAware() behavior under load before assuming isolation is both correct and efficient — default-on scoping is convenient but should be confirmed rather than trusted.Failure modes
| Failure | Symptom | Likelihood | Mitigation |
|---|---|---|---|
CURATOR_GLIDE_TOKEN missing in an environment |
URL generation and media route both fail — images dead | High on first deploy to a new env | Add to .env.example and secret store before deploy |
Token rotated (curator:token re-run) |
Cached HTML, CDN copies, sent emails, stored content return 403 | Medium | Treat as key rotation: schedule, purge caches, run config:clear |
| Token changed but config cached | Stale token served; signatures rejected | Medium | Bake php artisan config:clear into the rotation runbook |
Custom theme not built with plugin CSS / @source |
Unstyled, broken picker UI | High on first install | Explicit spike acceptance criterion |
| Standalone forms without modal in layout | Picker modal never opens | Medium (only standalone) | Checklist item if not on Panels |
| Relationship column without eager loading | Slow list views as media grows | High if undocumented | ->with(...) or modifyQueryUsing on every affected table |
| Executable MIME types opted back in | Stored XSS via uploaded HTML/XML served from own origin | Low but severe | Keep MimeType::defaults(); private disk if unavoidable |
Direct storage symlink access bypassing route headers |
Restricted type served without nosniff/attachment |
Low | Private disk for anything sensitive |
| Filament minor upgrade | Picker/column styling or behavior regressions | Medium over time | Pin Curator major; re-verify on Filament upgrades |
| Maintainer abandonment | No upstream fixes | Low-to-medium over years | Pin, keep path generators in-house, fork as fallback |
Ramp-up
FileUpload, so prior Filament experience transfers almost entirely. The three things a developer must learn are the plugin registration surface, the relationship patterns (single belongsTo vs. multi belongsToMany with pivot order), and the Glider component's attribute set.How can I help you explore Laravel packages today?