ojessecruz/simple-blog
A ready-to-use Laravel blog package: a clean, reading-focused public listing plus Livewire admin CRUD, Markdown with HTML escaping, and draft previews in a new tab. Auth is left to your own middleware. Install with one command; bring Tailwind.
Install in three commands. The package ships an idempotent installer that does the boring parts — config, migrations, and Tailwind wiring:
composer require ojessecruz/simple-blog
php artisan simple-blog:install # publishes config + migrations, injects the Tailwind @import, offers to migrate
npm run build
Re-running simple-blog:install is safe; it won't duplicate the CSS import. If resources/css/app.css doesn't exist, it prints the line to paste manually.
Look here first. After install, open config/blog.php. It is the entire surface area of the package — every behavior you'll care about day-to-day is a key in that file, and each key is commented. Then open php artisan route:list --path=blog to see the eight routes you just inherited.
First real use case: lock the door. The package deliberately ships no authorization. Before you visit /admin/blog in a browser, set admin_middleware:
// config/blog.php
'admin_middleware' => ['web', 'auth', 'can:manage-blog'],
'public_middleware' => ['web'],
'author_model' => App\Models\User::class,
// AuthServiceProvider (or bootstrap/app.php with a closure)
Gate::define('manage-blog', fn ($user) => $user->is_admin === true);
Second step: make User an Author. The admin panel renders author bylines and avatars, and it talks to your model through a contract rather than assuming column names:
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)) ?: [];
return implode('', array_map(
fn ($w) => mb_strtoupper(mb_substr($w, 0, 1)),
array_slice($words, 0, 2),
));
}
public function getBlogAuthorAvatarUrl(): ?string { return $this->avatar_url; }
}
Then write one post in the admin, publish it, and hit /blog and /blog/{slug}. That is the whole happy path — the rest of the package is customization.
Verify before you build on it: confirm the published migrations created posts and post_categories tables, confirm /blog renders styled (not just unstyled markup — see Gotchas), and confirm a draft is invisible on /blog but visible at /admin/blog/{slug}/preview.
The default public layout is deliberately plain. The fastest integration is a three-line bridge around your existing slot-based layout:
{{-- resources/views/layouts/blog-public.blade.php --}}
<x-site-layout>
@yield('content')
</x-site-layout>
'layouts' => [
'public' => 'layouts.blog-public',
'admin' => 'layouts.app', // admin Livewire components use ->layout(), so slot-based works directly
],
For the public side, if you want full control (SEO meta, header, footer), write a @yield-based layout from scratch instead — the package only requires @yield('content') and @stack('head').
If your layout already calls @vite('resources/css/app.css') and the package also emits asset tags, you get the stylesheet twice. Set:
'assets' => [],
This is the single most common integration mistake after Tailwind wiring.
Three patterns work; pick one per app:
// Gate-based — best when "who can blog" is a permission, not a role
'admin_middleware' => ['web', 'auth', 'can:manage-blog'],
// Separate guard — best when admins are a distinct model/table
'admin_middleware' => ['web', 'auth:admin'],
// Stack — best when your own middleware already encodes the policy
'admin_middleware' => ['web', 'auth', 'verified', 'super.admin'],
The can: form requires the Gate to exist before the route is resolved; define it in a service provider, not inline in a controller.
Tailwind v4 is a one-line import in resources/css/app.css (the installer does it for you):
@import 'tailwindcss';
@import '../../vendor/ojessecruz/simple-blog/resources/css/simple-blog.css';
The path resolves relative to the package file, so your directory structure can change without breaking it. Tailwind v3 needs the views in content:
content: [/* ... */, './vendor/ojessecruz/simple-blog/resources/views/**/*.blade.php'],
Publishing everything with --tag="simple-blog-views" freezes public, admin, layouts, and icons at today's version. Publish only the slice you intend to change:
php artisan vendor:publish --tag="simple-blog-views-public" # index, show, public layout
php artisan vendor:publish --tag="simple-blog-views-admin" # Livewire views + admin layout
php artisan vendor:publish --tag="simple-blog-views-layouts" # shells only
Published files land in resources/views/vendor/blog/ and shadow the package copies. A find-and-replace of emerald- → your accent is the intended retheme path.
The editorial workflow is: save a post with a draft status, click preview (opens /admin/blog/{slug}/preview in a new tab), iterate, flip to published. The preview route lives behind admin_middleware, so drafts never leak publicly — but that also means the preview URL is useless to anyone who isn't an authorized admin. Don't hand it to a ghostwriter expecting it to work without a login.
The package exposes Jessecruz\SimpleBlog\Models\Post and PostCategory. This is your escape hatch for sitemaps, RSS, exports, and dashboard widgets:
use Jessecruz\SimpleBlog\Models\Post;
$posts = Post::published()->with('category')->latest('published_at')->get();
A sitemap entry is roughly a route('blog.show', $post->slug) per row; RSS is the same loop with a Content-Type header.
cta_view renders after post content on the show page and below the feed on the index. Point it at a component:
'cta_view' => 'components.blog-cta',
The view receives $post on the show page. On the index it's rendered feed-wide — treat $post as possibly absent there and guard accordingly.
If your app already owns /blog (a marketing page, a redirect, an existing CMS), don't fight route order — remount:
'route_prefix' => 'articles',
'admin_route_prefix' => 'admin/articles',
The route names (blog.index, blog.show, blog.admin.index) stay stable, so route('blog.show', $post) keeps working after a prefix change. Always reference names, never hardcoded paths.
There are no shipped factories, so seed with the model:
Post::create([
'title' => 'Hello',
'slug' => 'hello',
'body' => "# Heading\n\nSome **markdown**.",
'published_at' => now(),
// author relation / status per your schema
]);
Then feature-test the public routes with RefreshDatabase and assert that an unpublished post returns 404 on blog.show while an authenticated admin gets 200 on blog.admin.preview.
Unstyled blog = Tailwind never scanned the package. This is the #1 failure. The package ships Tailwind classes but compiles nothing. If /blog renders as bare HTML with no spacing, your @import (v4) or content entry (v3) is missing, or you forgot npm run build. Running simple-blog:install again is the fastest fix.
@stack('head') is mandatory for SEO. The package pushes <title>, OG tags, and JSON-LD via @push('head'). A custom layout without @stack('head') in <head> silently drops all of it — no error, just missing meta. Grep your layout for it before debugging anything SEO-related.
admin_middleware is unprotected by default. The package makes zero assumptions about auth, which means a fresh install has a world-writable admin panel. Protect it in the same deploy as composer require, not in a follow-up ticket.
The Author contract is enforced at runtime, not install time. Pointing author_model at a User that doesn't implement Jessecruz\SimpleBlog\Contracts\Author fails when the admin or public views try to render a byline. Implement all three methods (Name, Initials, AvatarUrl); return null from the avatar method rather than omitting it.
Markdown escapes HTML by design. Raw <iframe>, <video>, and <script> in post bodies will be escaped, not rendered. That's the intended safety default. If you need embeds, adjust the markdown config options passed to Str::markdown() — and accept that you're trading escaping for trust in your authors. Keep allow_unsafe_links off unless you audit authors.
Version-pin around the Livewire 4 / Laravel 11 fixes. The changelog is worth reading once: 0.5.0 fixed admin components being registered as blog::admin.* (which Livewire 4 can't resolve — it reserves ::) and replaced the #[Scope] attribute on published() with the conventional scopePublished() so Laravel 11 works. On Laravel 11, or on Livewire 4, require ^0.5 minimum. If you've written your own Blade that references admin components, use the dotted names (blog.admin.post-index) post-0.5.
Published views are frozen. Once you publish a slice, package bugfixes stop reaching it. When upgrading, git diff your resources/views/vendor/blog/ against the newly installed package views to see whether you're missing a fix — or publish only the slice you actually customized and let the rest track upstream.
Custom admin layouts must include Livewire directives. The default admin layout handles @livewireStyles / @livewireScripts. If you point layouts.admin at a hand-rolled @yield-based shell, include them explicitly or the admin panel renders inert.
Config changes need a clear. Every key here is read through config(). If you use php artisan config:cache in deploy, re-cache after editing config/blog.php — otherwise you'll debug a middleware change that "did nothing."
CTA $post on the index. The same cta_view renders on both the show page and below the feed. isset($post) / $post ?? null in the CTA view prevents an undefined-variable blow-up on the listing page.
Small ecosystem, so own your extension points. At 1 star and a 0.x series, expect the package to be a solid, narrow CRUD core rather than a platform. Don't fork the vendor directory or edit published views to add features — add them around the package: your own routes that query Post directly, your own Blade layout, your own CTA view. That way composer update stays a non-event.
How can I help you explore Laravel packages today?