Skip to content

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, then php artisan migrate and 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_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. 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:

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

  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.

StepFromToPermissionEvent
Submitdraftin reviewmedia.submitMediaSubmitted
Approvein review, or draftapprovedmedia.approveMediaApproved
Reject (comment required)in reviewrejectedmedia.approveMediaRejected
Archiveapprovedarchivedmedia.approveMediaArchived
Reopenrejected, archiveddraftmedia.submitnone

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 ​

LevelDelivered toIn public/img
publiceveryoneyes
internalsigned-in usersno
restrictedsigned-in usersno
blockednobodyno

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. Developers and admins pass them like every Evolve ability.

PermissionAllowsGranted by the migration to
media.submitsubmitting and reopeningdeveloper, admin, and every role that holds media.manage
media.approveapproving, rejecting, archivingdeveloper, admin
media.edit-rightschanging rights level, credit, usage terms, validitydeveloper, admin
media.view-restrictedseeing held back media on the websitedeveloper, admin

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

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

Configuration ​

Publish the config to change it:

bash
php artisan vendor:publish --tag=media-governance.config
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,
];
KeyDefaultMeaning
initial_statusdraftstatus of new media
default_rights_levelpubliclevel of media without one
four_eyesfalsesubmission by someone else required before approval
fields[]project fields, see below
iptc_mappingsee aboveIPTC datasets to prefill, see below
expiry_notice_days30horizon 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
'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.