Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Canvas Laravel Package

austintoddj/canvas

Canvas is a minimal, elegant publishing platform for Laravel. It adds a distraction-free writing dashboard with posts, tags, and media management, so developers can launch a fast, self-hosted blog or publication without wrestling with a heavyweight CMS.

View on GitHub
Deep Wiki
Context7

Getting Started

Canvas is a self-hosted publishing platform that drops a full blog (posts, tags, topics, series, scheduled publishing, and an admin UI) into an existing Laravel app. You are not scaffolding views from scratch — you are mounting an opinionated content system and then deciding how much of its front-end you keep.

Minimal path to a working blog:

composer require austintoddj/canvas
php artisan canvas:install   # publishes config/assets and runs migrations
php artisan storage:link     # if you plan to use uploaded media

Then check these three places, in this order:

  1. config/canvas.php — the route prefix/path, the middleware stack protecting the admin, the auth guard, the storage disk for uploads, and any Unsplash integration keys. This is the file that determines whether Canvas lives at /canvas, /blog, or /admin/blog.
  2. routes/web.php — confirm how Canvas's routes are registered in your app and whether they sit behind web only or an auth group. Everything else follows from this.
  3. The database — canvas_posts, canvas_tags, canvas_topics, canvas_series. Read the posts table once; the columns (slug, published_at, featured_image, meta, is_published) tell you what the package considers first-class.

A good first use case is the lowest-risk one: mount Canvas at a non-public path, create one post through the admin UI, and render it via a deliberately hand-written Blade route (/blog/{slug}). That exercises install, auth, media, and the read side without committing you to the package's front-end conventions.

On v7.2.5+, reach for Post::forPublicSlug($slug) as the canonical public lookup rather than trusting a raw slug match — see the slug notes under Gotchas.

Implementation Patterns

Use the Eloquent models, not raw queries. Canvas's models (Canvas\Models\Post, Tag, Topic, Series) already carry the scopes you want. Build your own controller and keep the front-end fully yours:

use Canvas\Models\Post;

Route::get('/blog/{slug}', function (string $slug) {
    $post = Post::forPublicSlug($slug)->firstOrFail();

    abort_unless($post->is_published && $post->published_at <= now(), 404);

    return view('blog.show', [
        'post' => $post->load('tags', 'topic'),
        'related' => Post::published()->where('topic_id', $post->topic_id)
            ->whereKeyNot($post->id)->latest('published_at')->take(3)->get(),
    ]);
});

forPublicSlug() (added in v7.2.5) resolves a slug the way the package itself does: when duplicate slugs exist, it picks the earliest published post. Using it keeps your hand-written routes consistent with Canvas's own UI instead of drifting onto whichever row implicit binding happens to find first. If you prefer route-model binding, keep {post:slug} only on databases you know have no duplicate slugs; otherwise bind on the scope.

Treat published_at as the scheduling primitive. Drafts, published posts, and future-dated posts are one column apart. Any public query should filter on both is_published and published_at <= now(), and any cached index page should be invalidated on save, not on a timer — otherwise scheduled posts appear late.

Keep the admin UI and the public site on separate middleware stacks. Canvas ships with its own routes; the common pattern is to let it own /canvas behind your auth (or an admin gate) middleware, while your public blog routes live in web with your own caching and SEO middleware. Mixing the two tends to leak admin-only behaviour into public responses.

Model content relationships deliberately. Topics and Series are navigational, Tags are descriptive. Deciding early which one owns the URL structure (/blog/topics/{topic} vs /blog/tags/{tag}) prevents a painful redirect pass later. Series is the right tool for multi-part tutorials; don't emulate it with tags.

Use the config file as the extension point for behaviour you'd otherwise fork. Storage disk for images, Unsplash for featured-image search, and the route prefix are all config-driven. Forking vendor views to change a path is a maintenance tax you'll pay on every release — override the config or wrap the route instead. The one exception is the published CanvasUiController: if you've published the UI, expect to keep its show() method in sync with upstream (see Gotchas).

Upgrade as a ritual, not an afterthought. The published upgrade steps — composer update, php artisan canvas:migrate, php artisan canvas:publish — are not optional ceremony; canvas:publish refreshes published assets and config stubs, and canvas:migrate applies schema changes. If you have published the UI, add php artisan canvas:ui --force to the sequence, then re-apply any local customizations. Run the whole thing against a staging copy first, especially on releases that touch slugs or routes.

Gotchas and Tips

Slug generation changed in v7.2.3 — and only for new posts. Prior to that release, a newly created post was stored with a post-{uuid} slug, which meant every URL was opaque and effectively permanent unless manually edited. v7.2.3 makes new posts derive their slug from the title. Practical consequences:

  • Existing posts keep their post-{uuid} slugs. Nothing rewrites them. If you want clean URLs across the archive, you'll be writing a one-off backfill that regenerates slugs from titles — do it as a queued job with a redirect map, not a raw Post::all() loop.
  • Slug collisions are now handled rather than fatal: v7.2.5 gives an already-taken slug a numeric suffix instead of letting it collide. That makes title-derived slugs safe to adopt at scale, but it also means the slug you expect from a title (release-notes) may be release-notes-2. Never hard-code a slug you derived by hand — read it back from the model.
  • Pre-existing duplicate slugs are tolerated, not repaired. v7.2.5 resolves them deterministically: a public lookup returns the earliest published post. That's a stable rule, but it is not the same as uniqueness — decide whether you want to consolidate the duplicates or live with the earliest-wins behaviour, and document the choice.
  • Non-ASCII titles and punctuation-heavy headlines are where title-derived slugging usually breaks. Check a handful of real titles after upgrading, not just "Hello World".
  • If you have existing permalinks in the wild, do not touch them casually. The SEO cost of changed URLs on published, indexed content is real; the cost of ugly slugs on a new blog is not.

canvas:publish overwrites — and so does canvas:ui --force. Any direct edits to published views, config stubs, assets, or the published CanvasUiController are lost. Customize by overriding from your app (your own views, your own config values) and leave the vendor-published copies pristine — or at minimum, track your diffs in version control so the overwrite is visible. v7.2.5 is the concrete case: if you published the UI, either run canvas:ui --force and lose your edits, or hand-patch show() in your CanvasUiController to use Post::forPublicSlug($slug). Pick one deliberately and write the choice down, because the next upgrade will ask again.

Config caching bites after every config change. config/canvas.php edits do nothing until php artisan config:cache runs in environments that cache. The classic symptom is "I changed the route prefix and nothing happened."

Auth surprises are the most common integration failure. If Canvas's admin routes return a redirect loop or a 401 in a way that doesn't match your app's guard, the cause is almost always a guard/middleware mismatch between your auth config and Canvas's declared middleware. Check config/canvas.php before you start writing custom middleware.

Media and storage disks. Uploaded images resolve through the configured disk plus the public storage symlink. Broken featured images after a deploy usually mean storage:link was never run on that host, or the disk was changed without migrating existing files.

Scheduled posts and timezones. published_at is compared against now(); if your app timezone and your editorial expectations disagree, posts go live at the wrong hour. Confirm the timezone the admin UI writes with before you trust the schedule.

Debugging checklist for a "missing" post: is is_published true, is published_at in the past in app timezone, does the slug match the route binding (old UUID slugs vs new title slugs vs numeric-suffixed duplicates), and is a cached index page serving a stale list? Those four cover most reports, in that order. If you served the wrong post rather than none, that's the duplicate-slug case — switch the lookup to Post::forPublicSlug() and confirm the earliest published post is the one you intended.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console