ojessecruz/simple-blog

A Laravel lightweight blog to quickly publish and share ideas.
279 1
Install
composer require ojessecruz/simple-blog
Latest Version:v0.8.5
PHP:^8.3
License:MIT
Last Updated:Oct 9, 2026
Links: GitHub  ·  Packagist
Maintainer: ojessecruz

Simple Blog

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.php and the migrations;
  • add the Tailwind source @import to resources/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"

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
GET /blog/category/{slug} blog.category Posts in a category
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 routes
  • public_middleware / admin_middleware — middleware stacks
  • api — opt-in JSON API: enable flag, prefix, middleware, token and page size (see JSON API)
  • author_model — User model
  • layouts.public / layouts.admin — Blade layouts wrapping the content
  • cta_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)
  • public_back_url / admin_back_url — where the "back" links point (null hides them)
  • seo — site/blog names, default description and images used in titles, meta tags and the Article JSON-LD (see SEO)
  • markdown — options passed to Str::markdown()

Customizing the layout

The package ships two neutral default layouts, both pulled from config/blog.php:

  • public (blog::layouts.public) — used for /blog and /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="{{ request()->url() }}">

    @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="https://github.com/ojessecruz/simple-blog/blob/HEAD/" 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 + categories without one
    'image' => 'https://usehoot.app/img/og-card.png',                  // default og:image
    'publisher_logo' => 'https://usehoot.app/img/logo.png',            // Article publisher logo
],

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), …) 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, 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.

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, body and category (a category slug) are required on create and optional on update.
  • slug defaults to Str::slug(title) on create; reading_time is estimated from the body (200 words/minute) when omitted.
  • published_at: omitted or null → draft; in the past → published; in the future → scheduled. The response reports it as status.
  • Also accepted: cover_image, og_image, meta_title, meta_description and author_id (validated against author_model when one is configured).

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\Post
  • Jessecruz\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.

Related Packages

jrean/blogit

Blog with Git(hub). For Laravel 5.*. Fetch and Parse Documents hosted on Github.

71 9
talvbansal/easel

A minimal blogging package for laravel

477 11
websanova/larablog

Drop in blogging solution for Laravel 5.x

492 19
ryanwinchester/kwiki-laravel

Quick wiki or blog with markdown files for laravel

5 3
spatie/commonmark-wire-navigate

Add a wire:navigate attribute to links rendered in Markdown

16,722 10

Version History

Version Released PHP Laravel License
v0.8.5 ^8.3 ^11.0||^12.0||^13.0 MIT
v0.8.4 ^8.3 ^11.0||^12.0||^13.0 MIT
v0.8.3 ^8.3 ^11.0||^12.0||^13.0 MIT
v0.8.2 ^8.3 ^11.0||^12.0||^13.0 MIT
v0.8.1 ^8.3 ^11.0||^12.0||^13.0 MIT