Publishr for Craft CMS

Twig reference

Everything on craft.publishr is read-only. Anything that changes something goes through a controller with a permission check on it; a template is not the place from which a piece gets signed off.

It works on the front end as well as in the control panel. A staging site that shows "in progress · due Friday · Sam" above each article is worth more to a review meeting than any amount of screen sharing, and it takes one tag.

The editorial record

{% set item = craft.publishr.item(entry) %}

{% if item %}
    {{ item.getStage().name }}
    {{ item.getStage().color }}
    {{ item.getAssignee().friendlyName ?? 'Unassigned' }}
    {{ item.dueDate|date('j M Y') }}
    {{ item.brief }}

    {% if item.isOverdue() %}
        {{ item.daysUntilDue()|abs }} days late
    {% else %}
        due in {{ item.daysUntilDue() }} days
    {% endif %}
{% endif %}

item() returns null for anything Publishr is not tracking. daysUntilDue() returns null when there is no deadline, and counts whole days in the site's time zone — "due tomorrow" means tomorrow's date, not 24 hours from now.

craft.publishr.item(entry)the editorial record, or null
craft.publishr.stage(entry)its stage, or null
craft.publishr.stages()every stage, in workflow order

The calendar

{% for day, events in craft.publishr.month(2026, 6, ['publish', 'due']) %}
    <h3>{{ day|date('j F') }}</h3>
    {% for event in events %}
        <p>
            <span class="lane-{{ event.lane }}">{{ event.lane }}</span>
            <a href="{{ event.entry.getCpEditUrl() }}">{{ event.entry.title }}</a>
            {% if event.late %}<strong>late</strong>{% endif %}
            {% if event.isDraft %}<em>draft</em>{% endif %}
        </p>
    {% endfor %}
{% endfor %}

month(year, month, lanes, siteId) — every argument optional. Returns events bucketed by Y-m-d. Lanes are due, publish, expire and review; omitting them uses the site's defaults.

Queues and lists

{% for item in craft.publishr.assignedTo() %}          {# defaults to the current user #}
    {{ item.getElement().title }} — {{ item.dueDate|date('j M') }}
{% endfor %}

{% for item in craft.publishr.overdue() %}{% endfor %}
{% for item in craft.publishr.reviewsDue(null, 20, 30) %}{% endfor %}
assignedTo(userId, siteId, limit)somebody's queue, soonest deadline first
overdue(siteId, limit)past their deadline and not yet published
reviewsDue(siteId, limit, withinDays)due a freshness review

Requirements, freshness and history

{% set report = craft.publishr.gates(entry) %}
{{ report.passedCount() }} of {{ report.applicableCount() }}

{% for result in report.blocking() %}
    <li>{{ result.gateName }} — {{ result.message }}</li>
{% endfor %}

{% if craft.publishr.staleness(entry) > 60 %}
    <p>Not reviewed since {{ craft.publishr.item(entry).lastReviewedAt|date('F Y') }}.</p>
{% endif %}

{% for row in craft.publishr.history(entry, 10) %}
    <li>{{ row.dateCreated|datetime('short') }} — {{ row.describe() }}</li>
{% endfor %}
gates(entry, recheck)the verdict; empty on Lite
staleness(entry)0–100, where 100 is a full interval overdue
history(entry, limit)what happened, newest first
comments(entry, includeResolved)the editorial thread
openComments(entry)how many are unresolved
report(siteId)everything the governance report shows
isPro()which edition is licensed

A word about the front end

Editorial comments are internal. craft.publishr.comments() is available on the front end because a staging build is a legitimate place to show them — but it is your template that decides, and putting them on a public page will publish your desk's private conversation. Guard it.

More in Editorial

Pairs well with Publishr

For the people writing the content. Scheduling, staging, revision diffs, house style, find and replace, and a warning before two editors overwrite each other.