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);
ScopeDescription
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

AttributeNotes
idUUID
slugUnique per author
title, summaryPlain text
bodyHTML from the editor
published_atnull draft · future scheduled · past/now live
featured_imageURL or /storage/... path
metaSEO fields (title, description, canonical_link, …)
pendingUnpublished edits (editor only)
user_idHost user id
topic_idOptional 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 storedFull 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 labelsOptional 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 pending so 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).

ContentTypical markup
Textp, h1–h3, lists, blockquote
Imagesimg.canvas-post-body-image
Codepre.canvas-post-body-code
YouTubediv[data-youtube-video] > iframe
Video embedsdiv[data-canvas-iframe][data-layout="video"]
X / Twitterdiv[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.