Skip to main content

Documentation

No results found.
Features

Media Library

WebProCMS has one media library, and every image on the site goes through it. Posts, events, locations, content items, design library row image fields — they all reference the same media_items table. There are no per-entity uploads, no scat...

WebProCMS has one media library, and every image on the site goes through it. Posts, events, locations, content items, design library row image fields — they all reference the same media_items table. There are no per-entity uploads, no scattered file inputs, and no orphaned copies of the same logo in five different folders. Editors upload an image once, fill in alt text and caption once, and pick it from a single picker anywhere it's needed.


The problem

CMSes typically grow image upload capability one feature at a time. Blog posts get a "featured image" field with its own upload widget. Events get another. Team members get another. Each form stores its file somewhere different, often under a folder named after the feature ("blog/", "events/", "team/"). Alt text gets re-typed every time the same headshot is reused. When the editor renames "Team" to "Staff" the folder name is locked in forever, or a migration scrambles every URL on the site.

The fallout shows up six months in:

  • The same image is uploaded four times because the editor can't see what's already in the library.
  • Alt text is inconsistent across uses of the same photo.
  • Replacing a logo means hunting through every page and re-uploading it everywhere it's used.
  • A category rename either leaves a stale folder name on disk or breaks every reference.
  • Deleting an image leaves dangling references behind because nothing tracked who was using it.

WebProCMS is built around the opposite invariant: the media library is the single source of truth for every image, and the rest of the system references it.

The fix

A single media_items table holds the path, alt text, caption, dimensions, and category for every uploaded image. Every entity that needs an image references media_items — by foreign key (featured_media_id), pivot table (content_block_media), or stored path (content_overrides.value). Every place an image can be picked uses the same picker modal. Replacing or deleting an image goes through one flow that knows about all consumers, warns the editor with a usage count, and cleans up references afterwards.

Storage on disk uses year/month folders (storage/app/public/{Y}/{m}/) rather than category-named folders. Categories are renameable in the UI; year/month folders are stable forever, regardless of how many times the editor reorganises their library.

How it works

Storage layout: year/month folders

Uploads land in storage/app/public/{Y}/{m}/ based on upload date — 2026/04/foo.jpg, never blog/foo.jpg or team-photos/foo.jpg. The category an image belongs to is metadata on the media_items row, not a path component, so renaming a category is a single UPDATE and never touches a single byte of disk.

The MediaReorganizeByDateCommand artisan command moves any pre-existing files into this layout and updates content_overrides.value references in the same pass. Idempotent — safe to run repeatedly.

ImageResizer::resizeToMaxWidth($path, 2400) runs on every upload to cap the original at 2400px wide. See the responsive image pipeline doc for the full story on what happens to images downstream.

