---
url: /modules/media-governance.md
---
# Media Governance&#x20;

An optional module for sites whose media library holds material that must not simply go online: photos with licence terms, internal documents, images that are only cleared for a period. It adds an approval workflow, rights levels that Evolve enforces on every public media route, typed rights fields, project fields defined in config, prefill from IPTC and EXIF, and review actions for many files at once.

::: glance

* **Module key** `media-governance`
* **Default** **off**
* **Switch** `EVOLVE_MODULE_MEDIA_GOVERNANCE=true`, then `php artisan migrate` and the backfill below; see [Modules](/modules/#switching-a-module-on-or-off)
* **Role access** the media library roles (`evolve.roles.media`) plus the four permissions below
* **Panel** the media library itself: badges, filters, a governance block in the file drawer, bulk actions, a sidebar entry and a Cmd-K command "Awaiting approval"
* **Since** 2.22
  :::

While the module is off, nothing of it loads (no columns, no permissions, no panel changes) and media is delivered exactly as before. It is built on the [media extension points](/custom-modules/media-extension-points) of the core, so it does not patch any core view.

## What it does

* **Workflow.** Every media item has a status: draft, in review, approved, rejected or archived. Only approved media is delivered on the website.
* **Rights levels.** Public media goes to everyone. Internal and restricted media only to signed-in users. Blocked media never. Restricted additionally carries usage terms.
* **Validity window.** `valid_from` and `valid_until` hold media back before and after the dates.
* **Enforcement.** `media/{hash}/{slug}`, its download and preview routes and every `/img/...` variant answer 404 for media the viewer may not see, the same answer as for media that does not exist.
* **Static image cache.** Only approved, public media without an imminent end date is rendered into `public/img`, where the web server serves it without PHP. Everything else is rendered to a private cache (`storage/app/evolve/image-cache`) and always goes through PHP, with `Cache-Control: private`.
* **Media library.** A governance block below the fields in the file drawer, status and rights badges on cards, rows and in the media picker, filters for status, rights level and "expiring soon", bulk actions and a sidebar entry for items awaiting approval.

Everyone who may open the media library (`media.manage`) sees every item there, because the panel's thumbnails load through the same `/img` routes. The restrictions apply to the website.

## Enabling

1. Switch it on in the `.env`, or in the module switches of the Developers tab in Settings:

   ```ini
   EVOLVE_MODULE_MEDIA_GOVERNANCE=true
   ```

   The switch works whether or not the project's `config/evolve.php` lists the module (since 2.21). `php artisan evolve:install` writes it into the `.env` of new installs, set to `false`.

2. Run the migrations. They add the columns to `evolve_medias` and create the permissions.

   ```bash
   php artisan migrate
   ```

3. **Keep the existing library online.**  Media that predates the module has no status and counts as a draft, so it disappears from the website until it is approved. Approve the existing library in one go:

   ```bash
   php artisan evolve:media-governance:backfill --status=approved
   # optionally also a rights level for items without one
   php artisan evolve:media-governance:backfill --status=approved --rights=internal
   # count only
   php artisan evolve:media-governance:backfill --dry-run
   ```

   The command only touches media without a value and clears the image cache afterwards.

4. Grant the permissions to the project's roles (see [Permissions](#permissions)).

5. Make sure the scheduler runs (`schedule:run` in the cron). The module schedules `evolve:media-governance:expire` daily at 00:10.

Run `evolve:images:warm` only after these steps, otherwise it renders variants of media that is about to be held back.

## Working with it in the panel

Open a file in the media library. Below the usual fields the drawer shows the **Approval** block: the current status and rights level as badges, who submitted and approved the file and when, the review comment, the steps you may take, the rights fields and the project fields. The block saves with its own button, apart from the core form.

A typical round:

1. An editor uploads a photo. It starts as a **draft** and is not delivered on the website, even when it is already placed on a page (the image slot stays empty for visitors).
2. The editor fills credit and usage terms, optionally a validity window, and clicks **Submit**. The file is now **in review** and the sidebar entry "Awaiting approval" counts it.
3. An approver opens the file and clicks **Approve**, or **Reject** with a comment. Approved media appears on the website on the next request.
4. When a licence ends, the approver sets **Valid until**. The file disappears from the website the day after, and the approvers get a mail before.

**Badges** appear on cards, list rows and in the media picker for everything that is not approved and public, and for expired items. Picking such an item in a block is still allowed; it just does not render for visitors until it is approved.

**Filters** for status, rights level and "expiring soon" sit next to the type filter. They live in the query string, for example `/evolve/media?filters[governance_status]=in_review`, so a link can open a filtered library.

**Bulk actions** in the selection menu: submit, approve, reject (asks for a comment), archive. Each item is checked on its own; items whose status or permission does not fit are skipped and named in the message.

## Status and transitions

All changes go through the `MediaReview` service, which checks the permission and the current status, records who did it and when, writes an entry to the activity log and fires an event.

| Step | From | To | Permission | Event |
|---|---|---|---|---|
| Submit | draft | in review | `media.submit` | `MediaSubmitted` |
| Approve | in review, or draft | approved | `media.approve` | `MediaApproved` |
| Reject (comment required) | in review | rejected | `media.approve` | `MediaRejected` |
| Archive | approved | archived | `media.approve` | `MediaArchived` |
| Reopen | rejected, archived | draft | `media.submit` | none |

Events live in `Racerfish\MediaGovernance\Events` and carry the media and the acting user.

New media starts with `initial_status` (default `draft`), whether it is uploaded in the panel or created in code. Media without a status counts as a draft.

**Four eyes.** With `four_eyes` on, a draft cannot be approved directly. It has to be submitted first, and only someone other than the submitter can approve it. It is off by default.

In code:

```php
use Racerfish\MediaGovernance\Services\MediaReview;

app(MediaReview::class)->approve($media, $user);
app(MediaReview::class)->reject($media, $user, 'Wrong crop');
app(MediaReview::class)->updateRights($media, $user, [
    'rights_level' => 'restricted',
    'credit' => '© Jane Doe',
    'usage_terms' => 'Print only, no social media.',
    'valid_until' => '2027-06-30',
]);
```

A step that is not allowed throws `Racerfish\MediaGovernance\Exceptions\TransitionNotAllowed`.

## Rights and delivery

| Level | Delivered to | In `public/img` |
|---|---|---|
| `public` | everyone | yes |
| `internal` | signed-in users | no |
| `restricted` | signed-in users | no |
| `blocked` | nobody | no |

Additionally the item must be approved and today must lie within `valid_from` and `valid_until` (both inclusive, both optional). Media without a level counts as `default_rights_level` (default `public`). Users with `media.manage` or `media.view-restricted` see everything.

Media stops being publicly cacheable one day before `valid_until`. The daily expire run clears the variants of media whose window starts, ends or enters that margin, so no static file outlives the end date. A change of status or rights clears the item's variants immediately.

### Queries

The module adds scopes to media queries:

```php
use Racerfish\Evolve\Models\Media;

Media::deliverable(auth()->user())->get(); // what this viewer may see, same rules as delivery
Media::deliverable()->get();               // what a guest may see
Media::approved()->get();
Media::workflowStatus('in_review')->get();
Media::rightsLevel('internal')->get();
Media::expiringWithin(30)->get();          // approved, ending within 30 days
```

`deliverable()` follows the delivery rules, including the bypass for users with `media.manage`. For a public listing that should never show drafts, even to editors, use `deliverable()` without a user or `approved()` combined with your own rights condition.

The relations `submitter` and `approver` resolve the users behind `submitted_by` and `approved_by`.

## Permissions

The module adds four abilities to the [permission catalogue](/getting-started/roles#permissions). Developers and admins pass them like every Evolve ability.

| Permission | Allows | Granted by the migration to |
|---|---|---|
| `media.submit` | submitting and reopening | developer, admin, and every role that holds `media.manage` |
| `media.approve` | approving, rejecting, archiving | developer, admin |
| `media.edit-rights` | changing rights level, credit, usage terms, validity | developer, admin |
| `media.view-restricted` | seeing held back media on the website | developer, admin |

Grant the others to the project's roles in an app migration. `Permissions::apply()` only adds and never revokes:

```php [database/migrations/2026_11_01_000000_grant_media_governance.php]
use Illuminate\Database\Migrations\Migration;
use Racerfish\Evolve\Support\Permissions;

return new class extends Migration
{
    public function up(): void
    {
        Permissions::apply([
            'media.approve' => ['content_manager'],
            'media.edit-rights' => ['content_manager'],
        ]);
    }
};
```

Who opens the media library at all is still decided by `evolve.roles.media` (see [Restricting panel areas](/getting-started/roles#restricting-panel-areas)).

## Configuration

Publish the config to change it:

```bash
php artisan vendor:publish --tag=media-governance.config
```

```php [config/media-governance.php]
return [
    'initial_status' => env('EVOLVE_MEDIA_GOVERNANCE_INITIAL_STATUS', 'draft'),
    'default_rights_level' => env('EVOLVE_MEDIA_GOVERNANCE_DEFAULT_RIGHTS_LEVEL', 'public'),
    'four_eyes' => env('EVOLVE_MEDIA_GOVERNANCE_FOUR_EYES', false),
    'fields' => [],
    'iptc_mapping' => [
        '2#116' => 'credit',            // Copyright notice
        '2#080' => 'meta.photographer', // By-line
        '2#025' => 'meta.keywords',     // Keywords
    ],
    'expiry_notice_days' => 30,
];
```

| Key | Default | Meaning |
|---|---|---|
| `initial_status` | `draft` | status of new media |
| `default_rights_level` | `public` | level of media without one |
| `four_eyes` | `false` | submission by someone else required before approval |
| `fields` | `[]` | project fields, see below |
| `iptc_mapping` | see above | IPTC datasets to prefill, see below |
| `expiry_notice_days` | `30` | horizon of the expiry notice, `0` sends none |

### Project fields

Fields a project needs beyond the rights columns are stored in the JSON column `media_meta` and edited in the governance block:

```php [config/media-governance.php]
'fields' => [
    'asset_class' => [
        'type' => 'select',
        'label' => 'Asset type',
        'options' => ['photo' => 'Photo', 'plan' => 'Plan', 'rendering' => 'Rendering'],
        'rules' => ['required'],
    ],
    'photographer' => ['type' => 'text', 'label' => 'Photographer'],
    'keywords' => ['type' => 'tags', 'label' => 'Keywords'],
    'shot_on' => ['type' => 'date', 'label' => 'Shot on'],
],
```

* Types: `text`, `textarea`, `select`, `date`, `tags` (edited as a comma separated line, stored as a list).
* Labels and option labels may be translation keys.
* A select only accepts its options, whatever `rules` says.
* Keys that start with `_` are system keys (for example `_captured_at`). They never appear in the form and saving the form never removes them, nor any other key that is not a configured field.
* Everyone with `media.manage` can edit these fields. The rights fields need `media.edit-rights`.

### IPTC and EXIF prefill

When a file is uploaded or replaced (core event `MediaFileStored`), the module reads IPTC and EXIF from JPEG and TIFF files with PHP's own functions and fills **only empty fields**. A target is a rights column (`credit`, `usage_terms`) or a key in `media_meta` written as `meta.<key>`. Repeated datasets (keywords) and fields of type `tags` are stored as a list. The capture date (`DateTimeOriginal`) always goes to `media_meta._captured_at`. Latin-1 values are converted to UTF-8.

## Expiry notice

The daily `evolve:media-governance:expire` run mails every user with `media.approve` a list of approved media that expires within `expiry_notice_days`. Run it by hand with `--no-notify` to only clear caches.

## Coming from a project-level prototype

Sites that ran an earlier project-level prototype of this workflow already have the columns `media_meta` and `workflow_status`. The module keeps both names and ships that migration under the same file name, so it is skipped there. For one release the module still reads the prototype's `evolve.media.metadata` as `fields` when `fields` is empty, and logs a notice. Rights the prototype kept in `media_meta` (a rights select, copyright, validity) have to be moved into the new columns by an app migration.

## Verifying

1. Upload a JPEG with IPTC data (credit, by-line, keywords). Open it: the governance block shows status "Draft" and the prefilled credit.
2. Place it on a page. As a visitor (private window) the image is missing and its `/media/{hash}/...` URL answers 404. Signed in as an admin it shows.
3. Approve the file. The visitor sees it on the next request; the variant now exists under `public/img`.
4. Set **Valid until** to yesterday and save. The image disappears for visitors again, the card shows "Expired".
5. `php artisan evolve:media-governance:expire --no-notify` reports the cleared variants; `php artisan schedule:list` shows the daily entry.

## Troubleshooting

* **Images vanished from the site after switching the module on:** the library has no status yet. Run the backfill with `--status=approved`.
* **A visitor still sees a held back image:** its variant was rendered into `public/img` before the status changed and the web server serves the static file. Run `php artisan evolve:clear-images` or save the file once.
* **No badges, no governance block:** the module is off, or `php artisan migrate` has not run on this environment. `php artisan evolve:env:show` lists the switch.
* **An editor cannot approve:** `media.approve` is granted to developers and admins only until the project grants it to its own roles.
