NewYour coding agent can read the release notes before it upgrades.Set up the MCP server →
Packagist · #4885 most downloaded on Packagist
A media picker plugin for FilamentPHP.
Last release 17 days ago
21 Sep 2026
Ships fairly regularly
a new release about every 2 weeks
Some releases are documented
notes for 16 of the last 60 stable releases
Nothing withdrawn
no release was ever pulled
4 years old
267 releases · first in 2022
One column per quarter.
One hook for every action that deletes media. Curator::configureDeleteActionsUsing() configures the resource table's row and bulk delete actions, the
One hook for every action that deletes media. Curator::configureDeleteActionsUsing() configures the resource table's row and bulk delete actions, the edit page's delete action, and the picker panel's delete action. Use it to add a warning or a guard without a global DeleteAction::configureUsing(). Call it from a service provider's boot() method:
use Awcodes\Curator\Facades\Curator;
use Awcodes\Curator\Models\Media;
use Filament\Actions\Action;
Curator::configureDeleteActionsUsing(function (Action $action): void {
$action->modalDescription(fn (?Media $record) => "Delete {$record?->name}? It may still be in use.");
});The callback receives the Filament action, so any action method works, including before() with $action->halt(). Single-record actions receive the media as $record and the bulk action receives $records. Type $record as nullable: the picker panel only resolves it once the delete dialog opens, and not at all if the user may not delete that item. If you have replaced the resource's page or table classes, pass your delete actions through Curator::configureDeleteAction() to pick up the hook. See Customising delete actions.
deleteAny(). If your policy restricted delete() on individual records but didn't define deleteAny(), bulk delete from the media table removed records the policy denies. Each selected record is now checked. Records the user may not delete are skipped, and Filament reports a partial delete.Added in #739, with tests for each delete path.
Filament 4 users are covered: 5.x serves both majors.
Deleting a media item no longer breaks every page that renders it. <x-curator-glider> threw Invalid media item provided to Glider component. whenever
<x-curator-glider> threw Invalid media item provided to Glider component. whenever an id no longer resolved and no fallback was set, so pages returned a 500. It now renders nothing and logs a warning naming the missing id. Misconfiguration still throws, now with a clearer message: a fallback name that was never registered reports The [name] glider fallback is not registered., and a fallback without a source still throws as before.
:media="null" without a fallback now renders nothing instead of throwing.width and height attributes now match the image Glide serves. Asking for width="960" on a 1600×900 image wrote height="900" while Glide returned 960×540. That distorted the box and shifted the layout. The missing side is now derived from the aspect ratio, in either direction. A srcset on a path or fallback with unknown dimensions no longer divides by zero.curator.default_directory is null), the observer called deleteDirectory(null) once the disk held no files, which removed the disk root along with any empty folders. Renaming or replacing root-level media also stored paths with a leading slash (/renamed.jpg), which the Glide route then couldn't find. Both are fixed.storage/app/public. Media on any other disk stored without error and then returned a 500. That included a fresh Laravel app's local disk, which default_disk follows through FILESYSTEM_DISK, as well as S3 and MinIO. Glide now reads each item from the disk it was stored on, so cloud disks no longer need a custom server config. A config passed to Glide::serverConfig() is still used exactly as given.curator.default_directory. With it set, new files landed in that directory while the picker and rich editor panels opened at the disk root, so a file you had just uploaded wasn't listed. The picker, rich editor, media form, bulk upload and CuratorUtils now all fall back to it.Fixed in #738, with regression tests for each fix.
Filament 4 users are covered: 5.x serves both majors.
Several Curator pickers bound to the same MorphMany relationship no longer clobber or duplicate each other's rows.
MorphMany relationship no longer clobber or duplicate each other's rows.A form with more than one picker on a single relationship — featured, thumb, gallery, told apart by typeValue() — gained a row on every save, and the pickers displayed images belonging to their siblings.
The MorphMany save path reads and writes its rows in five places. Three of them disagreed about which rows belong to the picker doing the work:
featured picker rewrote the type of every row sharing that media_id — including the thumb row. The orphaned picker then found nothing of its own and created a replacement. One extra row per save, forever. Introduced in v5.0.6 by the sort-order fix: before that the update matched nothing at all, which was the sort-order bug itself.typeValue() loaded every row on the relationship — including rows owned by typed siblings — and then wrote them back as untyped copies of itself. Present since v5.0.0.->typeValue('0') — a backed enum whose first case is zero, for instance — stored a row that no later lookup could match, and every save appended another duplicate. Present since v5.0.0.All five now scope identically. A null type value matches on type IS NULL, which is what the existing lookup and delete already relied on.
Reported in #734, fixed in #735 (thanks @appdezign) and #736. Four regression tests cover it.
Filament 4 users are covered — 5.x serves both majors.
The RichEditor integration now closes the Curator panel it opened, instead of whichever modal happens to come first on the page.
Curator closed its media panel by searching the whole document for the first .fi-modal and clicking that element's close control. Filament renders every mounted action modal as a sibling in stack order, so the first match is the outermost modal — not the panel the selection was made in.
On an ordinary page form the panel is the only modal on screen, so that lookup happened to be right and the bug stayed hidden. Nest the editor inside another modal — a Mason brick, a relation manager edit action, a RichContentCustomBlock — and inserting an image closed the host modal instead, discarding the unsaved form. In Mason's case the brick was silently never added.
Livewire dispatches from the component's own root element, so the insert event already identifies which panel sent it. Curator now walks up from there to its enclosing modal and closes that one by id, through Filament's own close-modal event.
Reported as awcodes/mason#53. Present since the RichEditor integration shipped in v4.0.0; this is not a regression of the context scoping added in v5.0.3, which addressed a different problem in the same handler.
Filament 4 users are covered — 5.x serves both majors.
Removed the unused third parameter from CuratorPicker::relationship() .
CuratorPicker::relationship().The signature carried ?Closure $callback = null from the first commit that added relationship support, copied from Filament's Select::relationship() where the equivalent argument is $modifyQueryUsing. It was never referenced in the method body, so anything passed to it was silently discarded — worse than not offering it, since the call reads as though it does something.
No caller breaks: nothing in the package, its tests or its docs ever passed a third argument, and PHP accepts extra arguments to userland functions without error.
Dropped the Pint mb_str_functions rule and reverted the mb_ltrim() it had introduced in the Uploader form component.
mb_str_functions rule and reverted the mb_ltrim() it had introduced in the Uploader form component.mb_ltrim() is a PHP 8.4 function, and this package declares ^8.2. Below 8.4 it resolves only through symfony/polyfill-mbstring, which is not required directly, so a lock file resolving an older polyfill on PHP 8.2 or 8.3 could fatal with Call to undefined function mb_ltrim(). Affects v5.3.2.
docs/ directory.Fixes an SVG sanitization bypass and unvalidated curation paths. See GHSA-3xm3-q2fj-x8rq for the full advisory.
Fixes an SVG sanitization bypass and unvalidated curation paths. See GHSA-3xm3-q2fj-x8rq for the full advisory.
payload.txt was stored unsanitized. Sanitizing now keys off the detected type as well as the extension, in both the uploader and CuratorUtils::importMedia, and the serving layer pins Content-Type from the stored extension instead of the sniffed bytes.saveCuration() consumed the crop payload unvalidated, so a key such as ../../other could overwrite a sibling file inside the storage disk. The payload is now validated before anything is written.curator:sanitize-svgs now selects on the detected type as well as the extension, so rows stored under a spoofed filename are no longer invisible to it.Re-scan stored media to clean up anything already on disk:
php artisan curator:sanitize-svgs --dry-run # report only
php artisan curator:sanitize-svgsThe serving-layer change already prevents affected rows from rendering as documents, so this is cleanup rather than the primary fix.
exif form view. It was not referenced anywhere in the package and could not render as shipped, since it requested an Alpine component that is never registered. If you published views with vendor:publish --tag=curator-views, your local copy is untouched by this removal and still contains a raw {!! !!} echo of EXIF metadata — escape or delete it.Reported by Afsana Alijabarova (@afa114).
fix: align Media docblock with the nullable migration columns by @awcodes in #722
Full Changelog: v5.3.0...v5.3.1
Support Filament 4 and 5 from a single branch by @awcodes in #720
Full Changelog: v5.2.0...v5.3.0
The media picker's search now matches each term on its own instead of looking for the whole input as a single substring. A file named my-image.png cou
The media picker's search now matches each term on its own instead of looking for the whole input as a single substring. A file named my-image.png could not previously be found by typing "my image".
The search is split on whitespace, hyphens and underscores, and every term has to match — though any of the five searchable columns (name, title, alt, caption, description) may be the one matching it. So my-image.png is now found by "my image", "my_image", "my-image", or "image my", while my-document.png stays out of those results.
LIKE wildcards typed into the search are now escaped, so searching for 100% looks for a literal percent sign rather than matching everything. Worth knowing that _ has always been an unescaped single-character wildcard, so photo_8-sunset matched photo-8-sunset by accident while a hyphen did nothing; that inconsistency is gone.
Case sensitivity is still left to the database — Postgres LIKE is case-sensitive where MySQL and SQLite are not. That is unchanged from previous versions.
Thanks to @amywestlake for reporting this and proposing the original approach in #623.
Search results are now ordered by the panel's sort direction, matching the unfiltered list. There was no ORDER BY at all before, so the 50-row limit truncated an arbitrary slice of matches.
GliderFallback's setters were typed to require a value, while every property except the name is optional and every getter already returned null. Building a fallback from a conditional expression — the usual reason to have one — threw a TypeError in a service provider before the application could boot:
GliderFallback::make('logo')
->alt(config('app.name'))
->source(filled(setting('logo')) ? $logo->url : Vite::image('logo.webp'))alt(), height(), source(), type() and width() now all accept null.
A registered fallback that ends up with no source still cannot be rendered, but it now names itself — The [logo] glider fallback does not have a source. — instead of reporting the media item as invalid, which pointed at the wrong thing.
Thanks again to @battulga0719 for reporting this in #718.
No changes are required for ordinary use. Two notes:
GliderFallback and overrode any of the setters with the narrower string/int signature, widen it to ?string/?int to match the parent.0 into the picker search previously reset the list to show everything. It now searches for "0"; whitespace-only input is what falls back to the unfiltered list.Applications that already set acceptedFileTypes() themselves — globally or per field — were never affected, because the vulnerable list was only used…
Curator's upload components fell back to a default list of accepted file types that included text/html, application/xhtml+xml, text/javascript, application/xml and application/octet-stream. An authenticated user with permission to upload media could upload an HTML file containing a <script> tag. The file was stored unmodified and later served from the application's own origin, executing the script with the session of whoever opened it.
This affected the Media resource form, MultiUploadAction, CuratorPicker and the rich editor's attachment flow, which all share the same default. Applications that already set acceptedFileTypes() themselves — globally or per field — were never affected, because the vulnerable list was only used as a fallback.
The default is now MimeType::defaults(): the full list minus types that are effectively executable content. MimeType::toArray() is unchanged, so code referencing the enum directly still works. As defense in depth, media served through Curator's own route now sends X-Content-Type-Options: nosniff, and restricted types are forced to Content-Disposition: attachment.
If your application genuinely needs to host these types, opt back in explicitly:
use Awcodes\Curator\Enums\MimeType;
Curator::acceptedFileTypes([...MimeType::defaults(), 'text/html']);Note that Curator only sanitizes SVG uploads. Anything else you allow is stored and served verbatim, and with the default public disk it is also reachable through the storage symlink, where the new response headers do not apply. Serve deliberately-allowed executable types from a private disk.
The 3.x line is not affected — its accepted types come from config('curator.accepted_file_types'), whose default has always been limited to images and PDFs.
Glider fallbacks could not be used at all. Several faults compounded:
GliderFallback::make() passed the name through the container, but the class has no constructor, so Laravel discarded it. The name stayed null and getName() threw a TypeError, meaning a fallback built exactly as documented failed before it could be registered.<x-curator-glider> component rejected a null media item, which is the main reason to configure a fallback in the first place. It now accepts null.handleInt() checked the raw id rather than the looked-up record, so the fallback branch never ran and a missing record produced "Attempt to read property path on null" instead. The documented <x-curator-glider :media="1" fallback="thumbnail"/> could not work.Fallback resolution now happens in one place, so a null media item, an id that does not resolve, and a blank string all reach it. An unregistered fallback name, or one with no source, no longer dereferences null.
Also fixed while in there:
GliderFallback's optional getters were typed non-nullable while every property defaults to null, so a partially configured fallback threw. They are nullable now.GliderFallback::isPreviewable() called Curator::isResizable(), reporting svg sources as not previewable.Thanks to @battulga0719 for reporting the fallback issues in #717.
The media picker breadcrumb's root Disk label and the Selected Files heading in the selection modal were hardcoded in English and ignored the active l
curator::views.details.disk key, so it is already translated in every shipped locale.Adds a new attach_curator_media.modal.selected_files key under resources/lang/en/views.php. Other locales fall back to English until translated — contributions welcome.
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Fixes an SVG sanitization bypass and unvalidated curation paths. See GHSA-3xm3-q2fj-x8rq for the full advisory.
Fixes an SVG sanitization bypass and unvalidated curation paths. See GHSA-3xm3-q2fj-x8rq for the full advisory.
payload.txt was stored unsanitized. Sanitizing now keys off the detected type as well as the extension, in both the uploader and CuratorUtils::importMedia, and the serving layer pins Content-Type from the stored extension instead of the sniffed bytes.saveCuration() consumed the crop payload unvalidated, so a key such as ../../other could overwrite a sibling file inside the storage disk. The payload is now validated before anything is written.curator:sanitize-svgs now selects on the detected type as well as the extension, so rows stored under a spoofed filename are no longer invisible to it.Re-scan stored media to clean up anything already on disk:
php artisan curator:sanitize-svgs --dry-run # report only
php artisan curator:sanitize-svgsThe serving-layer change already prevents affected rows from rendering as documents, so this is cleanup rather than the primary fix.
exif form view. It was not referenced anywhere in the package and could not render as shipped, since it requested an Alpine component that is never registered. If you published views with vendor:publish --tag=curator-views, your local copy is untouched by this removal and still contains a raw {!! !!} echo of EXIF metadata — escape or delete it.Reported by Afsana Alijabarova (@afa114).
The media picker's search now matches each term on its own instead of looking for the whole input as a single substring. A file named my-image.png cou
The media picker's search now matches each term on its own instead of looking for the whole input as a single substring. A file named my-image.png could not previously be found by typing "my image".
The search is split on whitespace, hyphens and underscores, and every term has to match — though any of the five searchable columns (name, title, alt, caption, description) may be the one matching it. So my-image.png is now found by "my image", "my_image", "my-image", or "image my", while my-document.png stays out of those results.
LIKE wildcards typed into the search are now escaped, so searching for 100% looks for a literal percent sign rather than matching everything. Worth knowing that _ has always been an unescaped single-character wildcard, so photo_8-sunset matched photo-8-sunset by accident while a hyphen did nothing; that inconsistency is gone.
Case sensitivity is still left to the database — Postgres LIKE is case-sensitive where MySQL and SQLite are not. That is unchanged from previous versions.
Thanks to @amywestlake for reporting this and proposing the original approach in #623.
Search results are now ordered by the panel's sort direction, matching the unfiltered list. There was no ORDER BY at all before, so the 50-row limit truncated an arbitrary slice of matches.
GliderFallback's setters were typed to require a value, while every property except the name is optional and every getter already returned null. Building a fallback from a conditional expression — the usual reason to have one — threw a TypeError in a service provider before the application could boot:
GliderFallback::make('logo')
->alt(config('app.name'))
->source(filled(setting('logo')) ? $logo->url : Vite::image('logo.webp'))alt(), height(), source(), type() and width() now all accept null.
A registered fallback that ends up with no source still cannot be rendered, but it now names itself — The [logo] glider fallback does not have a source. — instead of reporting the media item as invalid, which pointed at the wrong thing.
Thanks again to @battulga0719 for reporting this in #718.
No changes are required for ordinary use. Two notes:
GliderFallback and overrode any of the setters with the narrower string/int signature, widen it to ?string/?int to match the parent.0 into the picker search previously reset the list to show everything. It now searches for "0"; whitespace-only input is what falls back to the unfiltered list.Applications that already set acceptedFileTypes() themselves — globally or per field — were never affected, because the vulnerable list was only used…
Curator's upload components fell back to a default list of accepted file types that included text/html, application/xhtml+xml, text/javascript, application/xml and application/octet-stream. An authenticated user with permission to upload media could upload an HTML file containing a <script> tag. The file was stored unmodified and later served from the application's own origin, executing the script with the session of whoever opened it.
This affected the Media resource form, MultiUploadAction, CuratorPicker and the rich editor's attachment flow, which all share the same default. Applications that already set acceptedFileTypes() themselves — globally or per field — were never affected, because the vulnerable list was only used as a fallback.
The default is now MimeType::defaults(): the full list minus types that are effectively executable content. MimeType::toArray() is unchanged, so code referencing the enum directly still works. As defense in depth, media served through Curator's own route now sends X-Content-Type-Options: nosniff, and restricted types are forced to Content-Disposition: attachment.
If your application genuinely needs to host these types, opt back in explicitly:
use Awcodes\Curator\Enums\MimeType;
Curator::acceptedFileTypes([...MimeType::defaults(), 'text/html']);Note that Curator only sanitizes SVG uploads. Anything else you allow is stored and served verbatim, and with the default public disk it is also reachable through the storage symlink, where the new response headers do not apply. Serve deliberately-allowed executable types from a private disk.
The 3.x line is not affected — its accepted types come from config('curator.accepted_file_types'), whose default has always been limited to images and PDFs.
Glider fallbacks could not be used at all. Several faults compounded:
GliderFallback::make() passed the name through the container, but the class has no constructor, so Laravel discarded it. The name stayed null and getName() threw a TypeError, meaning a fallback built exactly as documented failed before it could be registered.<x-curator-glider> component rejected a null media item, which is the main reason to configure a fallback in the first place. It now accepts null.handleInt() checked the raw id rather than the looked-up record, so the fallback branch never ran and a missing record produced "Attempt to read property path on null" instead. The documented <x-curator-glider :media="1" fallback="thumbnail"/> could not work.Fallback resolution now happens in one place, so a null media item, an id that does not resolve, and a blank string all reach it. An unregistered fallback name, or one with no source, no longer dereferences null.
Also fixed while in there:
GliderFallback's optional getters were typed non-nullable while every property defaults to null, so a partially configured fallback threw. They are nullable now.GliderFallback::isPreviewable() called Curator::isResizable(), reporting svg sources as not previewable.Thanks to @battulga0719 for reporting the fallback issues in #717.
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
See GHSA-3xm3-q2fj-x8rq for the full advisory.
See GHSA-3xm3-q2fj-x8rq for the full advisory.
image/svg+xml under an inline disposition and could render as a document in the application's origin. The serving layer now pins Content-Type from the stored extension, forces a download when the extension is unknown and the sniff would render as a document, and sends X-Content-Type-Options: nosniff throughout.saveCuration() consumed the crop payload unvalidated, so a key such as ../../other could overwrite a sibling file inside the storage disk. The payload is now validated before anything is written.curator:sanitize-svgs could never finish on larger libraries. Its two selection criteria were or'd at the top level, so chunkById's and id > ? bound to only one of them and every type-matched row was handed back on each pass. Any library with more than 100 SVG records looped until the command was killed. If a scan appeared to hang on an earlier version, re-run it.The upload-side half of the advisory does not affect 3.x: its uploader already decided whether to sanitize from the detected type rather than the filename.
php artisan curator:sanitize-svgs --dry-run # report only
php artisan curator:sanitize-svgsReported by Afsana Alijabarova (@afa114).
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Your coding agent can read these notes before it upgrades. Set up the MCP server →