Media
This document explains the package's media library, which holds every file the site uses. It covers the data model, image conversions, how a block refers to a file, replacing a file, finding where a file is used, uploading, alt text, the focal point, SVG sanitizing, configuration and the MCP tools. It also states the rules that keep a media id stored in a block resolving to the same image years later. It is written for developers. Editors should read For content authors, which describes the same screen step by step.
The data model
A file in the library is a MediaItem row with exactly one Spatie media record attached to it, in the file collection. The editor names and tags the MediaItem.
Blocks store the Spatie media id, not the MediaItem id. The panel shows a library item, while the block payload holds the id of a different row, so the two numbers differ. cms_list_media returns both ids and labels each one. In content, media_id always means the Spatie media id.
The two rows hold different data:
| Row | Holds |
|---|---|
| MediaItem | The name, the alt text and the tags. This editorial metadata can change without the image changing |
| Spatie media record | The file's bytes, its mime type and its conversions |
Blocks store the Spatie media id because the file is what a block renders.
Conversions
MediaItem::registerMediaConversions() registers three conversions, which are generated at upload time:
| Name | Longest edge | Used for |
|---|---|---|
| thumb | 400px | The library grid, the block picker, small inline images |
| medium | 1000px | Most in-page images |
| large | 2000px | Heroes, full-bleed bands, and any image that spans the viewport |
Each conversion uses Fit::Max, so the image is scaled to fit inside the box and is never cropped or enlarged. A 300px original stays 300px in all three conversions.
SVG files
SVG files have no conversions. An SVG has no meaningful raster size, and the conversions previously generated for SVG files produced a poorly rasterized logo. An SVG resolves to its original file at every size.
Output format
PNG, WebP and GIF files keep their own format in their conversions. Every other format is converted to JPEG, which is the media library's default and suits a photograph. JPEG has no alpha channel, so before this rule a transparent logo came back with its transparent areas filled in black. No error was raised, and the converted file stayed wrong. PNG, WebP and GIF are the three formats that can carry transparency and that the media library can keep, so they are converted to their own format.
The rule tests the file's format, not its pixels. A PNG with no transparent pixel still stays a PNG, which makes its conversions slightly larger than the JPEG they would otherwise have been. Testing the alpha channel would require a disk read every time conversions are registered, and conversions are registered far more often than they are generated.
Conversions are nonQueued(), so they exist by the time the upload request returns, and a newly uploaded file never shows a broken thumbnail. The cost is a longer upload for a large image, which is accepted because an editor is looking at the library while uploading.
Requesting a URL
Do not call getFullUrl($conversion) directly. Use MediaUrl:
use Mcg\FilamentCms\Support\MediaUrl;
MediaUrl::for($media, 'medium'); // that conversion, or the original
MediaUrl::firstOf($media, 'large', 'medium'); // the first conversion that exists
MediaUrl::srcset($media, ['medium' => 1000, 'large' => 2000]);
Spatie's two checks read different sources:
hasGeneratedConversion()reads a column that records which conversions were generated at upload time.getFullUrl($name)resolves against the conversions the model registers at the moment of the call.
A file uploaded before a conversion was removed returns true from the first and throws InvalidConversion from the second. When the SVG conversions were removed, this turned each poorly rasterized logo into a 500 error on every page that rendered it. MediaUrl performs both checks and falls back to the original file, so it keeps working when the registered conversions change after files are already on disk.
Referring to a file from a block
A block field that holds a media id is declared like any other integer:
public function rules(): array
{
return ['media_id' => ['required', 'integer']];
}
The schema describer (BlockSchemaDescriber) tags the field as a media id so an MCP client knows what the number means. It applies the tag by field name only, to these names:
media_id- any name ending in
_media_id - the two list fields
imagesandlogos
A media field with any other name is described as a plain integer. An agent writing that block then puts an arbitrary number in the field, and the block renders nothing. Name media fields according to this convention.
Resolve ids with MediaResolver. It keeps the order the author chose and silently drops ids that no longer exist:
app(MediaResolver::class)->resolve($data['images'] ?? []);
MediaResolver drops a missing id instead of throwing an exception because a version is immutable: a reference to a deleted file in a stored version can never be rewritten. A page that renders a gap is preferred to a page that returns a 500 error.
Every block that holds media ids must report them through mediaIdsFrom():
public function mediaIdsFrom(array $data): array
{
return array_values(array_map('intval', $data['logos'] ?? []));
}
The returned ids are stored in media_refs on the version. The usage report and the delete guard read media_refs for blocks, so a block that does not implement mediaIdsFrom() is invisible to both.
The same naming convention applies to the fields a content type declares in attributeSchema(). Those values are stored in the version's attributes column, and MediaReferences finds media ids there by field name alone; see Finding where a file is used.
Replacing a file
The Replace file action changes the file's bytes and keeps its id. Blocks, seo.og_image, content type fields in attributes, five columns on site_settings, menu item images and columns in the application's own tables store that id, so if the id changed, every one of those references would point at nothing, and the ones in immutable versions could not be repaired. The file's URL does change, so a browser that cached the old URL may need a refresh.
Before MediaFileReplacer existed, uploading over a file changed its id. Spatie's ->singleFile() prunes the collection, and Filament's uploader deletes the abandoned row, so both a replacement and saving a crop from the image editor inserted a new row with a new id and left every existing reference pointing at nothing. MediaFileReplacer prevents both. Spatie has no public replace API (updateMedia() changes only names and custom properties), so MediaFileReplacer combines the public primitives.
Use Replace file for a better version of the same asset. Upload a new file for a different asset.
Finding where a file is used
MediaReferences (src/Support/MediaReferences.php) finds every use of a file in six sources. A delete is safe only when none of the six references the file:
| Source | What it covers | MediaReference kind | Label in the media library |
|---|---|---|---|
| 1. mediarefs on a version | Blocks | content | The record's path, for example /about |
| 2. seo.ogimage on a version | The share image | content | The record's path |
| 3. attributes on a version | The fields a content type declares in attributeSchema(), and the standard Navigation image field (navimagemediaid) | content | The record's path and the field, for example /songs/opener (audiomediaid) |
| 4. Five columns on sitesettings: logomediaid, logodarkmediaid, faviconmediaid, favicondarkmediaid and defaultogimageid | The logo, the favicon, their dark-theme variants and the default share image | sitesetting | The setting's name, for example Logo (dark theme) |
| 5. imagemediaid on cmsmenuitems | The image on a menu item, set in the Menus editor | menuitem | The menu's name and the item's label, for example Menu: Main βΊ Services |
| 6. Columns in the application's own tables | A column with a foreign key to the media table, an integer column named mediaid or ending in mediaid, or a column listed in media.references.include. See Media ids in application tables | apprecord | The managing resource's model label and the record's title, for example Product: Unisex WTN Street Sign Tee, or the table and key, for example appbanners #3 |
Sources 1 to 3 are read from the published version and the draft version each content row currently points at. Source 5 counts every menu item that holds an id, including an item whose Active toggle is off, because the item keeps the id and shows the image again when it is reactivated. Source 6 counts every row that holds an id, including a soft-deleted row, because the row still holds the id and its foreign key still acts when the file is deleted.
The Usage tab of a file links a content reference to the record's editor, a menu_item reference to the menu's edit screen, and an app_record reference to the edit page of the Filament resource that manages the table, when one exists and the signed-in user may view that resource (see Labels and links). A site_setting reference is plain text.
Source 6 is listed by name for at most 25 rows per column per file, the value of MediaReferences::APP_RECORD_LIMIT. The rows past that limit are counted, not labeled, and appear as follows:
| Where | How the rows past the limit appear |
|---|---|
| The Usage tab | One final, unlinked entry reading and 5 more, with the count summed over every column |
| The IN USE badge's tooltip | A trailing clause on the list, for example /about, Product: Tee and 5 more |
| The Delete action's tooltip and the Replace file dialog | A trailing clause on the sentence, for example Used by /about, Product: Tee and 5 more. |
| The cmsdeletemedia refusal | A trailing clause on the list of rows, for example 30 application record(s): Product: Product 1 (appproducts.covermediaid), β¦ and 5 more |
| The result of a forced cmsdeletemedia | apprecords holds the first 25 rows per column; apprecordstotal holds the number of rows in all |
The cms_delete_media MCP tool refuses a delete for references of kind content on a published version (MediaReferences::pagesIn()) and for references of kind app_record (MediaReferences::appRecordsIn()), unless force: true is passed. Both lists come from one MediaReferences::usedBy() call, so the tool scans once. Drafts, site settings and menu items do not stop it. The panel's Delete action, bulk delete and Unused only filter, and the media picker's Unused only filter, use all six sources.
media_refs is computed from blocks only, by VersionManager::save() calling each block's mediaIdsFrom(), so a share image, a content type field, the site logo, a menu image or an application row does not appear in it. A delete that checked only media_refs would break the uses that column omits.
The scan runs four queries for sources 1 to 5 whatever the number of files or versions: one on content, one on content_versions, one on site_settings and one on cms_menu_items joined to cms_menus and content. Source 6 is read in one of two ways, depending on what the caller needs:
- Ids only.
MediaReferences::usedMediaIds()answers "is this file referenced". The two Unused only filters call it, and so do the Delete action's disabled state and the bulk delete guard throughManageMedia::isInUse(), unless the labeled usage for the file was already built earlier in the same request, in which caseisInUse()reads that. It reads each detected column with oneselect distinct {column} from {table} where {column} is not nullquery and builds no label, no link and no model, so its cost does not grow with the number of rows. A column listed inmedia.references.includeis read in full and its values decoded instead, because it may hold JSON, which PostgreSQL cannot compare withdistinct. - Labeled.
MediaReferences::usedBy()(one file) andusedByMany()(several files) build the labels and links for the Usage tab, the tooltips andcms_delete_media. For one file, each detected column is read withwhere {column} = ? limit 26, and acount()query runs only when the 26th row exists. For several files, each column is read with one groupedcount(*)query, then the rows of every file at or under the limit in chunks of 500 ids, then onelimit 25query per file over the limit. A column listed inincludeis read in full, as above, and only the first 25 matching rows per file are labeled.
With 10,000 rows in app_products referencing one file, measured on SQLite in the package's test suite, usedBy() took 893 ms before the limit and 18 ms after it, and usedMediaIds() took 895 ms before and 7 ms after.
Media ids in content type attributes
MediaReferences::attributeMediaIds() reads a version's attributes column and treats a value as a media id when its field name matches the convention in Referring to a file from a block, which MediaReferences::isMediaFieldName() implements:
media_id- any name ending in
_media_id - the two list fields
imagesandlogos
The rules for reading the values are these:
- A field with a matching name may hold one id or a list of ids, so a
MediaOptions::picker('gallery_media_id')->multiple()field is covered. - A value counts only when it is a positive integer or a string of digits.
MediaReferences::asMediaId()makes that test, so a URL stored under a matching name is not counted. - Nested arrays are searched at any depth, so a media field inside a
Repeateritem is found, and so is an array under a matching name, such asimages: [{media_id: 5}]. The reported field name leaves out list indexes and Filament's UUID item keys: an id in a repeater item readstracks.cover_media_id. - A field whose name does not match the convention is not read, whatever it holds. A
MediaOptions::picker('photo')field is therefore invisible to the IN USE badge in the media library and to the delete guard. The match is exact:media_ids,social_media_id_labelandnot_a_media_idxdo not match. Name every media field inattributeSchema()according to the convention.
The scan matches field names in the stored data instead of reading attributeSchema() for two reasons:
- Versions are immutable, and the stored data outlives the schema. A field removed from a type, or a content type that is no longer registered, leaves its values in every version already written, and those files must still count as in use for as long as the versions are current.
- Filament builds a component's child schema by evaluating its
schema()argument, which may be a closure that expects a form, a record or aGetcallback. Outside a rendered form,attributeSchema()cannot be walked reliably to find everyMediaPicker.
The scan runs when the badge or a delete guard asks, and reads the attributes column in the same query that already reads media_refs and seo. Versions written before version 0.81.3 are therefore covered without being saved again, and reading attributes adds no query.
LaravelPageCache::put() also indexes each cached page under the media ids in its published version's attributes, using the same MediaReferences::attributeMediaIds(). When a file is replaced or its focal point changes, MediaCachePurge::for() purges the page cache as follows:
| The file's references include | Purge |
|---|---|
| A site setting (kind sitesetting) | The whole cache is flushed |
| A menu item image (kind menuitem) | The whole cache is flushed |
| A page's Navigation image (field attributes.navimagemediaid) | The whole cache is flushed, because DerivedMenuItems::navImageId() shows it in the derived menu on every page |
| A row in an application table (kind apprecord) | The whole cache is flushed, because application data reaches pages through a layout, a block or a route resolver, and only a block can declare the ids it shows to the page cache's index |
| None of the above: only blocks, seo.ogimage or other attribute fields | forgetMedia() forgets each cached page whose published version holds the id |
A page cached before version 0.81.3 is not indexed under its attribute media ids; run php artisan cms:cache-clear after updating. See Caching.
Each reference from an attribute is labeled with the record's path followed by the field name, for example /songs/opener (audio_media_id). The Usage tab of a file links that label to the record's editor.
Media ids in application tables
AppMediaColumns (src/Support/AppMediaColumns.php) finds the columns in the application's own tables that hold a media id. It reads the database schema of the default connection, not the application's model classes, and applies three rules:
- A column with a single-column foreign key to the media table's primary key is a reference. The media table is the table of the model in
media-library.media_model,mediaby default. This migration line creates such a key:
$table->foreignId('cover_media_id')->nullable()->constrained(table: 'media')->nullOnDelete();
- A column without such a foreign key is a reference when its name is
media_idor ends in_media_idand its type is an integer type. The namesimagesandlogos, which the attribute rule above accepts, are not used for columns. - A column listed in
media.references.includeis a reference.
These tables are not read by the rules:
- The package's tables:
content,content_versions,redirects,site_settings,media_items, and every table whose name starts withcms_. Sources 1 to 5 read the package's references directly. - The media table itself. Its
model_typeandmodel_idcolumns attach each file to itsmedia_itemsrow and are not references. - Tables and columns listed in
media.references.ignore.
Only the current schema of the default connection is read: the database named in the connection on MySQL, main on SQLite, and, on PostgreSQL, the first entry of the connection's search_path setting in config/database.php, falling back to its schema setting, then public, with $user replaced by the connection's username.
What a foreign key does when a file is deleted
When a file is deleted, Spatie deletes its row in the media table, and the database then applies each referencing foreign key's ON DELETE action:
| Action | Effect on the application row |
|---|---|
| SET NULL (nullOnDelete()) | The column is set to NULL, and the association is lost with no message |
| CASCADE (cascadeOnDelete()) | The row is deleted |
| RESTRICT or NO ACTION (the default for constrained()) | The database refuses the row's delete. DeleteMediaTool::handle() runs the whole delete inside DB::transaction(), so this rolls back the file's own media row as well: the file is not deleted either |
The delete guards refuse a file that an application row references before any of these actions can run. cms_delete_media with force: true bypasses that guard: under SET NULL or CASCADE the file is deleted and the column's action runs; under RESTRICT or NO ACTION the database raises a QueryException, the transaction rolls back, and restrictedDelete() returns an error naming the row as {label} ({table}.{column}) with nothing deleted.
DeleteMediaTool::isForeignKeyRefusal() treats the QueryException as a restricting foreign key only when its SQLSTATE is 23000 (MySQL, MariaDB and SQLite) or 23503 (PostgreSQL). Any other database error, such as a lost connection, is rethrown rather than reported as a foreign key.
The transaction restores database rows only. Spatie removes a media row's files from the disk after that row is deleted, so for a library item that holds more than one media row, when a foreign key refuses the second row's delete, the first row is restored but the files Spatie already removed for it are not.
Labels and links
Each row is labeled from the Filament resource whose model uses the row's table and connection. Resources are searched on the current panel first, then on the CMS panel, then on the other panels in the order they were registered. Panels with tenancy are skipped, because their URLs need a tenant.
| Situation | Label | Link |
|---|---|---|
| A resource matches, the user may view it, and the row has a title | The resource's model label and the title, for example Product: Unisex WTN Street Sign Tee | The resource's edit page, or its view page when it has no edit page |
| A resource matches, the user may view it, and the title is empty | The model label and the key, for example Product #12 | As above |
| A resource matches and the user may not view it | The model label and the key, for example Product #12 | None |
| No resource matches | The table and the key, for example appbanners #3 | None |
| No resource matches and the table has no single-column primary key | The table and the column, for example gallerymedia.mediaid | None |
"The user may view it" means the resource's own canViewAny() returns true. AppRecordResources::canView() evaluates it once per resource per scan, so labeling a row adds no authorization check. canViewAny() asks about the user on the panel's guard, and the user is resolved as follows:
- A user is signed in to the panel. The result of
canViewAny()applies. With a model policy, that result is the policy'sviewAny()method; with no policy, Filament allows the view. - No user on the panel's guard, but one on the default guard. This is an MCP request authenticated with a Sanctum token. That user is set on the panel's guard for the one
canViewAny()call and removed afterwards, so the policy's result for that user applies. - No user on either guard. This is the stdio MCP transport, a queued job or an Artisan command. No title and no link are shown. Filament's
get_authorization_response()allows a null user when the model has no policy, which would show every record title to a caller with no authenticated user, so the package does not call it in this case.
The title is read from the first of these that is a column of the table: the resource's $recordTitleAttribute, name, title. A record title that a model accessor produces is not available, because the scan reads columns without loading models. ManageMedia::usageKey() keys an application row's Usage tab entry as app:{table}:{record key}, so two records with the same title, for example two products both titled "Tee", appear as two entries, each linked to its own edit page. A table with no single-column primary key has no record key, so every row from that table shares the one entry described above instead of getting its own. Every other kind of reference is still keyed by its label, so a page that uses the same file in two blocks remains one entry.
AppRecordResources also memoizes, per table, the title column and the route key column. Reading the route key column calls the model's getRouteKeyName(), which needs a model instance, so the instance is created once per table rather than once per row.
Configuration
Two keys in config/cms.php, inside media, adjust what is detected:
'references' => [
// A table, or one column, to leave out.
'ignore' => [
'legacy_imports',
'profiles.social_media_id',
],
// Columns neither rule finds.
'include' => [
'events.poster',
'events.gallery',
['connection' => 'shop', 'table' => 'items', 'column' => 'photo_media_id'],
],
],
ignoretakes a table name ortable.columnon the default connection. Use it for an integer column whose name matches the convention but which stores another system's id: every value in such a column that equals a media id is otherwise counted as a use.includetakestable.columnon the default connection, or an array withconnection,tableandcolumn. An entry whose table or column does not exist is skipped, and a warning is logged.- An entry in
includeis counted even when its table is one detection otherwise skips: thecms_*tables,content,content_versions,redirects,site_settings,media_itemsand the media table, or a table or column listed inmedia.references.ignore. Listing a column the package already reads through another source, for examplecms_menu_items.image_media_id, therefore counts each of its rows twice: once as a menu item (source 5) and once as an application row (source 6). - A column listed in
includemay be a JSON column. The scan reads one id, a list of ids such as[3, 5], or an object searched by field name asattributesis, so{"hero": {"cover_media_id": 5}}yields 5.
A change to either list applies on the next request: both lists are part of the cache key.
Caching
Detection runs inside the first scan that needs it, and the result is cached in the page cache's store (cache.store) for 24 hours under the key cms:media-references:app-columns:{hash}. The hash covers the two lists, the name of the default database connection and the name of its database, so two applications or two databases that share one cache store each keep their own list. The cached list is forgotten, and detected again by the next scan, when:
php artisan migrate,migrate:rollback,migrate:reset,migrate:refreshormigrate:freshruns at least one migration (Laravel'sMigrationsEndedevent);php artisan migrateloads a schema dump (SchemaLoaded);php artisan cms:cache-clearruns.
A column added outside a migration, for example by hand in a database client, is picked up within 24 hours, or at once after cms:cache-clear.
The forget on migrate reaches other instances only when the cache store is shared. An instance whose cache.store is local to it, such as the file or array driver, keeps its cached list until the 24 hours pass or cms:cache-clear runs on that instance.
When the schema cannot be read
If reading the schema fails, for example because the database user may not read information_schema, the media library keeps working with sources 1 to 5. AppMediaColumns logs one warning that begins "CMS could not read the database schema to find application columns that reference media", caches an empty list for 5 minutes, and tries again after that. While the list is empty, files used only by application tables are not marked as in use and are not protected by the delete guards.
If a detected column is dropped while the list is cached, the scan skips it, logs a warning, and forgets the list so that the next scan detects it again.
What is not detected
- A media id inside a JSON column. List the column in
media.references.include. - A polymorphic reference, such as an
attachable_type/attachable_idpair whose type names the media model. Listing the_idcolumn inincludecounts every value in it, whatever the type column holds, so it is correct only when every row points at media. Use a dedicated*_media_idcolumn with a foreign key instead. - A table on another database connection. List the column in
includewith itsconnection. - **A table in a PostgreSQL schema other than the first one listed in the connection's
search_pathsetting.** Put the schema that holds the table first insearch_path; see the$usercase below for how Laravel resolves that setting. - PostgreSQL with
$userfirst in the connection'ssearch_path. Detection reads the tables ofSchema::getCurrentSchemaName(), called fromAppMediaColumns::detect()insrc/Support/AppMediaColumns.php. Laravel'sIlluminate\Database\Schema\PostgresBuilder::getCurrentSchemaListing()parses thesearch_pathsetting from the connection's configuration inconfig/database.php, falling back toschemathenpublic, and replaces$userwith the connection'susername. When that setting is, for example,'"$user", public'and no schema named after the database user exists, detection lists the tables of a schema that does not exist, finds no columns, and logs no warning, because no exception is thrown. Laravel's defaultsearch_pathofpublicis not affected. Put the schema that holds the application's tables first insearch_path, or list its columns inmedia.references.include. - A column with any other name and no foreign key, such as
hero_image. List it ininclude.
Drafts
Drafts count as uses. This differs from page deletion, which ignores drafts so that an editor's experiments cannot block deleting a page. For a file, the question is whether it can be removed in a bulk delete. A draft that referenced a deleted file would break without an error, with no editor watching, so the delete is refused.
Uploading
Files enter the library in three ways. All three go through Support\MediaUploader, so each produces an identical library row: the same name derived from the filename, the same attribution and the same collection.
| Route | Details |
|---|---|
| Media in the panel | Accepts several files at once. Names come from the filenames. Alt text is written afterwards, when the grid shows which files lack it |
| The media picker inside a block | Lets an editor add a missing image without leaving a half-written page. The picker sorts newest first, so the uploaded file is the first tile |
| cmsuploadmedia over MCP | Accepts a file path or base64 data. Always set alt |
MediaUploader creates the MediaItem before attaching the file, and deletes it again if the attach fails, because a library row with no file would show as a broken tile permanently.
Alt text
Alt text is written on the library item, not on the block. Each file has one description, used everywhere the file appears. The image blocks also fall back to it when a file is missing.
A file without alt text has the badge No alt text in the library, so the omission is visible to editors before a screen-reader user encounters it. Alt text is the only media field whose absence has a direct accessibility cost, so it should be filled in first.
Focal point
The focal point marks the part of an image that must stay visible when the image is cropped. Every conversion uses Fit::Max, and every placement that crops to cover its box crops from the center by default, so without a focal point a hero whose subject sits to one side loses the subject.
Setting a focal point
- In the admin panel, open Media and click Edit on the file. The edit slide-over opens.
- On the Details tab, under Focal point, click the part of the image that must stay visible. With the image focused, the arrow keys move the point by 1 percent, Shift with an arrow key moves it by 10 percent, and Home returns it to the center.
- Click Save changes.
Behavior
The focal point is stored on the file, not on the block, like alt text. It therefore applies everywhere the file is used, including pages published years earlier. Correcting a crop once corrects it on every page that shows the image.
The focal point affects only placements that crop, such as a hero background, a card image or a carousel slide. An image shown whole is unchanged, because nothing is cropped.
The center is stored as the absence of a focal point, which is how an untouched file already behaves. Reset to center therefore clears the value instead of writing one, and the rendered HTML returns to exactly what it was before.
The focal point is rendered as object-position on the <img> element the theme already outputs. No file is reprocessed and no conversion is generated, so it works on files uploaded before the feature existed.
SVG sanitizing
SVG uploads are sanitized when they are stored. An SVG is a document rather than an image: files are served from the site's own origin, and the File tab of the edit slide-over shows the file's URL as a copyable field, so a <script> inside an SVG would run in the session of whoever opened it.
The sanitizer removes:
<script>,<foreignObject>, and any element the SVG specifications do not define;on*event handler attributes;hrefandxlink:hrefvalues with a scheme that executes code, including a nesteddata:image/svg+xml;url(β¦)references that point anywhere other than a fragment in the same file;- CSS containing
expression(),behavior:,-moz-bindingor@import, in a<style>element or astyleattribute; - an animation that targets
href, which would rewrite a link after the page has loaded.
The sanitizer leaves ordinary artwork untouched: gradients, use references, embedded raster images, external links, titles, and every geometry and paint attribute. A file with nothing to remove is stored byte for byte.
Sanitizing applies to every write path: the Media screen, the media picker's upload button, cms_upload_media, and Replace file. An SVG that is not well-formed XML is refused with a message stating the reason. A browser would show a parse error for such a file in any case.
Sanitizing files stored before this feature existed
- In a terminal at the site's root, list the files that contain executable content:
php artisan cms:sanitize-svg --dry-run
- In the same terminal, rewrite them:
php artisan cms:sanitize-svg
The command keeps each file's media.id, name and mime type, so every block, every seo.og_image and every stored version that references the file keeps working. A file the command cannot parse is left unchanged and reported, and the command then exits with a non-zero status.
Configuration
| Key | Default | Effect |
|---|---|---|
| media.disk | public | The filesystem disk. Any disk Flysystem supports |
| media.maxuploadkb | 10240 | The largest upload the panel accepts, in kilobytes |
| media.sanitizesvg | true | Removes executable content from an SVG before storing it |
| media.references.ignore | [] | Application tables (table) or columns (table.column) that are not counted as media references |
| media.references.include | [] | Application columns counted as media references although neither detection rule finds them. See Media ids in application tables |
To store media in an S3-compatible bucket, such as Laravel Cloud Object Storage, Amazon S3 or Wasabi, follow the steps in Storage.
Turn sanitize_svg off only if media is served from a separate origin, where a script in an SVG has no session to reach.
Keep max_upload_kb at or below media-library.max_file_size, which defaults to 10 MB. Spatie throws FileIsTooBig from inside its file adder, so a file that max_upload_kb allows and max_file_size refuses produces a 500 error instead of a validation message on the field.
On a remote disk, TemporaryUploadedFile::getRealPath() does not return a local path. See Pitfalls.
Accepted file types
The Media screen, the replace action and the Upload button beside a media field accept the types in MediaResource::ACCEPTED_TYPES (src/Resources/MediaResource.php):
| Kind | MIME types |
|---|---|
| Images | image/jpeg, image/png, image/webp, image/avif, image/gif, image/svg+xml |
| Documents | application/pdf |
| Audio | audio/mpeg (MP3), audio/mp4 and audio/x-m4a (M4A), audio/aac, audio/wav and audio/x-wav, audio/ogg, audio/flac |
Audio was added in 0.81.2. Before that release the browser refused an MP3 before sending it, although the Audio block plays files from the library.
The server checks a file's type with Laravel's mimetypes: rule, using the type Livewire detects. Livewire runs finfo over the first 64 KB of the file only. An MP3 whose ID3v2 tag is longer than 64 KB, when the tag holds cover art, is detected as application/octet-stream, because the first MPEG audio frame lies after the tag and outside the bytes finfo receives. Since 0.81.3 the upload fields use Mcg\FilamentCms\Forms\Components\MediaFileUpload. A file that Laravel's mimetypes: rule refuses is accepted only when all five of these hold:
- The accepted list contains
audio/mpeg. - Livewire detected
application/octet-stream. - The original filename ends in
.mp3. - The file starts with a well-formed ID3v2 header.
- finfo reports
audio/mpegfor the 4,096 bytes that follow the tag.
In 0.81.2 such an MP3 was refused with "must be a file of type: β¦", although the list in that message includes audio/mpeg.
Every upload is checked twice: first by Livewire's temporary-upload rule, then by the field's cms.media.max_upload_kb. When the site has not set livewire.temporary_file_upload.rules, the package sets it to ['required', 'file', 'max:N'], where N is cms.media.max_upload_kb or 12288, whichever is larger. Without that, Livewire's default of 12288 KB refused larger files before the media library's limit was checked. A site that sets the Livewire rule itself keeps its own value.
APP_URL
APP_URL is not a media key, but an incorrect value breaks every image on the site at once. Media URLs are absolute and built from it: the public disk's URL is APP_URL followed by /storage, so APP_URL sets the host and the scheme of every <img> on the site.
An incorrect APP_URL fails in two ways, and neither reports an error:
| Fault | Effect | Detection |
|---|---|---|
| Wrong host, for example the skeleton default http://localhost:8000 left in place | Every uploaded image points at a machine that is not serving the page | cms:install warns about this case |
| Wrong scheme, an http:// address on a site served over https | Every image is mixed content, which browsers block without a visible error. The page renders, the images do not, and only the browser console reports it | cms:install cannot check this in general, because at install time there is no request and the package cannot know whether the site will be served over TLS. It warns about the one combination it can infer: an http:// address on a .test host, which Herd and Valet serve over HTTPS by default |
After changing APP_URL, run php artisan config:clear in a terminal at the site's root. A cached configuration keeps the old value, and the images stay broken.
Over MCP
The MCP server has four media tools: cms_list_media, cms_upload_media, cms_update_media and cms_delete_media. See The MCP server.
cms_list_media returns both ids for each file, the MediaItem id and the Spatie media id. Blocks need the Spatie media id, and an agent given only the MediaItem id would write a page that renders no image.
cms_update_media accepts focal_point as {"x": 25, "y": 70}, in percentages from the top left corner, and cms_list_media reports it, with null for a file that has no focal point. Send {"x": 50, "y": 50} to clear it. A value outside 0 to 100 is refused rather than clamped: a caller that sends 400 most likely meant pixels, and storing the edge of the image instead would appear to have succeeded.