---
url: /custom-modules/media-extension-points.md
---
# Media extension points&#x20;

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](/modules/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](/getting-started/roles#permissions) 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.

| 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`) |

```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](/getting-started/upgrading#overridden-panel-views).

## 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 [modules/media-governance/lang/en/governance.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 [modules/acme/src/AcmeServiceProvider.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 [modules/acme/src/BlockedTagVisibility.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 [modules/acme/views/media-badge.blade.php]
@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.
