Media extension points 2.22
A module can decide what the public media routes deliver, react to uploaded files, add columns to the media table and extend the media library screens, without copying a single core view. These are the hooks the core offers, and the Media Governance module is the first to use all of them. Everything here is additive: without a module that registers something, the core behaves exactly as before.
Visibility: who gets a file
Racerfish\Evolve\Contracts\MediaVisibility decides whether a media item may be delivered on the public routes: media/{hash}/{slug}, media/{hash}/download, media/{hash}/preview and every /img/... variant. The core binds OpenMediaVisibility, which allows everything. A module binds its own implementation in its provider:
use Racerfish\Evolve\Contracts\MediaVisibility;
$this->app->singleton(MediaVisibility::class, MyVisibility::class);The core binds its default with singletonIf, so the module's binding wins whether it registers before or after the core.
interface MediaVisibility
{
public function allows(Media $media, ?Authenticatable $user): bool;
public function isPubliclyCacheable(Media $media): bool;
}allows()gets the media and the signed-in user of the request, if any. Afalsemakes the route answer 404, the same as for a hash that does not exist, so held back files do not leak their existence.isPubliclyCacheable()answers whether the file and its rendered variants may be served as static files and cached by shared caches. Anything that depends on the viewer or can be revoked must answerfalse.
What "not publicly cacheable" changes
Rendered image variants normally land under public/img, where the web server serves them without PHP from the second request on. That is fine for a public image and wrong for one that only some viewers may see, or one that expires: the static file would outlive the decision. For media that is not publicly cacheable the core therefore
- renders variants to the private disk
image-cache-private(storage/app/evolve/image-cache) instead ofpublic/img, so every request goes throughImageControllerand the visibility check; - answers the file routes and the image routes with
Cache-Control: private, max-age=600instead ofpublic, max-age=86400(files) orpublic, max-age=60(the original standing in for a cold variant); - clears both disks in
ImageRenderer::clearCache()andevolve:clear-images.
Nothing of this touches public media. With the default visibility every variant renders into public/img as before, with the same headers.
A visibility that depends on data the module stores on the media row (a status, a level) should clear the item's variants when that data changes. Saving the model does this (Media::updated clears the cache); bulk updates do not, so run ImageRenderer::clearCache() after them.
Uploads: MediaFileStored
Racerfish\Evolve\Events\MediaFileStored fires once an uploaded or replaced file sits on the uploads disk, from the media library, the picker and the upload endpoint. The model's created event is too early for that: it fires before the file is written. The event carries the Media and replaced (true when an existing file was replaced).
use Illuminate\Support\Facades\Event;
use Racerfish\Evolve\Events\MediaFileStored;
Event::listen(MediaFileStored::class, ReadIptcOnStore::class);Read the file through $event->media->sourcePath(). A listener that writes back to the model should use saveQuietly() when it does not want to trigger another round of model events.
Columns: Media::extendCasts()
A module that adds columns to evolve_medias through its own migration registers their casts when it boots, before media models are instantiated:
use Racerfish\Evolve\Models\Media;
Media::extendCasts([
'media_meta' => 'array',
'valid_until' => 'date',
]);The casts merge with the core's. Relations are added with Media::resolveRelationUsing(), query scopes with a global scope that only implements extend() (like Laravel's SoftDeletingScope), so they exist on media queries and nowhere else. Fill the new columns with forceFill(): a long-running worker may still cache the table's columns from before the migration.
Permissions: Permissions::register()
Module abilities join the permission catalogue with the roles that get them by default:
use Racerfish\Evolve\Support\Permissions;
Permissions::register([
'media.approve' => ['developer', 'admin'],
]);Registered abilities count as Evolve abilities: developers pass them through the Gate::before hook, admins as well. The module still grants them to the roles in a migration with Permissions::apply(), which only adds and never revokes. Abilities nobody registered are left to the host app's own gates.
Media library: panel view slots
Evolve::addPanelView($slot, $view) adds a view to a named place in a core screen. The screen includes it with its own variables, so the module extends the screen without overriding it.
| Slot | Where | Variables |
|---|---|---|
media.card.badges | bottom right of a card in the grid, after the tag badges | $file |
media.row.badges | after the tag badges of a list row | $file |
media.picker.badges | next to name and size of a file in the media picker | $file |
media.filters | the toolbar, between "Not attached" and the grid/list control | the MediaManager properties, $extensionFilters among them |
media.bulk-actions | the selection menu, below "Download (ZIP)" | the MediaManager properties |
media.form.after | the file drawer, below the form | $item (the Media) |
use Racerfish\Evolve\Facades\Evolve;
Evolve::addPanelView('media.card.badges', 'media-governance::badges');
Evolve::addPanelView('media.form.after', 'media-governance::form-after');Use the panel's components in these views: <x-evolve::icon name="..."> for icons, btn btn-secondary btn-toggle with aria-pressed for an on/off filter, dropdown__item for a menu entry. The media.form.after slot is the place for a Livewire component of your own (<livewire:... :media-id="$item->id" />), which saves apart from the core form.
A project that copied one of these views into resources/views/vendor/evolve has to add the slot loop itself; php artisan evolve:view-overrides lists what changed, see Upgrading.
Media library: query and bulk actions
MediaManager::extendQuery() adds a closure to the library's listing query. It receives the Eloquent builder and the current $extensionFilters, an array the module's filter views bind with wire:model.live="extensionFilters.<name>". The filters live in the query string as ?filters[<name>]=..., so a link can open a filtered library, and the page resets when a filter changes.
use Illuminate\Database\Eloquent\Builder;
use Racerfish\Evolve\Http\Livewire\MediaManager;
MediaManager::extendQuery(function (Builder $query, array $filters) {
if ($status = $filters['governance_status'] ?? null) {
$query->where('workflow_status', $status);
}
});MediaManager::registerBulkAction() registers a handler for the selection menu. The handler gets the selected media as a collection, plus the arguments the menu item passed, and returns the message to flash or null. It checks its own authorization. The menu item calls runBulkAction('<name>', {arguments}):
MediaManager::registerBulkAction('media-governance.reject', function (Collection $media, array $arguments = []) {
return app(BulkReview::class)->run('reject', $media, auth()->user(), $arguments);
});<button class="dropdown__item" type="button" wire:click="runBulkAction('media-governance.approve')">
<x-evolve::icon name="circle-check" />
<span>{{ __('media-governance::governance.actions.approve') }}</span>
</button>An unknown action name answers 404. The selection is cleared after the handler ran.
Activity log entries
The activity log renders every entry through evolve::panel.activity.messages.<description>. A module that logs its own steps gives the description a translation key of its own language files, and the log uses it as it is:
activity()
->performedOn($media)
->causedBy($user)
->withProperties(['note' => $note])
->log('media-governance::governance.activity.media-rejected');'activity' => [
'media-rejected' => ':causer rejected <strong>:subject</strong>: :note',
],:causer and :subject are filled by the log, every string property of the entry is available as a placeholder too. The values are escaped, the translation carries the markup.
A minimal module
A module that holds back every file carrying a blocked tag, and shows a badge for it:
protected function bootModule(): void
{
$this->app->singleton(MediaVisibility::class, BlockedTagVisibility::class);
Evolve::addPanelView('media.card.badges', 'acme::media-badge');
Evolve::addPanelView('media.row.badges', 'acme::media-badge');
}class BlockedTagVisibility implements MediaVisibility
{
public function allows(Media $media, ?Authenticatable $user): bool
{
return $user !== null || ! $media->tags->contains('label', 'blocked');
}
public function isPubliclyCacheable(Media $media): bool
{
return ! $media->tags->contains('label', 'blocked');
}
}@if ($file->tags->contains('label', 'blocked'))
<span class="badge badge-danger">{{ __('acme::media.blocked') }}</span>
@endifTesting
The core suite has MediaExtensionHooksTest for the hooks and MediaDeliveryWithoutModuleTest for the promise that nothing changes without a module. A module's own tests bind their visibility with $this->app->instance(MediaVisibility::class, ...) and flush the static registries in tearDown(): MediaManager::flushExtensions(), Media::flushExtensionCasts(), Permissions::flushRegistered(). They are static so that a module registers once per process; in a test process they would otherwise leak into the next test.