bjuppa/laravel-blog
A lightweight blog package for Laravel that adds posts, categories, and tags to your application. Use Eloquent models and Blade views to publish, organize, and display articles quickly without building blog basics from scratch.
bjuppa/laravel-blog is a batteries-included Eloquent content package: it ships the models, migrations, and config for blog entries plus categories/tags (and comments, if you use them). You don't scaffold anything — you install it and start writing rows.
composer require bjuppa/laravel-blog # v1.11.2 adds Laravel 13 support
php artisan vendor:publish --provider="Bjuppa\LaravelBlog\BlogServiceProvider"
php artisan migrate
Where to look first, in order:
config/blog.php — the published config is the real API surface (layout/view, route prefix, middleware, model bindings). Read it before writing any code.Bjuppa\LaravelBlog\Models\BlogEntry — the class you'll actually touch daily. Note its $fillable, $casts, and scopes.database/migrations — the published migrations are yours now; treat them as app schema.First use case — get one entry rendering end to end before building any admin UI:
use Bjuppa\LaravelBlog\Models\BlogEntry;
BlogEntry::create([
'title' => 'Hello world',
'slug' => 'hello-world',
'content' => 'First post.',
'published_at' => now(),
]);
Then hit the route the package registered (per your route_prefix) or your own controller. If that page renders, the rest is ordinary Laravel work.
Separate "public" reads from "editor" reads. Public listings should always go through the published scope so future-dated posts stay hidden; admin screens deliberately don't.
// public
BlogEntry::published()->with(['category', 'tags'])->latest('published_at')->paginate(10);
// admin
BlogEntry::withTrashed()->latest()->paginate(50);
Wrap the package in your own controller + API Resource. Don't let views or JSON responses bind directly to vendor models — you'll want to change the shape later without fighting the package. A thin PostController that queries BlogEntry and returns a PostResource is the cheapest insulation you can buy.
Route resolution by slug, not id. Blog URLs are /blog/{slug}. Confirm the model's route key is the slug and that slugs are unique at the database level (unique index, not just a validation rule). If you override getRouteKeyName() in a subclass, you own that contract.
Authoring workflow: your admin, their storage. There is no admin panel. The pragmatic pattern is a small Filament/Nova/custom CRUD over BlogEntry, or seeders + a Markdown importer for a content team that lives in git. Either way, set published_at explicitly — don't rely on created_at.
Scheduling lives in published_at. Draft = null, scheduled = future, live = past. That single column drives visibility, ordering, and sitemap output, so build one query scope/helper around it and reuse it everywhere instead of scattering where('published_at', '<=', now()).
Eager load taxonomies. category and tags are relations; an index page without with() is a textbook N+1. Same for comments if you render counts.
Testing. There are no factories shipped. Write your own BlogEntryFactory in database/factories and use RefreshDatabase; state helpers like ->published() / ->draft() pay for themselves immediately.
Upgrading. v1.11.2 is a compatibility release (Laravel 13) plus added test coverage for BlogEntry casts — no migration or API changes. Bump with composer update bjuppa/laravel-blog -W and run your suite; the real work is the Laravel 13 upgrade itself (PHP 8.3+).
$fillable will bite you. Package models often guard columns you want to mass-assign (published_at, slug). Either extend the model and widen $fillable, or assign attributes individually. Silent dropping of published_at looks exactly like "scheduling doesn't work."$casts in a subclass. The v1.11.2 release explicitly added tests around BlogEntry casts — published_at behaving as a Carbon instance is load-bearing for the published scope. If you subclass and redefine $casts, merge rather than replace.published_at before you check anything else. Inversely, don't assume a fresh create() shows up publicly.now() in the app timezone, and add one test that publishes an entry exactly at the boundary. Off-by-hours scheduling bugs are the most common support ticket for this class of package.published_at too, or scheduled posts won't appear until something else invalidates it./blog/tagged/foo or your own sub-routes. Register blog routes last, and constrain slugs ([A-Za-z0-9-]+).config/blog.php run php artisan config:clear; after publishing/overriding views run view:clear. Most "my changes do nothing" reports are one of these two.->toSql() / DB::listen) for an empty listing. Nine times out of ten the query has a published_at clause you forgot, or the row's published_at is null.saving to auto-generate slugs), and add global scopes for multi-tenant filtering. Avoid editing vendor files or patching the service provider.^1.11) rather than left floating, so the next major doesn't surprise a deploy.How can I help you explore Laravel packages today?