ojessecruz / simple-blog
A Laravel lightweight blog to quickly publish and share ideas.
Fund package maintenance!
Requires
- php: ^8.3
- illuminate/contracts: ^11.0||^12.0||^13.0
- league/commonmark: ^2.10
- livewire/livewire: ^3.8.3||^4.3.4
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^11.0.0||^10.0.0||^9.0.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v0.9.4
- v0.9.3
- v0.9.2
- v0.9.1
- v0.9.0
- v0.8.8
- v0.8.7
- v0.8.6
- v0.8.5
- v0.8.4
- v0.8.3
- v0.8.2
- v0.8.1
- v0.8.0
- v0.7.5
- v0.7.4
- v0.7.3
- v0.7.2
- v0.7.1
- v0.7.0
- v0.6.1
- v0.6.0
- v0.5.1
- v0.5.0
- v0.4.3
- v0.4.2
- v0.4.1
- v0.4.0
- v0.3.2
- v0.3.1
- v0.3.0
- v0.2.0
- v0.1.0
- dev-feat/image-uploads
- dev-fix/view-counting
- dev-fix/xml-short-open-tag
- dev-feat/heading-anchors-toc
- dev-fix/blog-ui-p0-p1
This package is auto-updated.
Last update: 2026-10-23 19:51:52 UTC
README
A ready-to-use Laravel blog — minimal reading-focused public listing, admin CRUD with Livewire, Markdown with HTML escaping, and draft preview in a new tab. Zero opinions on authentication: you plug it in via Laravel middleware (Gate, guard, or any combo).
Requirements
- PHP 8.3+
- Laravel 11 / 12 / 13
- Livewire 3.8.3+ or 4.3.4+
- Tailwind CSS in the consuming app (the package ships Tailwind classes, it does not compile its own CSS)
Installation
composer require ojessecruz/simple-blog
Quick install (recommended)
One command publishes the config & migrations and wires Tailwind up for you:
php artisan simple-blog:install
It is idempotent — safe to re-run — and will:
- publish
config/blog.phpand the migrations; - add the Tailwind source
@importtoresources/css/app.css(Tailwind v4), right after your@import "tailwindcss";; - offer to run the migrations.
Afterwards, protect the admin routes (see below) and run npm run build. To wire things up by hand instead, follow the manual steps.
Manual installation
Publish and run the migrations:
php artisan vendor:publish --tag="simple-blog-migrations"
php artisan migrate
Publish the config (optional, but recommended):
php artisan vendor:publish --tag="simple-blog-config"
Publish the views (optional, only if you want to customize):
php artisan vendor:publish --tag="simple-blog-views"
If your default disk is Laravel's local one, link the public disk so uploaded cover and social images are served (see Image uploads):
php artisan storage:link
Make Tailwind aware of the package views, otherwise it won't compile the classes they use and the blog renders unstyled.
Tailwind v4 (CSS-based config) — import the package's CSS entrypoint from your app's main stylesheet. It registers the package views as a source, and because the path is resolved relative to the package file, your app never has to know the package's internal structure:
/* resources/css/app.css */ @import 'tailwindcss'; @import '../../vendor/ojessecruz/simple-blog/resources/css/simple-blog.css';
Tailwind v3 (tailwind.config.js) — add the views to the content array:
content: [ // ... your existing paths ... './vendor/ojessecruz/simple-blog/resources/views/**/*.blade.php', ],
If you published the views (--tag="simple-blog-views"), they live in resources/views/ and are already covered by your app's default Tailwind sources — but keep the import above if you rely on any non-published views (e.g. the admin panel).
Quickstart
Three steps and your blog is up.
1. Protect the admin routes
The package does not embed any authorization logic. You plug it in via middleware in config/blog.php. Examples:
// Via a specific Gate 'admin_middleware' => ['web', 'auth', 'can:manage-blog'], // Via a separate guard 'admin_middleware' => ['web', 'auth:admin'], // Combo of your app's own middleware 'admin_middleware' => ['web', 'auth', 'verified', 'super.admin'],
If you go with a Gate, define it as usual in AuthServiceProvider:
Gate::define('manage-blog', fn ($user) => $user->is_admin === true);
2. Configure the author model
Point it to your app's User:
// config/blog.php 'author_model' => App\Models\User::class,
And make User implement the Author contract:
use Jessecruz\SimpleBlog\Contracts\Author; class User extends Authenticatable implements Author { public function getBlogAuthorName(): string { return $this->name; } public function getBlogAuthorInitials(): string { $words = preg_split('/\s+/', trim($this->name)) ?: []; $initials = array_map( fn (string $w) => mb_strtoupper(mb_substr($w, 0, 1)), array_slice($words, 0, 2), ); return implode('', $initials); } public function getBlogAuthorAvatarUrl(): ?string { return $this->avatar_url; // or null if you don't have avatars } }
3. Access it
- Public:
https://yourapp.com/blog - Admin:
https://yourapp.com/admin/blog
Done.
Routes
The package registers:
| Method | URL | Name | Description |
|---|---|---|---|
| GET | /blog |
blog.index |
Public listing (?page=2, …) |
| GET | /blog/category/{slug} |
blog.category |
Posts in a category (?page=2, …) |
| GET | /blog/feed |
blog.feed |
RSS feed of the latest posts |
| GET | /blog/sitemap.xml |
blog.sitemap |
XML sitemap of the blog |
| GET | /blog/{slug} |
blog.show |
Individual post |
| GET | /admin/blog |
blog.admin.index |
Admin list (filter/search) |
| GET | /admin/blog/create |
blog.admin.create |
Create form |
| GET | /admin/blog/{slug}/edit |
blog.admin.edit |
Edit form |
| GET | /admin/blog/{slug}/preview |
blog.admin.preview |
Preview a draft/scheduled post |
| GET | /admin/blog/categories |
blog.admin.categories |
Categories CRUD |
The /blog and /admin/blog prefixes are configurable (route_prefix, admin_route_prefix).
When the JSON API is enabled, it also registers the blog.api.* routes under /api/blog.
Configuration
See config/blog.php (published) — every key has comments explaining what it does, with examples. Summary:
route_prefix/admin_route_prefix— where to mount the routespublic_middleware/admin_middleware— middleware stacksapi— opt-in JSON API: enable flag, prefix, middleware, token and page size (see JSON API)per_page— page size of the public index and category listings (default 12)author_model— User modellayouts.public/layouts.admin— Blade layouts wrapping the contentcta_view— optional view rendered at the end of each post (e.g. pricing, newsletter)mid_cta_view— optional view inserted once inside the post body (see Injecting a CTA into posts)share— which share buttons appear on posts, in what order, and the X handle to credit (see Share buttons)toc— table of contents above posts (on/off, minimum headings, depth) and "#" heading links (see Table of contents and heading links)feed/sitemap— RSS feed (on/off, item count, full content) and XML sitemap (on/off) (see RSS feed and sitemap)views— post view counting: on/off, skip bots, once per visitor session (see View counting)images— cover and social image uploads: on/off, disk (default:FILESYSTEM_DISK), folder and size limit (see Image uploads)publishing— schedule of the command that firesPostPublished, announce window, webhook URLs and signing secret (see Publishing events and webhooks)public_back_url/admin_back_url— where the "back" links point (nullhides them)seo— site/blog names, default description and images used in titles, meta tags and the Article JSON-LD (see SEO)timezone— timezone editors type publish dates in and readers see them in; the database stays inapp.timezone(see Dates and timezones)markdown— options passed toStr::markdown()
Customizing the layout
The package ships two neutral default layouts, both pulled from config/blog.php:
- public (
blog::layouts.public) — used for/blogand/blog/{slug}. Plain HTML, no nav, content rendered through@yield('content'). - admin (
blog::layouts.admin) — used for/admin/blog. Slot-based, content rendered through{{ $slot }}so it can be pointed straight at modern Laravel layouts.
Most apps want the public side wrapped in their site chrome (header, nav, footer, SEO meta) and the admin inside their auth shell.
Public layout: build it from scratch
The simplest path is creating a dedicated layout for the blog with @yield('content'):
{{-- resources/views/layouts/blog-public.blade.php --}} <!DOCTYPE html> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> @stack('head') {{-- Required: the package pushes <title>, meta, OG tags and JSON-LD here --}} <link rel="canonical" href="{{ \Jessecruz\SimpleBlog\Support\Seo::canonicalUrl() }}"> {{-- keeps ?page=N --}} @vite('resources/css/app.css') </head> <body class="bg-white text-gray-900"> <header class="border-b"> <div class="max-w-3xl mx-auto px-4 py-4"> <a href="/" class="text-xl font-bold">{{ config('app.name') }}</a> </div> </header> <main> @yield('content') </main> <footer class="border-t mt-16 py-8 text-center text-sm text-gray-500"> © {{ date('Y') }} {{ config('app.name') }} </footer> </body> </html>
Then point the config at it:
'layouts' => [ 'public' => 'layouts.blog-public', 'admin' => 'blog::layouts.admin', ],
If your layout already loads its own CSS via @vite, set 'assets' => [] in config/blog.php to avoid loading it twice.
Public layout: reuse a Blade component (slot-based)
If your app already has a slot-based layout (Breeze, Jetstream, Folio, or a custom <x-site-layout>), create a small wrapper that bridges @yield('content') and {{ $slot }}:
{{-- resources/views/layouts/blog-public.blade.php --}} <x-site-layout> @yield('content') </x-site-layout>
'public' => 'layouts.blog-public',
Three lines of plumbing, your app's chrome wrapping the blog.
Admin layout
The admin Livewire components use ->layout() (slot-based), so the admin layout key can point at any slot-based view directly:
'admin' => 'layouts.app', // your <x-app-layout> view, no wrapper needed
If your admin layout is @yield-based instead, build a small slot bridge:
{{-- resources/views/layouts/blog-admin.blade.php --}} <!DOCTYPE html> <html> <head> @vite('resources/css/app.css') @livewireStyles </head> <body> {{ $slot }} @livewireScripts </body> </html>
Required directives in your layout
The package emits the <title>, meta description, OG tags and JSON-LD of every public page via @push('head'). For these to render, your layout's <head> must contain:
@stack('head')
Without it, titles and meta tags won't appear in the head. Do not add your own <title> to the public layout — the page already pushes one, and you would end up with two.
SEO
Every public page renders exactly one <title>, a meta description when one is available, Open Graph / Twitter tags and, on published posts, a schema.org Article. The defaults work with zero configuration and derive from config('app.name'):
| Page | <title> |
Description |
|---|---|---|
| Index | Blog | {app.name} |
seo.description |
| Category | {Category} | Blog | {app.name} |
category description, then seo.description |
| Post | {meta_title or title} | Blog | {app.name} |
meta_description, then excerpt |
Tune them in config/blog.php:
'seo' => [ 'site_name' => 'Hoot', // brand; null → config('app.name') 'blog_name' => "Hoot's Blog", // index title + suffix of the other pages 'index_title' => "Hoot's Blog | Product feedback and roadmaps", // optional tagline for the index only 'description' => 'Guides on collecting feedback and shipping it.', // index subtitle + meta description 'image' => 'https://usehoot.app/img/og-card.png', // default og:image 'publisher_logo' => 'https://usehoot.app/img/logo.png', // Article publisher logo ],
The index description is also shown as the subtitle under the index heading (a category shows its own description); without one, no subtitle is rendered.
Paginated listings canonicalize to themselves (/blog?page=2), and other query strings are dropped from the canonical URL; pages past the last one return 404. Admin previews of drafts and scheduled posts send X-Robots-Tag: noindex, nofollow and a matching meta tag.
Posts use og_image, then cover_image, then seo.image for their social image. The Article JSON-LD includes headline, description, author (from your Author model), publisher, image, datePublished, dateModified and inLanguage.
If you publish and customize the public views, Jessecruz\SimpleBlog\Support\Seo exposes the same resolvers (indexTitle(), categoryTitle($category), postTitle($post), postImage($post), articleSchema($post), canonicalUrl(), …) so your templates keep the fallbacks.
Customizing the visual style
The package ships with emerald as the accent colour and zinc as the neutral. To brand it differently, publish the views and edit them directly. Three granularities are available:
# Everything (public + admin + layouts + icons) php artisan vendor:publish --tag="simple-blog-views" # Just the public side (index, show, share and toc components, feed, sitemap, public layout) php artisan vendor:publish --tag="simple-blog-views-public" # Just the admin side (Livewire admin views + admin layout) php artisan vendor:publish --tag="simple-blog-views-admin" # Just the layouts (public and admin shells, no inner content) php artisan vendor:publish --tag="simple-blog-views-layouts"
The Blade files land in resources/views/vendor/blog/. From that point Laravel uses your published copies, so a global find-and-replace (emerald- → blue-, emerald- → rose-, etc.) is enough to retheme that slice.
Trade-off: published views are frozen at the version you published — bug fixes and new features that ship in later releases of the package won't reach them automatically. Publishing only the slice you actually want to customize (admin or public) keeps the rest tracking upstream.
Injecting a CTA into posts
Create a view (e.g. resources/views/components/blog-cta.blade.php) and point to it:
'cta_view' => 'components.blog-cta',
The view receives the $post variable and is rendered after the post content (on show) and below the feed (on index).
Mid-article CTA
To also show a CTA inside the post body, point mid_cta_view at another view (it can be the same one):
'mid_cta_view' => 'components.blog-mid-cta',
It is inserted once per post, only between top-level blocks (never inside a list, table, quote or code block):
- posts with two or more
##headings get it right before the second heading, i.e. after the first section; - otherwise it goes after the block that ends nearest ~40% of the content;
- short posts (fewer than 4 top-level blocks) don't get it, so it never sits at the very top or bottom.
The view receives $post and is wrapped in <div class="my-10" data-blog-mid-cta>. It is independent from cta_view: set either, both or neither.
Share buttons
Published posts show a row of share buttons in their footer. Pick the networks and their order in config/blog.php:
'share' => [ 'networks' => ['whatsapp', 'x', 'linkedin', 'facebook', 'copy', 'native'], // default 'x_via' => 'usehoot', // optional: "via @usehoot" on X ],
| Network | What it does |
|---|---|
whatsapp, x, linkedin, facebook, telegram |
Opens the network's share page in a new tab, pre-filled with the post URL (and title where supported) |
email |
mailto: link with the title as subject and the excerpt + URL as body |
copy |
Copies the post URL to the clipboard and confirms with a check icon and a screen-reader announcement |
native |
Opens the device's share sheet (Web Share API), which lists every app installed (Instagram, Telegram, Messages…). Hidden in browsers without support, such as desktop Firefox |
- An empty
networksarray hides the share block; unknown entries are ignored. - Every button has a translated
aria-label. The small inline script behindcopyandnativeis emitted once per page and carriesVite::cspNonce()when your app sets a CSP nonce. - Shares are not rendered on the admin preview of drafts or scheduled posts, which have no public URL yet.
- Link previews on the networks come from the post's Open Graph tags (see SEO).
The block is a component, so a published view can place it anywhere (e.g. also below the title):
<x-blog::share :post="$post" />
To change its markup, publish the public views (--tag="simple-blog-views-public") and edit resources/views/vendor/blog/components/share.blade.php. Jessecruz\SimpleBlog\Support\Share exposes the resolvers it uses (networks(), links($post), url($network, $post), label($network)) so custom templates keep the config behavior.
Table of contents and heading links
Every heading in a post body gets an id built from its text, so any section can be linked to directly: ## Como começar becomes /blog/my-post#como-comecar (repeated headings get -2, -3, …; headings with nothing to slug, such as only emoji, fall back to section). Hovering a heading shows a # link to copy that address; on touch screens it stays faintly visible.
Posts with at least three headings also get a collapsible "In this article" table of contents above the body. Tune both in config/blog.php:
'toc' => [ 'enabled' => true, // table of contents above the body 'min_headings' => 3, // fewest listed headings for the table to show 'depth' => 2, // 1 → sections only (usually ##), 2 → also subsections (###) 'permalinks' => true, // "#" link after each heading ],
- Levels are counted from the post's top heading level, so a body written with
#or starting at###gets the same table. - Heading text stays escaped like the rest of the body; the table repeats the plain text (no inline HTML).
- The RSS feed's
content:encodedandPost::renderedBody()keep the plain headings, without ids or#links.
The table is a component, so a published view can place it anywhere. Render the body and the table from the same call, so the links match the ids:
@php([$html, $toc] = \Jessecruz\SimpleBlog\Support\TableOfContents::render($post)) <x-blog::toc :items="$toc" /> <div class="blog-content">{!! $html !!}</div>
RSS feed and sitemap
Both are on by default and live under the blog prefix.
Feed — /blog/feed is an RSS 2.0 feed of the latest published posts: title, link, publication date, author, category, excerpt and cover image (media:content). Every public page advertises it with <link rel="alternate" type="application/rss+xml">, so browsers and readers find it from the blog URL. It also works with RSS-to-email tools (Mailchimp, Kit, …) to send new posts to a newsletter.
'feed' => [ 'enabled' => true, 'limit' => 20, // latest posts listed 'full_content' => false, // true: also send the rendered body (<content:encoded>) ],
Sitemap — /blog/sitemap.xml lists the index, every category with published posts and every published post, each with its last modification. Drafts and scheduled posts stay out until they go live. Point search engines at it from your robots.txt, or submit it in Google Search Console:
Sitemap: https://example.com/blog/sitemap.xml
If your app already builds its own sitemap, set 'sitemap' => ['enabled' => false] and add route('blog.sitemap') to your sitemap index instead, or keep both.
feed is a reserved post slug: the admin and the API reject it, since /blog/feed would never reach the post.
View counting
Each read of a public post adds to its views_count, shown in the admin post list. By default the count leaves out:
- Bots — requests whose User-Agent is empty or names a crawler, link preview, AI agent, uptime checker or script (Googlebot, WhatsApp and Slack previews, GPTBot, curl, …). Bots that pose as regular browsers can't be told apart and still count.
- Reloads — each post counts once per visitor session. The session remembers the last 50 posts read.
- HEAD requests and admin previews.
Counting never touches the post's updated_at, so the sitemap's <lastmod>, the feed and the dateModified meta only move when the post is edited.
'views' => [ 'enabled' => true, // false: stop counting (e.g. you rely on your own analytics) 'ignore_bots' => true, 'once_per_session' => true, // false: count every page load ],
The per-session rule needs the session started by the default web public middleware. If your public_middleware has no session, every request counts.
Image uploads
The post form takes images in two places:
- Images section: the cover image and the social image (
og:image), each as a URL or as an upload. The picked file is previewed right away and stored when the post is saved. - Insert image button above the body editor: the file is stored right away, and its Markdown (
) is inserted at the cursor, in a paragraph of its own. The file name becomes the alt text; edit it into a description.
Files are named after the post (blog/my-post-k3j9x2ab.jpg). JPG, PNG, GIF and WebP are accepted, checked by content, not by name. SVG is refused, since it can carry scripts.
'images' => [ 'enabled' => env('BLOG_IMAGES_ENABLED', true), // false: URLs only, no upload button 'disk' => env('BLOG_IMAGES_DISK'), // null: your default disk (FILESYSTEM_DISK) 'directory' => 'blog', 'max_size' => 2048, // kilobytes ],
- Uploads go to your app's default disk (
FILESYSTEM_DISK), in theblogfolder. If your default is S3, R2 or similar, images land there.- Laravel's own
localdisk is private, so when it is the default thepublicdisk is used instead. Runphp artisan storage:linkonce for it. - If your default disk is private for another reason, or you want the images elsewhere, set
BLOG_IMAGES_DISKto a disk name fromconfig/filesystems.php. A name that isn't configured there turns uploads off.
- Laravel's own
- The disk must be public, because readers load the images from its URL. Where local files don't survive a deploy (containers, serverless), use S3, R2 or a similar disk.
- S3 and Cloudflare R2: give the disk a
urlwith the bucket's public domain (anr2.devURL or a custom domain). Without it, URLs point at the API endpoint, which needs credentials. Files are written without an ACL, so R2 works.- When that bucket is your default disk, Livewire also uploads its temporary files straight from the browser to the bucket, which needs CORS on the bucket. To keep them on the server instead, set
temporary_file_upload.disktolocalinconfig/livewire.php.
- When that bucket is your default disk, Livewire also uploads its temporary files straight from the browser to the bucket, which needs CORS on the bucket. To keep them on the server instead, set
- For the cover and social images, the post keeps the file's path on the disk, not its URL. The URL is built each time the post is read, so a new
APP_URL, a CDN in front of the disk or a different bucket URL applies to every image at once.$post->cover_image,$post->og_image, the views, the feed and the API all return the full URL. Saving a post back with that URL, from the form or through the API, keeps the upload.- Images in the body are plain Markdown, so they keep the full URL from the moment they were inserted.
- Files are deleted once no post uses them, whether as its cover, as its social image or in its body. That happens when an image is replaced, removed, switched to an external URL or taken out of the body and the post is saved, or when the post is deleted.
- A file whose URL also appears in another post stays until that post drops it too. Only files inside
directoryare ever deleted. - A body image inserted into a post that is never saved stays on the disk.
- A file whose URL also appears in another post stays until that post drops it too. Only files inside
- Upload limits:
max_sizeis checked by the form. PHP'supload_max_filesize(2 MB by default) and Livewire's temporary upload limit (12 MB) apply as well, so raise those to accept larger files.
With images.enabled off, images in the body are added by hand as Markdown ().
Publishing events and webhooks
When a post goes live, the package dispatches Jessecruz\SimpleBlog\Events\PostPublished once. That covers posts published right away and scheduled posts reaching their date. Use it to post on social networks, send a newsletter or purge a cache:
use Illuminate\Support\Facades\Event; use Jessecruz\SimpleBlog\Events\PostPublished; Event::listen(function (PostPublished $event) { $post = $event->post; // category and author loaded; $post->url() is the public URL });
The simple-blog:announce command finds posts that went live and fires the event. The package schedules it every minute (publishing.schedule), so your app's scheduler must be running: php artisan schedule:work locally, or the usual * * * * * php artisan schedule:run cron. A post is announced at most once. If a listener throws, the error is reported and the other posts still go out.
Posts that went live more than publishing.announce_window hours ago (default 24) are marked without firing the event. That stops a bulk import of old posts or a scheduler that was down for days from flooding your channels.
Webhooks. For no-code tools (Zapier, Make, n8n), list URLs and every announcement is POSTed to each of them through your queue (3 attempts):
BLOG_WEBHOOK_URLS=https://hooks.zapier.com/hooks/catch/123/abc/,https://n8n.example.com/webhook/blog BLOG_WEBHOOK_SECRET=a-long-random-secret
{
"event": "post.published",
"post": { "id": 12, "slug": "how-to-reduce-no-shows", "title": "…", "url": "https://example.com/blog/how-to-reduce-no-shows", "status": "published", "…": "same fields as the JSON API" }
}
Requests carry X-Simple-Blog-Event: post.published and, with a secret, X-Simple-Blog-Signature: sha256=<HMAC-SHA256 of the raw body>. Verify it on the receiving side:
$expected = 'sha256='.hash_hmac('sha256', $request->getContent(), $secret); abort_unless(hash_equals($expected, (string) $request->header('X-Simple-Blog-Signature')), 401);
Upgrading from 0.8.x: this adds the blog_posts.announced_at column. Publish and run the new migration; posts already live are marked as announced, so nothing is re-sent:
php artisan vendor:publish --tag=simple-blog-migrations php artisan migrate
Dates and timezones
Publish dates are stored in app.timezone, like every Laravel date. Many apps run in UTC (the Laravel default) while their editors and readers live elsewhere; set timezone so the blog works in local time:
'timezone' => 'America/Sao_Paulo', // null → app.timezone
- The admin "Publish at" field is typed and shown in this timezone, which is displayed under the field. "10:00" means 10:00 in São Paulo, stored as 13:00 UTC.
- Public post dates, the scheduled-post preview notice and the admin list are shown in this timezone, so a post published at 23:30 local time doesn't show the next day's date.
- Machine-readable dates (
<time datetime>, Open Graph, JSON-LD, the JSON API) carry their offset and point to the same instant either way. - Dates assigned to
Post::$published_atwith an offset (an ISO 8601 string, a Carbon in another timezone) are converted toapp.timezonebefore saving.
Date formats come from the date_format, datetime_format and datetime_short_format lang strings. In custom templates, Jessecruz\SimpleBlog\Support\Dates::format($post->published_at, $format) converts to the blog timezone and translates month names.
JSON API
An optional REST API to manage content from automations (n8n, CI jobs, AI agents). It is disabled by default — no routes are registered until you turn it on:
BLOG_API_ENABLED=true BLOG_API_TOKEN=a-long-random-secret
Every request must send Authorization: Bearer {BLOG_API_TOKEN} and Accept: application/json. While the token is empty, every request is rejected with 401.
| Method | URL | Name | Description |
|---|---|---|---|
| GET | /api/blog/categories |
blog.api.categories.index |
List categories |
| POST | /api/blog/categories |
blog.api.categories.store |
Create a category (name, slug?, description?) |
| GET | /api/blog/posts |
blog.api.posts.index |
Paginated posts, newest first, without body |
| POST | /api/blog/posts |
blog.api.posts.store |
Create a post |
| GET | /api/blog/posts/{slug} |
blog.api.posts.show |
A single post, with body |
| PATCH | /api/blog/posts/{slug} |
blog.api.posts.update |
Update only the fields sent |
Post payload:
{
"title": "How to reduce no-shows",
"excerpt": "Why clients miss appointments and what to do about it.",
"body": "## Markdown body...",
"category": "scheduling",
"published_at": "2026-10-01T09:00:00-03:00",
"keywords": ["no-shows", "reminders"]
}
title,excerpt,bodyandcategory(a category slug) are required on create and optional on update.slugdefaults toStr::slug(title)on create;reading_timeis estimated from the body (200 words/minute) when omitted.feedis a reserved slug.published_at: omitted ornull→ draft; in the past → published; in the future → scheduled. The response reports it asstatus. An offset is honored (2026-10-01T09:00:00-03:00and2026-10-01T12:00:00Zare the same instant); a value without one is read inapp.timezone. Responses useapp.timezone.- Also accepted:
cover_image,og_image,meta_title,meta_descriptionandauthor_id(validated againstauthor_modelwhen one is configured). cover_imageandog_imageare URLs both ways. Uploaded images come back as their full URL, and sending that URL back keeps the upload (see Image uploads).
To use your own auth instead of the shared token, replace the middleware stack:
'api' => [ // ... 'middleware' => ['api', 'auth:sanctum', 'can:manage-blog'], ],
Models
The package exposes:
Jessecruz\SimpleBlog\Models\PostJessecruz\SimpleBlog\Models\PostCategory
Use them directly when needed (e.g. to generate a sitemap, export content):
use Jessecruz\SimpleBlog\Models\Post; $posts = Post::published()->with('category')->latest('published_at')->get();
Testing
composer test
Changelog
See CHANGELOG.
Contributing
Please read CONTRIBUTING.md before opening an issue or a pull request. Security problems are reported privately: see SECURITY.md.
License
MIT — see License File.