Skip to content

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:

php
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.

php
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. A false makes 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 answer false.

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 of public/img, so every request goes through ImageController and the visibility check;
  • answers the file routes and the image routes with Cache-Control: private, max-age=600 instead of public, max-age=86400 (files) or public, max-age=60 (the original standing in for a cold variant);
  • clears both disks in ImageRenderer::clearCache() and evolve: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).

php
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:

php
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:

php
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.

SlotWhereVariables
media.card.badgesbottom right of a card in the grid, after the tag badges$file
media.row.badgesafter the tag badges of a list row$file
media.picker.badgesnext to name and size of a file in the media picker$file
media.filtersthe toolbar, between "Not attached" and the grid/list controlthe MediaManager properties, $extensionFilters among them
media.bulk-actionsthe selection menu, below "Download (ZIP)"the MediaManager properties
media.form.afterthe file drawer, below the form$item (the Media)
php
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.

php
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}):

php
MediaManager::registerBulkAction('media-governance.reject', function (Collection $media, array $arguments = []) {
    return app(BulkReview::class)->run('reject', $media, auth()->user(), $arguments);
});
blade
<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:

php
activity()
    ->performedOn($media)
    ->causedBy($user)
    ->withProperties(['note' => $note])
    ->log('media-governance::governance.activity.media-rejected');
php
'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:

php
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');
}
php
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');
    }
}
blade
@if ($file->tags->contains('label', 'blocked'))
    <span class="badge badge-danger">{{ __('acme::media.blocked') }}</span>
@endif

Testing ​

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.