Building a Blog with a Modern CMS: A Hands-On Tutorial
From clone to live blog in 35 minutes — install, write, schedule, ship.
Most "build a blog" tutorials end at the database schema. Real blogs need an editor, media, scheduling, SEO meta, sitemaps, and a way to publish from your laptop without SSHing into the server. This tutorial walks through the full flow on a modern CMS — UnfoldCMS — and shows the exact commands that ship a post live in production.
TL;DR — A modern CMS gives you blog posts, draft scheduling, SEO meta, a media library, JSON-LD schema, and a sitemap out of the box. You don't write any of that yourself. You write the words and pick the categories. This post is the end-to-end tour: install, write your first post, attach a featured image, schedule it, and verify it ships. About 35 minutes from clone to live blog.
What This Tutorial Builds
A working blog with:
- A first post with title, body, featured image, and SEO meta
- A draft that gets scheduled to publish at a specific time
- A category and a permalink at
/blog/{slug} - A sitemap entry, JSON-LD schema, and an RSS-friendly JSON endpoint
You'll touch the admin UI and the artisan CLI. By the end, you can repeat the flow for any new post in under five minutes.
This is a hands-on tutorial. If you want the broader context on why a modern CMS beats WordPress for developer-built blogs, read why developers are leaving WordPress first — that piece sets up the problem this tutorial solves.
Why Build a Blog on a Modern CMS?
A "modern CMS" means three things in 2026:
- Built on a real framework. Not PHP soup. Laravel 12, React 19, TypeScript, Inertia 2 — code you can read and extend.
- Self-hosted. You own the database. No vendor read-quota meter ticking up every API call.
- Headless-ready. The blog renders server-side by default, but a JSON API ships in the box for when you want a Next.js or Astro frontend later.
The short version: you get the speed of a Jamstack setup with the editorial UX of a real CMS. For a deeper look, the developer's guide to choosing a modern CMS covers the trade-offs.
What you get without writing any code
| Capability | Status |
|---|---|
| Markdown/rich-text editor | Tiptap-based, in admin |
| Scheduled publishing | posted_at + scheduler cron |
| SEO title + meta description | Per-post fields, JSON-LD auto |
| Sitemap.xml | Dynamic, regenerates on save |
| Slug history + redirects | Old URLs keep working |
| Featured image with WebP conversions | Thumbnail, medium, large |
| Categories | Hierarchical, reorderable |
| Comments | Threaded up to 3 levels |
| Public JSON API | /api/blog/posts + /api/blog/posts/{slug} |
| Search | DB-backed via Spatie Searchable |
Every row above is shipped in the Core build. You don't add any of it.
Step 1 — Install and Boot the CMS
Three commands and a .env edit. Assume PHP 8.3+, Node 20+, and a fresh MySQL or SQLite database.
git clone https://github.com/hpakdaman/unfoldcms.git my-blog
cd my-blog
composer install
pnpm install
cp .env.example .env
php artisan key:generate
Open .env and set:
APP_URL=http://localhost:8000
DB_CONNECTION=sqlite
# leave DB_HOST/PORT/USER/PASS empty for sqlite
Then:
touch database/database.sqlite
php artisan migrate --seed
pnpm run build
php artisan serve
Visit http://localhost:8000. The default theme should render. Visit /admin and log in with the seeded admin user printed in the terminal output.
Why this is short: the CMS handles its own install. You don't wire an editor, a media library, or a router. Those exist before you write your first post.
What "Seeded" Means Here
The seeder creates:
- One super-admin user
- A handful of demo posts (delete them before publishing real content)
- Default categories
- Default settings (site title, blog posts-per-page, SEO fallbacks)
Delete the demo posts in the admin (or via tinker: Post::where('user_id', 1)->delete()) before going live.
Step 2 — Plan Your Content Model
Before you write a post, decide three things:
- Permalink pattern. Default is
/blog/{slug}. The CMS lets you change this in admin under permalink settings — posts can live at/posts/{slug}or any custom pattern. Pick once. Slug history keeps old URLs working if you change later. - Categories. One post can sit in multiple categories. Plan a small taxonomy (5-10 categories) — don't make 40 tags.
- Featured image strategy. Every post needs one. Upload from disk, pull from Unsplash (built into the admin), or attach via the API.
The CMS stores featured images in a Spatie Media Library collection called featured-image. It generates three conversions on upload — thumbnail (300×200 WebP), medium (600×400 WebP), large (1200×800 WebP). You reference them in blade with $post->getFirstMediaUrl('featured-image', 'large').
The Post Content Model
The schema is simple. One Post model serves four content types via a content_type enum: post, page, landing, block. For a blog, you only need post.
Important fields:
title,subtitle,slug,body,short_descriptionseo_title,meta_desc— separate from the title so you can write a search-optimized variantposted_at— the timestamp at which the post becomes visibleis_published— boolean. Set this true even for scheduled posts. The cron filters byposted_at <= now().allow_comments— toggle comments per postextra_attributes— JSON column for anything you want to attach without a migration
The body column stores HTML (the editor handles markdown → HTML). The extra_attributes column is a small escape hatch — useful for "estimated read time" or a "video URL" without changing the schema.
Step 3 — Write Your First Post (Admin UI)
Log into /admin and click Blog → Posts → New.
The form has four sections:
Content
- Title (required)
- Slug (auto-generated from title; editable)
- Short description (shows on the blog index)
- Body (rich-text editor with markdown shortcuts)
Featured image
- Upload, or click "From Unsplash" to search and pull directly into the media library
SEO
- SEO title (under 60 chars — appears in the browser tab and Google results)
- Meta description (under 155 chars)
- These are separate from the title field so you can A/B them later
Publishing
- Status: Draft / Published / Scheduled
- Scheduled date (if Scheduled)
- Categories (multi-select)
- Allow comments toggle
Click Save Draft first. Then preview by visiting /blog/{slug} while logged in (drafts are visible to admins).
When the post looks right, switch status to Published and click Save. It's live.
One gotcha: the CMS fallback for seo_title runs Str::title($post->title), which corrupts proper nouns. "WordPress" becomes "Wordpress". Always write the SEO title explicitly — don't leave it empty.
What the Editor Gives You
The editor is Tiptap-based. It supports:
- Headings (H2-H4)
- Bold, italic, strikethrough, inline code
- Bulleted and numbered lists
- Blockquotes
- Code blocks with syntax highlighting
- Tables
- Links with rel/target options
- Images (drag-drop or paste — auto-uploads to media library)
- HR rules and custom HTML blocks
- Markdown shortcuts (
##for H2,**bold**,>for blockquote)
If you prefer writing in your editor, paste markdown — the Tiptap editor parses it on the fly.
Step 4 — Add a Category
Don't skip this step. Posts without categories don't show up on category index pages, and they break the internal link tree. Every post needs at least one.
In admin: Blog → Categories → New. Give it a name and slug. Save.
Then go back to your post and tick the category in the multi-select. Save again.
If you want a hierarchy (e.g. "Tutorials" parent with "Laravel" and "React" children), drag-and-drop reorder in the categories admin. The tree renders on the public side automatically.
Step 5 — Schedule a Post for Later
This is where most CMSes get awkward. WordPress fakes scheduling with wp-cron, which only runs when someone visits the site. UnfoldCMS uses Laravel's scheduler — a real cron entry that runs every minute.
The scheduled command is blog:publish-scheduled-posts. It runs synchronously (no queue worker needed — fine on shared hosting).
In the admin: set status to Published, then set the scheduled date and time. Save.
Or via the CLI:
php artisan post:publish \
--title="My Scheduled Post" \
--slug="my-scheduled-post" \
--seo-title="My Scheduled Post — Site Name" \
--meta-desc="Short pitch for the post under 155 chars." \
--body-file="storage/posts/my-scheduled-post.md" \
--categories=2 \
--published \
--posted-at="2026-06-01 09:00:00"
The post is is_published=true immediately, but the public-facing query filters by posted_at <= now(). It won't appear on the blog index until the scheduled time. The cron runs every minute, so it flips visibility within 60 seconds of the timestamp.
Why Cron-Based Scheduling Beats Page-Hit Scheduling
WordPress's wp-cron problem: if no one visits the site, scheduled posts don't publish. A post scheduled for 2 AM on a low-traffic site might not appear until someone hits the homepage at 9 AM.
Real cron solves this. The scheduler runs every minute regardless of traffic. Set a post for 3:17 AM and it goes live at 3:17 AM.
This matters most for:
- Press release embargoes (post must appear at a specific minute)
- Coordinated multi-channel launches (blog + social + email at the same time)
- Off-hours publishing in time zones with low traffic
Step 6 — Add SEO Without a Plugin
Search optimization is built in. No Yoast, no Rank Math, no "SEO pack" plugin.
What ships in Core:
- Per-post SEO title and meta description — fields on the post form
- Dynamic sitemap.xml at
/sitemap.xml— regenerates on every post save - Dynamic robots.txt at
/robots.txt - JSON-LD schema —
Articleschema is rendered automatically on post pages via a@jsonld(schema_article($post))blade directive - Slug history — change a post's slug and the old URL still resolves
- Redirects manager at
/admin/seo/redirects— 301/302 rules with CSV import - llms.txt at
/llms.txtand/llms-full.txt— AI-crawler-friendly site summaries
For more on what actually moves the needle, headless CMS SEO best practices covers the underlying principles.
What Each Field Does for Search
| Field | Where It Appears | Length |
|---|---|---|
seo_title |
Browser tab + Google blue link | Under 60 chars |
meta_desc |
Google snippet under the link | Under 155 chars |
slug |
URL path | Short, keyword-rich |
short_description |
Blog index card | 1-2 sentences |
body |
The article itself | Whatever the post needs |
The seo_title and meta_desc are the only two fields that directly influence how Google displays your result. Write them with intent — don't let the CMS auto-fill them.
Adding Custom Schema
If your post is a how-to, you want HowTo schema instead of (or alongside) Article. Edit the blade template for blog posts:
@jsonld([
'@context' => 'https://schema.org',
'@type' => 'HowTo',
'name' => $post->title,
'step' => collect($steps)->map(fn($s, $i) => [
'@type' => 'HowToStep',
'position' => $i + 1,
'name' => $s['name'],
'text' => $s['text'],
])->all(),
])
The @jsonld() directive escapes and prints the JSON-LD block. You can chain multiple — Article + HowTo + FAQPage on the same page is fine.
Step 7 — Expose the Blog as a Headless API
The CMS renders the public blog server-side by default. But it also ships a small read-only JSON API for headless setups.
# List published posts (paginated)
curl https://your-site.com/api/blog/posts?per_page=12
# Filter by category slug
curl https://your-site.com/api/blog/posts?category=tutorials
# Fetch a single post
curl https://your-site.com/api/blog/posts/my-scheduled-post
Response shape per post:
{
"id": 42,
"title": "My Scheduled Post",
"slug": "my-scheduled-post",
"excerpt": "Short pitch from short_description...",
"featured_image": "https://your-site.com/storage/.../medium.webp",
"author": { "name": "Hamed Pakdaman", "avatar": "..." },
"categories": [{ "id": 2, "name": "CMS", "slug": "cms" }],
"reading_time": 7,
"posted_at": "2026-06-01T09:00:00Z",
"formatted_posted_at": "Jun 1, 2026"
}
That's enough to render a blog on Next.js, Astro, SvelteKit, or any framework that can hit JSON. If you want concrete examples per framework, see the best CMS for Next.js, the best CMS for Astro, or the best CMS for SvelteKit.
Limits to know:
- Read-only. No POST/PUT/DELETE on the public endpoint.
- Returns published posts only. No preview tokens for drafts.
- No GraphQL. REST JSON only.
- No public endpoint for pages or settings.
For most blogs, that's enough. If you need full CRUD over an API, an authenticated key-based endpoint exists internally — but it's not yet documented as a customer-facing feature.
Step 8 — Verify It's Live
Three checks before you call the post "shipped":
# 1. The post resolves at the expected URL
curl -I https://your-site.com/blog/my-scheduled-post
# Expect: HTTP/2 200
# 2. It appears in the sitemap
curl -s https://your-site.com/sitemap.xml | grep my-scheduled-post
# 3. It appears in the JSON API
curl -s https://your-site.com/api/blog/posts/my-scheduled-post | head -5
If all three pass, the post is fully wired into the public site, the sitemap, and the headless API.
Submit to Google
In Google Search Console, paste the post URL into the URL Inspection tool and click "Request indexing". This nudges Google to crawl the new URL within a few hours instead of waiting for a sitemap scan. The CMS doesn't do this for you — but the sitemap entry means GSC will find it eventually anyway.
What This Tutorial Skipped (And Where to Go Next)
This is a "first post" tutorial. There's more shipped in the box that you'll want as the blog grows:
- Comments — threaded up to 3 levels, admin moderation, optional guest comments. Toggle per-post.
- Newsletter forms (Pro tier) — email-capture lead magnets with double opt-in, file or URL downloads, signed links
- Social posting (Pro tier) — auto-share new posts to Discord, Telegram, Bluesky, Mastodon, Slack
- Analytics (Pro tier) — read-only GA4 dashboard pulled via Google API
- Backups (Pro tier) — Spatie Laravel Backup wired into admin
- Redirects manager — Core. Import a CSV of 301s when you migrate from WordPress
If you're coming from WordPress and want the migration path, see the CMS migration guide for developers.
If you want to compare this approach to alternatives before committing, the 10 best WordPress alternatives in 2026 lists what else is in the space.
FAQ
Do I need to know Laravel to use this CMS?
No, for writing posts. The admin UI is the same as any other CMS — title, body, save. You'd only touch Laravel if you wanted to add custom fields, change templates, or extend the API. The default install ships a working blog without any code edits.
Can I write posts in markdown?
Yes. The editor accepts markdown shortcuts in real time (## for H2, **bold**, etc.). You can also paste full markdown documents — the editor parses them. Or use the CLI: post:publish --body-file=path/to/post.md reads markdown from a file.
How do I migrate an existing WordPress blog?
Export your WordPress posts as XML (Tools → Export → Posts). The CMS doesn't ship a one-click WP importer, but there's a migration guide with the SEO-safe workflow — slug preservation, redirect CSVs, media folder rsync. Plan a half-day for a small blog.
Does scheduling work on shared hosting?
Yes. The CMS uses Laravel's scheduler with QUEUE_CONNECTION=sync by default. One cron entry (* * * * * php artisan schedule:run) runs everything. No queue worker, no Supervisor, no Redis. Most shared hosts support a cron entry.
Can I run the blog headless with Next.js or Astro?
Yes. The /api/blog/posts and /api/blog/posts/{slug} endpoints return JSON. You fetch them from your frontend framework like any other REST API. Server-side rendering still works in the CMS — you can run both modes side by side during a migration.
Where does the featured image come from?
Three sources: upload from disk, paste from clipboard into the editor (auto-uploads), or click "From Unsplash" in the admin to search Unsplash and pull the image directly into the media library. The CMS generates WebP conversions on save — thumbnail, medium, and large.
What to Do Next
You have a blog. Three things to do this week:
- Write three posts. Get the editor flow into your fingers. The fourth post will take 5 minutes.
- Set up categories. Plan a small taxonomy (5-10 categories) and stick to it. Don't add a new category for every post.
- Connect Google Search Console. Submit the sitemap (
/sitemap.xml) and start tracking impressions. The CMS does the SEO meta — GSC tells you whether it's working.
If you want to see the CMS in action before installing, the demo site is a working Core install you can edit. Or check pricing if you're evaluating for production.
Related: The Developer's Guide to Modern CMS, Best CMS for React Developers in 2026, The Modern CMS Stack: Laravel + React + Inertia.
Methodology
All commands and field names in this tutorial were verified against UnfoldCMS source as of May 24, 2026. Post model fields and conversions confirmed in app/Models/Blog/Post.php (lines 219–250). Public API endpoints verified in routes/web.php line 280 and app/Http/Controllers/Public/BlogController.php. Scheduled-post behavior confirmed in routes/console.php — blog:publish-scheduled-posts runs every minute, synchronously.
Share this post:
Leave a Comment
Please log in to leave a comment.
Don't have an account? Register here