Content
September 14, 2026 · View on GitHub
Introduction
Canvas is headless: published content lives in the database. Build your public site with Eloquent (or an API you own). The JSON routes under /canvas/api power the admin SPA only — they require a signed-in Canvas user and are not a public blog API.
Models live under Canvas\Models\.
Retrieving posts
use Canvas\Models\Post;
$posts = Post::published()
->with(['user', 'topic', 'tags'])
->latest()
->paginate();
$post = Post::published()
->with(['user', 'tags', 'topic'])
->firstWhere('slug', $slug);
| Scope | Description |
|---|---|
published() | published_at is set and not in the future |
draft() | No published_at, or a future published_at |
Published vs pending
While a post is live, the editor autosaves into a pending JSON column so the public columns stay stable. Readers must use the live columns (title, body, and so on), never pending. Promoting or publishing in the admin merges pending into the public snapshot.
Useful attributes
| Attribute | Notes |
|---|---|
id | UUID |
slug | Unique per author |
title, summary | Plain text |
body | HTML from the editor |
published_at | null draft · future scheduled · past/now live |
featured_image | URL or /storage/... path |
meta | SEO fields (title, description, canonical_link, …) |
pending | Unpublished edits (editor only) |
user_id | Host user id |
topic_id | Optional topic |
When a future published_at elapses, published() already includes the post. Canvas runs canvas:announce-scheduled every minute so domain events and outbound webhooks receive PostPublished without another editor save. Opening the editor or promoting pending changes after the clock has elapsed does not itself fire PostPublished; the scheduler still announces once.
Version history
The admin stores content checkpoints in canvas_post_revisions for editorial recovery — not every autosave, and never for public readers.
| What is stored | Full editor-visible snapshot: title, slug, summary, body, featured image fields, SEO meta |
|---|---|
Why (reason) | Lifecycle moments such as first version, published, scheduled, updated, left editor, restored |
| User labels | Optional names (rename in the UI) for quick filtering |
Checkpoints are created when a post is first saved with content, when visibility changes (publish / schedule / unpublish), when a live Update promotes changed content, when the author leaves the editor with unsaved-session content, and when a revision is restored. Draft and live pending autosaves do not append history rows.
Restore vs pending
- Draft / scheduled: restore writes the snapshot into the public columns (the editor’s working state).
- Live posts: restore writes into
pendingso readers still see the live public columns until an editor promotes with Update.
Public frontends must never join or expose revision rows. Use Post::published() and the live columns only.
Retention
Canvas keeps the newest 50 checkpoints per post (prune-on-write after each new row). Named revisions are not exempt — a label does not protect a row from retention. Operators may tighten or re-run:
php artisan canvas:prune-post-revisions
php artisan canvas:prune-post-revisions --keep=25
The host scheduler runs the prune weekly.
Tags, topics, and authors
use Canvas\Models\Tag;
use Canvas\Models\Topic;
use Canvas\Models\CanvasUser;
$posts = Tag::firstWhere('slug', $slug)
->posts()
->published()
->latest()
->paginate();
$posts = Topic::firstWhere('slug', $slug)
->posts()
->published()
->latest()
->paginate();
$author = CanvasUser::query()->where('username', $username)->firstOrFail();
$posts = Post::query()
->where('user_id', $author->user_id)
->published()
->latest()
->paginate();
SEO
use Canvas\Support\PostSeo;
$seo = PostSeo::resolve($post, url()->current());
Returns title, description, canonical URL, and image fields suitable for meta tags and JSON-LD.
Views and visits
To record a view on your own show route:
event(new \Canvas\Events\PostViewed(
post: $post,
ip: request()->ip(),
agent: request()->userAgent(),
referer: request()->header('referer'),
));
Apply Canvas\Http\Middleware\Session on that route so session keys stay tidy (Canvas UI does this for you).
Post body HTML
body is HTML from the TipTap editor, not Markdown. Escape titles and summaries; render body as HTML intentionally ({!! $post->body !!} in Blade).
| Content | Typical markup |
|---|---|
| Text | p, h1–h3, lists, blockquote |
| Images | img.canvas-post-body-image |
| Code | pre.canvas-post-body-code |
| YouTube | div[data-youtube-video] > iframe |
| Video embeds | div[data-canvas-iframe][data-layout="video"] |
| X / Twitter | div[data-canvas-iframe][data-layout="card"] |
Canvas UI ships embed CSS and a small script for X card height in ui/partials/embeds.blade.php. Custom frontends should reuse that pattern — tweet iframes do not resize with CSS alone.
Media on the public disk is usually a root-relative /storage/... path. Run storage:link if images 404.
Building your own API
use Canvas\Models\Post;
use Canvas\Support\PostSeo;
Route::get('/blog/posts', function () {
return Post::published()
->select(['id', 'slug', 'title', 'summary', 'featured_image', 'published_at', 'user_id', 'topic_id'])
->with(['topic:id,name,slug', 'user:id,name'])
->latest()
->paginate(10);
});
Route::get('/blog/posts/{slug}', function (string $slug) {
$post = Post::published()
->with(['tags:name,slug', 'topic:id,name,slug', 'user:id,name'])
->firstWhere('slug', $slug) ?? abort(404);
return [
'post' => $post,
'seo' => PostSeo::resolve($post, url("/blog/{$slug}")),
];
});
Never expose pending or sensitive host user fields. For cache invalidation and SSG rebuilds, prefer webhooks.