justinholtweb / craft-live
Live blogging for Craft — publish updates in a keystroke, and put them on the site without touching the page cache.
Package info
github.com/justinholtweb/craft-live
Type:craft-plugin
pkg:composer/justinholtweb/craft-live
Requires
- php: ^8.2
- ext-json: *
- craftcms/cms: ^5.3.0
- ezyang/htmlpurifier: ^4.17
Requires (Dev)
- craftcms/ecs: dev-main
- craftcms/phpstan: dev-main
- phpstan/phpstan: ^1.12 || ^2.2
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Live blogging for Craft CMS 5. An editor types a sentence, presses ⌘↵, and it is on the site — without saving the parent entry, without breaking the page cache, and without a PHP process per reader.
{% include 'live/_feed.twig' with { feed: entry.commentary } %}
That is a working live blog: server-rendered on first paint, updating itself afterwards.
What makes it fast
A live blog is a strange kind of content. It is written forty times an hour by someone watching something happen, and read by everyone at once. Most implementations treat each update as a content save on the parent entry, which means the entry's whole save pipeline — revisions, drafts, search index, cache invalidation — runs every time somebody types “corner”.
Live doesn't do that.
| An update is its own element | One lightweight save. No revisions, no provisional drafts, no search indexing, and the parent entry is never touched. |
| Order comes from a sequence, not a clock | Each update gets a per-post number allocated under a row lock, so two editors publishing in the same millisecond get 412 and 413 — and a reader that has seen 412 can ask for “everything after 412” with no clock skew in the conversation. |
| Delivery is static files | Each publish writes an immutable JSON file for the update and rewrites one ~180-byte head.json. Readers poll head; nginx or your CDN serves it without waking Craft. |
| The page cache is never invalidated | The HTML page can be cached for a day. Updates arrive over the top of it. That is the point. |
| Updates arrive pre-rendered | The JSON carries HTML rendered by your own Twig partial at publish time, so an appended update is byte-identical to a server-rendered one. There is no second implementation of the card in JavaScript. |
Requirements
Craft CMS 5.3+, PHP 8.2+.
Installation
composer require justinholtweb/craft-live php craft plugin/install live
Setting up
- Add a Live field to any entry type. That entry can now be live-blogged; the field's input is the composer.
- Add update types under Live → Update Types. One (“Update”) is created on install. Give each
its own field layout — a Goal that asks for a scorer, a Photo that asks for an image. Update types
are project config, and a type's title format is a Twig template, so — like Craft's entry types —
they are an admin's to change, and only where
allowAdminChangesis on. - Render the feed in your template:
{% include 'live/_feed.twig' with { feed: entry.commentary } %}
To take over the markup, copy live/_update.twig and live/_feed.twig into your own templates
directory. Craft looks there first, so a file you provide wins and the rest keep working.
Writing
The composer is a plain textarea and a row of buttons, one per update type. Markdown, then ⌘↵.
- Publishing never saves the entry, so two journalists on one match cannot overwrite each other.
- An update that fails to send is kept in a local queue and retried — a dropout in a press box costs you nothing.
- Editing an update leaves it where it is in the feed and bumps its revision, so readers holding the old copy quietly get the new one.
- There is a full-screen composer at Live → Posts for when the entry edit screen is in the way.
- Publishing, editing, pinning and deleting need permission to edit the entry as well as the Live permission; following the feed needs permission to view it.
- A live blog is only as public as its entry. Updates written on an entry that is scheduled, disabled or expired stay off the feed and out of the static files until it goes live.
Twig
{% set feed = entry.commentary %}
{{ feed.state }} {# upcoming | live | paused | ended #}
{{ feed.isLive }}
{{ feed.count }}
{{ feed.seq }} {# the current sequence number #}
{{ feed.pinned }} {# the pinned update, or null #}
{% for update in feed.updates.limit(50).all() %}
{{ update.postedAt|date('H:i') }}
{{ update.bodyHtml }}
{{ update.type.name }}
{% endfor %}
{% for goal in feed.updates.type('goal').all() %}…{% endfor %}
{% for key in feed.highlights.all() %}…{% endfor %}
craft.live covers the same ground without a field handle: craft.live.feed(entry),
craft.live.updates({ type: 'goal', limit: 10 }), craft.live.posts('live'), craft.live.seq(entry).
_feed.twig also prints schema.org LiveBlogPosting JSON-LD — coverage start and end times from
the post's state and the latest updates as BlogPosting items — which is what Google needs to show
a page as live coverage. With SEOmatic installed it goes into SEOmatic's JSON-LD instead of its own
tag. Copied the feed template? {{ craft.live.schema(entry.commentary) }} prints the same thing.
Because feed.seq changes on every publish, it is a safe cache key:
{% cache using key "feed-#{entry.id}-#{entry.commentary.seq}" %}
Editions
| Lite | Pro | |
|---|---|---|
| Composer, update types, Twig, static snapshots, polling | ● | ● |
| Pinning, key moments, states | ● | ● |
LiveBlogPosting structured data (SEOmatic-aware) |
● | ● |
| Other editors' presence (who else is on the post) | ● | |
| Scheduled and embargoed updates | ● | |
| Server-sent events | ● | |
| CDN purging (Cloudflare, Fastly, webhook) | ● | |
| GraphQL | ● |
GraphQL (Pro)
A headless front end polls liveFeed — one indexed row — and fetches updates only when the sequence
moves. Same shape as the JavaScript client, same reasoning.
{
liveFeed(postId: 923) {
seq
state
isLive
count
pinnedId
}
}
Then, when seq has moved:
{
liveUpdates(postId: 923, since: 412, orderBy: "seq asc") {
seq
rev
body
postedAt
typeHandle
... on goal_LiveUpdate {
scorer
}
}
}
liveUpdate, liveUpdateCount and the field itself are there too:
{
entry(id: 923) {
... on liveMatch_Entry {
commentary { seq state updates(limit: 20) { seq body } }
}
}
}
Ask for html and you get the update rendered through the site's own Twig partial — useful when the
front end wants the same markup the server would have produced. It costs a render per update, so it
is only ever produced when asked for.
Each update type is its own schema component (liveupdatetypes.<uid>:read), so a token can be given
the match commentary without being given the newsroom's internal feed. A type outside the schema
isn't merely filtered out of results — its GraphQL type doesn't exist for that token at all.
Testing
ddev start && ddev composer install ddev composer test
A PHP unit suite (no Craft, no database) and a JavaScript suite that runs against the shipped asset
files. See tests/README.md.
tests/integration/checks.php runs against a real Craft install (the shared plugin-testing site) and
covers the trust boundaries: who may publish, who may edit update types, and that an unpublished
entry's live blog stays off the feed and out of the web root.
tests/integration/schema.php checks the LiveBlogPosting output: coverage times through the
post's life cycle, the update items, options and event, </script> in a title, and the SEOmatic
hand-off.
docker exec -w /var/www/html ddev-plugin-testing-web php /var/www/craft-live/tests/integration/checks.php docker exec -w /var/www/html ddev-plugin-testing-web php /var/www/craft-live/tests/integration/schema.php
Console commands
php craft live/snapshots/rebuild 123 # rewrite one post's static files php craft live/snapshots/rebuild --all php craft live/snapshots/collect-garbage php craft live/scheduled/release # publish anything whose time has come (Pro)
A note on server-sent events
SSE is supported, and it is off by default. Every connected reader holds a PHP process open for as
long as they are connected, so on FPM your ceiling is pm.max_children — and you find out where that
ceiling is at exactly the moment a live blog goes well. Polling static JSON has no such ceiling. Turn
SSE on for an internal dashboard, not for a cup final.
Licence
The Craft License. See LICENSE.md. Lite is free; Pro is a paid edition, bought through the Craft
Plugin Store.