Media Governance 2.22
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.
- Module key
media-governance - Default off
- Switch
EVOLVE_MODULE_MEDIA_GOVERNANCE=true, thenphp artisan migrateand the backfill below; see Modules - 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 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_fromandvalid_untilhold 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, withCache-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
Switch it on in the
.env, or in the module switches of the Developers tab in Settings:iniEVOLVE_MODULE_MEDIA_GOVERNANCE=trueThe switch works whether or not the project's
config/evolve.phplists the module (since 2.21).php artisan evolve:installwrites it into the.envof new installs, set tofalse.Run the migrations. They add the columns to
evolve_mediasand create the permissions.bashphp artisan migrateKeep the existing library online. changes data 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:
bashphp 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-runThe command only touches media without a value and clears the image cache afterwards.
Grant the permissions to the project's roles (see Permissions).
Make sure the scheduler runs (
schedule:runin the cron). The module schedulesevolve:media-governance:expiredaily 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:
- 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).
- 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.
- An approver opens the file and clicks Approve, or Reject with a comment. Approved media appears on the website on the next request.
- 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:
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:
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 daysdeliverable() 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. 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:
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).
Configuration
Publish the config to change it:
php artisan vendor:publish --tag=media-governance.configreturn [
'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:
'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
rulessays. - 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.managecan edit these fields. The rights fields needmedia.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
- Upload a JPEG with IPTC data (credit, by-line, keywords). Open it: the governance block shows status "Draft" and the prefilled credit.
- 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. - Approve the file. The visitor sees it on the next request; the variant now exists under
public/img. - Set Valid until to yesterday and save. The image disappears for visitors again, the card shows "Expired".
php artisan evolve:media-governance:expire --no-notifyreports the cleared variants;php artisan schedule:listshows 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/imgbefore the status changed and the web server serves the static file. Runphp artisan evolve:clear-imagesor save the file once. - No badges, no governance block: the module is off, or
php artisan migratehas not run on this environment.php artisan evolve:env:showlists the switch. - An editor cannot approve:
media.approveis granted to developers and admins only until the project grants it to its own roles.