Media is served at /storage/* through the public/storage symlink (storage:link) that points at storage/app/public/. On hosts whose PHP disables symlink() for the web SAPI (RunCloud's stock disable_functions list does), both the installer and the dashboard repair fall back to running php artisan storage:link in a CLI subprocess, whose default php.ini allows it — the subprocess env drops PHP_INI_SCAN_DIR so the per-app web ini (the thing carrying the restriction) is not inherited. Only when that also fails (no proc_open, no reachable CLI binary) does the install "succeed" without a link, leaving every /storage/* file 404ing: dead logos, hero images, and galleries.

StorageLinkHealth turns that silent failure into a visible, one-click-fixable alert:

  • isHealthy() — true when public/storage resolves to the media disk (or when the install is a flat layout that serves /storage via an .htaccess rewrite and needs no symlink; required() returns false there, so there's no false positive).
  • Dashboard banner — the admin home (⚡dashboard.blade.php) shows a red "Media links are broken" banner (admin+) with a Fix now button whenever the link is missing, so it surfaces the first time an admin lands on the dashboard after install.
  • Tools → Host Compatibility (⚡tools.blade.php) shows the same warning + a Fix storage link button in the host panel. (The panel's "Storage media" capability row reports whether symlink() is available; this check reports whether the link actually exists.)
  • repair() attempts the link from the web request; where symlink() is disabled for the web SAPI it falls back to the CLI subprocess (createViaCliSubprocess()). Only when that also fails does the message point at the manual fix: run php artisan storage:link over SSH, or remove symlink from the host's disable_functions.

Categories with rename support

MediaCategory is a renameable bucket. The library UI lets editors create, rename, sort, and delete categories. Deleting a category reassigns its items to the default category rather than deleting the items themselves. The default category is whichever row has is_default = true and is guaranteed to exist.

Because categories are metadata-only, none of these operations touches the filesystem. Renaming "Team" to "Staff" is one row update; deleting "Old Stuff" reassigns FKs and leaves every actual file in place under its 2024/03/... path.

Centralised metadata

Every media_items row carries:

Column Notes
path Storage-relative path under the media disk (e.g. 2026/04/hero.jpg).
filename Original upload filename, retained for display/download.
alt Alt text. Edited inside the picker; never duplicated per consumer.
caption Optional caption text.
width, height Set on upload via getimagesize. Read by GlideUrl to size variants and avoid requesting widths >= original.
focal_x, focal_y Optional 0–100 percentage offset marking the important point in the image. Drives object-position on every render path that crops to a fixed aspect. See image focal point.
size, mime_type Standard file metadata.
media_category_id FK; null-on-delete reassigns to default.
is_seeded Marks demo content for the demo-cleanup flow.

Alt text lives on the media_items row and is edited inside the picker — entities don't store per-context alt overrides. Edit the alt once; every page that uses the image gets the new alt on next render.

Focal point — one click, every crop respects it

When the same image is rendered into slots with different aspect ratios (a wide hero, a square card, a portrait sidebar thumb), the browser's default centre-crop will cut heads off and lose subjects that aren't centred. Editors click on the image inside the picker (or the library detail panel) to mark the important point; every render path that uses object-fit: cover reads focal_x / focal_y and emits object-position: X% Y% automatically.

Per-image metadata, not per-spot — set the face of a headshot once and every card, banner, gallery, and slider on the site crops to it. Unset is the default and renders byte-identical to a system without the feature. The same path-keyed metadata cache that powers the responsive image pipeline already carries these columns, so applying focal points across a page costs zero extra queries.

Full details in image focal point.

Documents (PDFs)

The media library isn't image-only. Alongside image and video, a third kind — document — lets editors upload PDFs (brochures, disclosures, spec sheets, price lists, menus) and place them on pages as download links, all through the same single library. A document is any upload whose mime is application/pdf; MediaItem::kindForMime() maps it to kind = document and MediaItem::isDocument() reads it back.

Documents are non-visual media: they have no pixels, so the whole image pipeline is skipped for them.

  • No processing on upload. MediaUploadController stores the PDF bytes verbatim under the same {Y}/{m}/ layout — no ImageResizer, no getimagesize, width/height stay null. PDFs share the 100 MB cap with video (vs. 50 MB for images).
  • No Glide variants. Wherever an image grid would render <img>, a document renders an icon card (partials/media-document-card.blade.php — a document glyph + the file extension). The item's thumbUrl / lightboxUrl are null; the preview panel shows an "Open PDF" link instead of a focal-point / crop workspace (cropping and focal points are image-only and hidden for documents).
  • Everything else works unchanged. Rename/move, Replace (a PDF replaces a PDF), delete-with-usage-warning, categories, drag-to-reorder, and search all treat documents like any other row.

Uploading. The standalone library page's "Upload Files" button and the page editor's picker both accept application/pdf in addition to images and video. Because documents flow through the same MediaUploadController, no new upload path exists.

Placing a PDF on a page. A document isn't embedded like an image — it's linked. Every link and button URL field in the page editor (any _url field, e.g. a button's href) gains a "Choose PDF from media library" action beneath the URL input. It opens the picker in document mode (kindFilter: 'document'), and picking a PDF writes the file's public URL straight into the URL field — so the link or button becomes a download link. The wiring lives in the editor store: requestMediaPicker(rowIndex, fieldKey, { kindFilter: 'document' }) records the document intent, and _handleMediaImagePicked writes the picked item's url via setFieldValue (rather than staging an image path the way an image field does). See editor-state.js.

Documents stay out of image pickers. The image/featured-image/gallery/body-image pickers (the Livewire SFC, ⚡picker.blade.php) request a specific kindFilter — so documents only ever surface where a PDF is wanted (the link/button picker) and never appear as a broken thumbnail in an image slot. The SFC also excludes documents from its "all kinds" mode for the same reason.

Usage tracking. Because a linked PDF is referenced by its URL string (in a link/button _url override) rather than by an image-type override, MediaItem::usageSummary() adds a documents count — any override whose value contains the document's path — so deleting a PDF that's linked on a page still warns the editor first.

Picker contract — one component, every consumer

Any place that needs an image embeds the picker component as a Flux modal:

<flux:modal wire:model="showMediaPicker" name="media-picker" :closable="false" class="p-0!" style="max-width: 90vw; width: 90vw;">
    @if ($showMediaPicker)
        <livewire:pages::dashboard.media-library.picker
            :field-key="$mediaPickerKey"
            :default-category-slug="$mediaPickerCategorySlug"
            :current-image-path="$mediaPickerCurrentPath"
            :key="'media-picker-'.$mediaPickerKey.'-'.$mediaPickerOpenSeq"
        />
    @endif
</flux:modal>

The picker dispatches one of two events:

Event Payload Use
media-image-picked key, id, path, alt Single-select.
media-images-picked key, images: [{id, path, alt}, ...] Multi-select for galleries.

Multiple invocations on one page coexist by routing on $key — encode the field name into the key (e.g. image:hero, gallery:photos) and str_starts_with to dispatch in the listener.

Replace flow: usage count, then refresh

Every image-replace action goes through a usage-aware modal. MediaItem::usageSummary() returns a fan-out across the system:

return [
    'posts_featured' => $postsFeatured,
    'posts_gallery'  => $postsGallery,
    'overrides'      => $overrides,
    'documents'      => $documents, // link/button URL references to a PDF
    'total'          => $postsFeatured + $postsGallery + $overrides + $documents,
];

The modal shows the editor exactly how many places this image is used before they confirm. Two paths:

  • Replace — overwrite the file in place at the same path, refresh width / height / size / updated_at, and clear glide-cache/{path}/. Every reference (FK, pivot, override path) stays valid; the new bytes appear everywhere the image was used. Browsers refetch automatically because MediaItem::url() includes ?v={updated_at} in every URL.
  • Delete anyway — explicit confirm, then remove the row.

Cache busting works without manual purges because every URL the system emits is unique per updated_at. The cache invalidation is structural, not coordinated.

Cropping: recrop in place, every placement updates

The detail panel (and the editor picker) has a Crop button on any still image. It opens a full-screen workspace powered by Cropper.js where the editor drags a crop box — freely, or locked to a preset ratio (1:1, 16:9, 4:3, 3:2). Apply exports the cropped region from the browser canvas and uploads it back; the server overwrites the file in place at the same path, exactly like Replace, then refreshes width / height / size / mime_type and clears glide-cache/{path}/.

Because the path never changes, the crop propagates everywhere the image is used — there's no per-placement crop to manage, and every FK / pivot / override reference stays valid. The ?v={updated_at} cache buster flips so browsers refetch the recropped bytes automatically.

  • Destructive by design. Cropping discards the trimmed pixels and applies site-wide. For "this image needs different framing in this one spot", use the focal point instead — that's per-image metadata that lets a single original crop differently into each aspect ratio without losing data.
  • Focal point is reset on crop. The stored focal_x / focal_y are 0–100 percentages of the old frame, so a recrop invalidates them — applyCrop() nulls them so a stale point can't mis-position later cover-crops.
  • Format-gated. Only image/jpeg, image/png, and image/webp show the Crop button — the canvas re-encodes to the original's format so the extension stays truthful. GIF (animation), SVG (vector), and video are excluded; the canvas can't faithfully re-encode them.
  • One overwrite, three surfaces. The actual file overwrite (write + dimension refresh + Glide-cache clear) lives in MediaImageWriter::overwrite(), shared by Replace and every crop path. The three crop surfaces:
    • Media library page + Livewire pickers (blog / events / locations / content image fields) — the CropsMediaItems trait (applyCrop(), Livewire file upload) + the shared media-crop-overlay.blade.php partial driven by the mediaCropper() Alpine component in manager.js.
    • Page editor's picker — a separate pure-Alpine shell (media-picker-shell.blade.php + mediaPickerModal() in media-picker-modal.js) that persists over fetch, so its Crop posts the blob to the MediaUploadController::crop endpoint (POST dashboard/media-library/{mediaItem}/crop).
  • Cropper.js is lazy-loaded. Both the manager.js component and the editor shell import('cropperjs') dynamically, so the ~12 KB-gzip library + CSS split into their own chunk fetched only when a crop overlay first opens — never on dashboard/editor page load.
  • Refresh is automatic. The library page bumps a cropNonce to remount its wire:ignore'd preview; the Alpine shell patches its reactive item so the preview + grid update instantly. Either way a crop also fires media-focal-saved so any editor preview iframe showing the image refetches the new bytes.

Delete flow: FKs null, overrides cleaned, variants dropped

When a media_items row is deleted, the deleted Eloquent hook in MediaItem handles cleanup:

Reference Behaviour
content_items.featured_media_id (and other FKs) Null on delete (defined in migration).
content_block_media and similar pivot rows Cascade.
content_overrides rows of type image whose value matches the path Set to empty string by the deleted hook.
glide-cache/{path}/ directory Deleted.
Cache::META_CACHE_KEY Forgotten so the next render rebuilds the path-keyed metadata map.
Spatie response cache Cleared.

Gallery-ID arrays in overrides are render-time filtered against live media_items IDs and cleaned up on visit by cleanupDeadGalleryIds() — so deleting an image never leaves a broken reference in a multi-select gallery either.

Entity references — three reference shapes

Entity Featured image Gallery
content_items (blog, events, locations, and every other content type) featured_media_id (FK), plus image-type custom fields stored as paths in the data JSON content_block_media pivot (content_block_id, media_item_id, sort_order) via a gallery content block, plus gallery-type custom fields stored as arrays of paths in data JSON
Design library row image fields path in content_overrides.value + companion _media_id row array of paths in override JSON, each item carrying a media_id key

Models expose a featuredImageUrl() (or photoUrl()) accessor that returns the public URL via MediaItem::url() — which appends ?v={updated_at} so cache busting is automatic across every reference shape.

Companion _media_id references for editor-placed images

FK-based references (content_items.featured_media_id, etc.) survive a path change automatically — they resolve to the live media_items row at render time. But images placed by the editor into a design library row save the path directly into the blade file (field-image="2026/04/hero.jpg") so render stays zero-query. A path alone is brittle: it can't be renamed or moved without breaking every page that points at it.

The picker callback writes a companion ID alongside every path it saves:

Where the path is saved Companion ID
Single image (<x-dl.image>, <x-dl.media>, <x-dl.logo>) field-{prefix}-image-media-id="42" attr next to field-{prefix}-image="..." on the same tag
Section background (<x-dl.section> via the paintbrush picker) field-section-bg-image-media-id="42" on the section tag
Legacy gallery JSON items in repeater_* overrides media_id key inside each item dict, alongside image
Page-scoped content_overrides of type image Sibling row with key {key}_media_id, type text, value {id}

The render path never reads the companion ID — it stays a file read of the path string. The ID is editor-side metadata only, used by the rename feature to walk every reference by ID and rewrite the cached path without scanning the disk for path matches. This preserves the zero-query render guarantee while adding rename safety.

The companion is opt-in for existing data: pages that pre-date this feature simply lack the _media_id attrs and continue rendering exactly as before. The media:backfill-ids artisan command (idempotent, supports --dry-run) walks blade files, image overrides, and legacy gallery JSON to insert IDs in-place. Re-running the command is a no-op.

When a media_items row is deleted, the same deleted hook that nulls FKs also clears every _media_id override row whose value matches the deleted ID — so an orphan ID never points at a missing media item.

Renaming and moving files

Selecting an image in the media library reveals a pencil icon next to the filename in the preview panel. Clicking it opens a rename modal with a Filename field (visible by default) and a "Move to a different folder…" disclosure that exposes Year + Month inputs. Submitting the form calls MediaItem::rename(), which orchestrates the full rewrite:

  1. Validate. Filename must match [A-Za-z0-9._-]+\.(jpe?g|png|webp|gif|svg|avif|mp4|webm|mov|ogv|pdf). Year/month must be YYYY/MM. Target path must be unique across media_items and must not already exist on disk. Items whose path starts with tmp/ (AI previews not yet saved to the library) are refused.
  2. Move on the media disk. Storage::disk('media')->move($oldPath, $newPath). The disk-side move runs first so the listener never sees a path it can't open.
  3. Save the model. path and filename are updated and saved inside a DB transaction. The saved hook detects wasChanged('path') and fires MediaItemPathChanged($id, $oldPath, $newPath).
  4. Rewrite every reference. RewriteMediaPathReferences is registered globally in AppServiceProvider::boot() and runs synchronously. It walks three reference shapes:
    • ContentOverride rows of type image — found via their sibling _media_id row, then value rewritten.
    • ContentOverride rows of type repeater (legacy gallery JSON) — items with matching media_id get their image field rewritten.
    • Blade files in views/pages, views/shared-rows, views/layouts/partials — every <x-dl.*> tag carrying field-*-image-media-id="{id}" has its sibling field-*-image="..." attr rewritten.
  5. Clear caches. Glide variant directories at glide-cache/{oldPath} and glide-cache/{newPath} are deleted so the next request regenerates srcsets at the new location. The META_CACHE_KEY path-keyed metadata cache and Spatie's ResponseCache are cleared by the existing saved hook. URLs include a ?v={updated_at_unix} cache buster so browsers refetch automatically.

Failure handling. The listener stores every blade file it rewrites in a per-instance rewrittenFiles buffer (file path → pre-rewrite contents) before writing. The listener is bound as a container singleton, so MediaItem::rename() can resolve the same instance after dispatch. If anything throws after the disk move — listener exception, mid-loop disk error, a downstream write failure — the rename method:

  1. Reverses the disk move (Storage::move(newPath, oldPath)).
  2. Restores each blade file from the rewrittenFiles buffer.
  3. Re-throws to roll back the DB transaction.

End state on rollback: disk file is back at the old path, DB still records the old path, every touched blade file is restored. The UI surfaces the error on the Filename input and keeps the modal open.

media:backfill-ids is the prerequisite for legacy pages. Pages that pre-date the companion ID pattern have no field-*-image-media-id attr for the listener to match, so they're silently skipped by step 4. The rename modal detects this case (any ContentOverride of type image referencing this path that lacks a sibling _media_id) and shows a red warning telling the editor to run php artisan media:backfill-ids first.

Editor session staleness. A user with the page editor open while a rename happens has the pre-rename blade in $this->rows[*]['blade']. Without protection, their next save would overwrite the just-rewritten file with stale rows. The editor captures the source file's mtime in loadedSourceMtime at load time and compares it against the current on-disk mtime before writing. On a mismatch the save no longer refuses outright — it three-way merges the on-disk change with the editor's in-memory state (see "Concurrent saves" in editor-architecture.md). For a rename, the rewritten row's blade differs on disk while the editor didn't touch it, so the merge silently takes the on-disk version and the save proceeds. The refuse-and-reload path (conflictWithDisk = true + a "reload" notice) survives only as the fallback for genuinely unmergeable divergence — missing merge-base snapshot, legacy rows, or PHP-section conflicts. The mtime baseline advances to the post-write mtime after each successful save so in-session sequential saves don't false-alarm.

Permissions. The media library is already gated behind role:manager middleware in routes/cms.php, so the rename UI inherits the Manager+ requirement. No separate gate is needed.

Tests. MediaItemRenameTest covers the happy path, validation failures, the Livewire UI flow, the rollback path (subclass listener that throws after stashing a file in the buffer), and Glide-cache clearing. EditorSourceMtimeCheckTest covers the mtime staleness check and the in-session sequential-save baseline advance.

Out of scope (deferred). Bulk rename, filename SEO suggestions, undo / rename history.

Click-image-to-change + hover-X to remove

Every entity edit form uses the same UI shape: click the image itself to open the picker, hover to reveal a red circle X overlay for removal. No "Change" button text, no separate remove button — the image is the control.

@if ($featuredMedia)
    <div class="relative group mt-2">
        <button type="button" wire:click="openFeaturedMediaPicker" class="block w-full" aria-label="Change image">
            <img src="{{ $featuredMedia->url() }}" alt="{{ $featuredMedia->alt }}" class="h-36 w-full object-cover rounded-md" />
        </button>
        <button type="button" wire:click="removeFeaturedMedia" aria-label="Remove image"
            class="absolute top-2 right-2 size-6 rounded-full bg-red-500 text-white hover:bg-red-600 transition-all flex items-center justify-center opacity-0 group-hover:opacity-100">
            <flux:icon name="x-mark" class="size-3" />
        </button>
    </div>
@else
    <flux:button wire:click="openFeaturedMediaPicker" type="button" icon="photo" variant="filled" class="w-full justify-center mt-2">Pick from media library</flux:button>
@endif

This pattern is canonical — the blog edit form is the reference implementation and every other entity edit form follows it. Editors learn the UX once.

Glide thumbnails in the picker grid

The picker grid and library index don't generate new image variants — they reuse one the public site has already built, via glide_smallest_url($path, 824). Preview modals use glide_largest_url($path, 1200). The "Copy URL" button still serves the original storage URL.

They do that by picking a standard ladder step below the image's original width and signing the exact URL the public srcset uses — version token and delivery params included. The variant directory is not scanned; it holds OG crops, legacy sizes and old editor thumbnails, so "smallest file on disk" would frequently name a variant no page references.

The result is that the dashboard rides on the cache the public site already warmed. A page that's been viewed by visitors has small WebP variants on disk; the dashboard reuses those for thumbnails instead of generating new widths. See the responsive image pipeline doc for how variants get there in the first place.

Finding unused media — the "Unused only" filter

Over time a library accumulates images that were uploaded but never placed — a logo variant that lost the A/B test, a hero shot swapped out a week later, a batch import where half the photos were never used. The "Unused only" toggle in the library toolbar surfaces exactly these so an editor can review the thumbnails and bulk-delete with confidence.

Toggling it on narrows the grid to media_items rows that nothing references, as determined by MediaUsageScanner. An amber banner reminds the editor that "unused" includes images deliberately kept for later, so the thumbnails should be reviewed before deleting. A "Select all unused" action (category-scoped, across all pages — not just the visible one) pairs with the existing multi-select + bulk-delete bar to clear them out in one sweep.

The scanner is deliberately conservative — it over-protects, never under. A false "unused" verdict would delete an image that's live on a page, so any source that might reference an item counts it as used. It resolves references in three passes:

  1. Exact ID references — every FK column that points at media_items (content_items.featured_media_id, the product / doc tables, the gallery pivots, video posters) plus the companion _media_id overrides the picker writes alongside every editor-placed image.
  2. Exact path references — content_overrides values that look like a media file (keyed off the file extension, so images and videos both count regardless of the override's declared type), plus the branding / agency logo, favicon, and default-OG-image settings (mapped from their stored /storage/ URL back to a disk path).
  3. Fuzzy substring scan — for whatever candidates survive the first two passes, a haystack is built from everything that can embed a media path but isn't worth parsing precisely: snippet HTML, custom content-type JSON, marketing email / SMS blocks, legacy og-image columns, section-preset backgrounds, and the insert-time field-image="…" defaults baked into runtime page blades + custom design-library rows (the only place a media path lives outside the database). Any candidate whose path or filename appears anywhere in that text is treated as used. Because substring matching can only ever over-protect, this pass never produces a false "unused".

The result is memoized per request and recomputed automatically after any action that changes the browsable set (upload, generate, delete, bulk delete, poster removal). Posters are excluded from the candidate set entirely — they're never browsable in the grid.

"Unused" is not the same as "orphan." Two different cleanup tools, pointing in opposite directions:

  • Unused media (this filter) finds media_items rows — the thumbnails in the grid — that nothing references. The file on disk is fine; it's the library entry that has no consumer.
  • Orphan files (the Tools page's "Sweep Orphan Media Files" card and the media:prune-orphans command, backed by MediaOrphanScanner) find the inverse: loose files on the media disk that have no media_items row at all — leftovers from imports or deleted records.

An image that has a media_items row but no usage is unused, not an orphan — the file is still referenced by its own library entry, so the orphan sweep correctly leaves it alone. The "Unused only" filter is the tool for that case.

Backfill commands for legacy data

For installs migrating from older systems where images lived as columns directly on entities, a set of idempotent backfill commands move the references into the media library:

Command Purpose
media:backfill-content Move legacy content_items JSON image/gallery paths into media_items + FK. (The blog/events/locations variants were retired when those subsystems converged onto content types.)
media:backfill-ids Insert _media_id companion attrs and override rows next to existing image paths so the future rename feature can locate references by ID. Idempotent.
media:reorganize-by-date Move pre-existing files into year/month folders and rewrite override paths.
media:prune-orphans Find loose files on the media disk that have no media_items row at all; report or delete. The inverse of the Unused only filter, which finds library rows nothing references.

All support --dry-run. Don't delete these — they're the migration path for any new install importing legacy data.

What lives where

Path Purpose
app/Models/MediaItem.php Eloquent model, URL accessor, usage summary, deleted-hook cleanup, path-keyed metadata cache.
app/Models/MediaCategory.php Renameable category model.
resources/views/pages/dashboard/media-library/⚡index.blade.php Library dashboard: grid, upload, edit, replace, delete, category management.
resources/views/pages/dashboard/media-library/⚡picker.blade.php The picker modal embedded by every consumer.
app/Support/Media/ImageResizer.php 2400px max-width cap on upload.
app/Support/Media/GlideUrl.php smallestUrl / largestUrl for picker thumbnails; full responsive pipeline for public renders.
app/Console/Commands/MediaReorganizeByDateCommand.php Move legacy uploads into year/month folders.
app/Console/Commands/MediaBackfill*Command.php Per-entity legacy-import commands + MediaBackfillIdsCommand for companion _media_id references.
app/Services/MediaUsageScanner.php Backs the "Unused only" filter — finds media_items rows nothing references (conservative, over-protects).
app/Services/MediaOrphanScanner.php Backs the Tools "Sweep Orphan Media Files" card + media:prune-orphans — finds loose disk files with no media_items row (the inverse).
app/Console/Commands/MediaPruneOrphansCommand.php CLI for the orphan-file sweep; report or prune.
app/Events/MediaItemPathChanged.php Event fired from MediaItem::saved whenever path changes.
app/Listeners/RewriteMediaPathReferences.php Container-singleton listener that walks every reference by ID and rewrites the cached path; exposes rewrittenFiles for rollback.

Notes

  • No per-entity upload widget exists anywhere in the system. Every place an image is set goes through the picker. This is enforced by convention, not configuration — adding a <flux:input type="file"> to an entity edit form is a regression.
  • Year/month folders are forever. Once a file lives at 2026/04/foo.jpg, that path is its identity for the lifetime of the install. Categories, captions, and alt text can change freely; the path doesn't.
  • Delete is destructive but safe by design. The deleted hook fans out to every reference shape (FK, pivot, override path, gallery JSON) and the response cache. There is no scenario where a deleted image leaves a broken reference visible on the public site.
  • The picker is the only place alt text is edited. Per-context alt overrides intentionally don't exist — that's the WordPress trap. One image, one alt.
  • The library is also the AI image generation surface. When the editor saves an AI-generated image, it lands in the library with vision-derived alt text and is referenced through the same picker contract as every other image. See the AI image generation section in CLAUDE.md.
  • Path stability + ID companions = rename safety without runtime cost. Editor-placed images bake the path string into the blade file so render is zero-query; the picker also saves a companion _media_id so the rename feature can locate every reference by ID without scanning the disk. Existing pages with no companion ID continue to render unchanged; media:backfill-ids is the opt-in migration that must be run before renaming a file that older pages reference.
  • Two cleanup tools, opposite directions. The Unused only filter finds library rows nothing references (the file is fine, the entry is unused); the orphan sweep (media:prune-orphans) finds disk files with no library row (the inverse). Don't conflate them — an unused image is not an orphan, and the orphan sweep deliberately leaves it alone.