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.