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 the package's docs/authoring.md, 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.
The two rows hold different data:
MediaItemholds the name, the alt text and the tags. This editorial metadata can change without the image changing.- The Spatie media record holds 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, so a block that does not implement mediaIdsFrom() is invisible to both.
Replacing a file
The Replace file action changes the file's bytes and keeps its id. Blocks, seo.og_image and five columns on site_settings store that id, so if the id changed, every one of those references would point at nothing and 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 finds every use of a file in three sources. A delete is safe only when none of the three references the file:
media_refson a version, which covers blocks.seo.og_imageon a version, which covers the share image.- Five columns on
site_settings:logo_media_id,logo_dark_media_id,favicon_media_id,favicon_dark_media_idanddefault_og_image_id.
media_refs is computed from blocks only, so a share image or the site logo does not appear in it. A delete that checked only media_refs would break the uses that column omits.
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 |
| cms_upload_media 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.max_upload_kb | 10240 | The largest upload the panel accepts, in kilobytes |
| media.sanitize_svg | true | Removes executable content from an SVG before storing it |
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 Known traps.
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:
- Wrong host, for example the skeleton default
http://localhost:8000left in place. Every uploaded image points at a machine that is not serving the page.cms:installwarns about this case. - Wrong scheme, an
http://address on a site served overhttps. 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:installcannot 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: anhttp://address on a.testhost, 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.