Apps (Coming Soon) 29 / 32
Blog
A drop-in blog for Laravel — Post and Category models with caching, publish-only migrations, and an optional Filament admin — designed headless so it slots into any theme.
devdojo/blog gives a Laravel app a real blog without imposing a front-end. It ships the Post and Category Eloquent models, two publish-only migrations, and an optional Filament admin (BlogPlugin) for authoring. The public pages — index, category, single post — are left entirely to your application or theme, so the blog slots cleanly into whatever design you already have.
It is one of the feature packages bundled by foundation, where its admin self-gates on a feature flag, and it works standalone in any Laravel 10/11/12/13 app.
Requirements
- PHP 8.2+ and Laravel 10 – 13 (only
illuminate/supportandilluminate/databaseare required). - Filament v4 — optional, and only if you want the bundled admin resources; the models and migrations have no hard dependency on it.
Overview
The package is deliberately small and headless:
- A Post belongs to a Category and to an author — your application's
Usermodel, related viaauthor_id. - A Category can be nested (
parent_idself-referencescategories) and exposes a cached collection helper for things like a category nav. - The models carry the behavior (links, image URLs, author resolution); you render the actual
/blogpages with Folio, controllers, or Livewire. - Migrations are publish-only, so the
postsandcategoriestables land in your app'sdatabase/migrationsand are yours to edit.
Installation
composer require devdojo/blog
Publish the migrations and config, then migrate:
php artisan vendor:publish --tag=blog:migrations
php artisan vendor:publish --tag=blog:config
php artisan migrate
If your User model can't be resolved from config('auth.providers.users.model'), point the blog at it explicitly so posts can resolve their author:
BLOG_USER_MODEL="App\\Models\\User"
Note — the migrations are publish-only, not auto-loaded from the package. If you skip vendor:publish --tag=blog:migrations the posts and categories tables never get created.
Configuration
Publishing blog:config writes config/devdojo/blog/settings.php under the config key devdojo.blog.settings:
return [
// The host User model used as a post's author.
// Null → falls back to auth.providers.users.model.
'user_model' => env('BLOG_USER_MODEL'),
];
| Key | Default | Purpose |
|---|---|---|
user_model |
env('BLOG_USER_MODEL') → null |
The host User model used as a post's author. When null, resolved from auth.providers.users.model. |
Publish tags
| Tag | Publishes to |
|---|---|
blog:config |
config/devdojo/blog/settings.php |
blog:migrations |
database/migrations |
The data model
categories
| Column | Type | Notes |
|---|---|---|
id |
increments | |
parent_id |
unsigned int, nullable | self-referencing, onDelete('set null') |
order |
int, default 1 |
manual ordering |
name |
string | |
slug |
string, unique | |
created_at / updated_at |
timestamps |
posts
| Column | Type | Notes |
|---|---|---|
id |
increments | |
author_id |
unsigned big int | FK → users.id |
category_id |
unsigned int, nullable | FK → categories.id, onDelete('set null') |
title |
string(191) | |
seo_title |
string(191), nullable | |
excerpt |
text, nullable | |
body |
text | post content (HTML) |
image |
string(191), nullable | path on the configured disk |
slug |
string(191), unique | |
meta_description |
text, nullable | |
meta_keywords |
text, nullable | |
status |
enum PUBLISHED / DRAFT / PENDING |
default DRAFT |
featured |
boolean, default false |
|
created_at / updated_at |
timestamps |
Both Post and Category use $guarded = [], so they are fully mass-assignable.
Usage
Creating posts and categories
use Devdojo\Blog\Models\Category;
use Devdojo\Blog\Models\Post;
$marketing = Category::create([
'name' => 'Marketing',
'slug' => 'marketing',
'order' => 1,
]);
$post = Post::create([
'author_id' => $user->id,
'category_id' => $marketing->id,
'title' => 'Best ways to market your application',
'slug' => 'best-ways-to-market-your-application',
'excerpt' => 'A short summary…',
'body' => '<p>The full post body (HTML)…</p>',
'image' => 'posts/cover.jpg',
'status' => 'PUBLISHED',
'featured' => true,
]);
Querying the blog
The package imposes no routes or views — render the front-end however you like. Everything you need is on the models:
use Devdojo\Blog\Models\Post;
use Devdojo\Blog\Models\Category;
// Index: latest published posts, paginated
$posts = Post::where('status', 'PUBLISHED')
->orderByDesc('created_at')
->paginate(6);
// A single post by slug
$post = Post::where('slug', $slug)->firstOrFail();
// Posts within a category
$category = Category::where('slug', $categorySlug)->firstOrFail();
$posts = $category->posts()->where('status', 'PUBLISHED')->paginate(6);
Handy helpers on a Post:
$post->link(); // "/blog/{category-slug}/{post-slug}" (absolute url())
$post->image(); // full URL to the cover image on the default Filament disk
$post->user; // the author (belongsTo your User model, via author_id)
$post->category; // the Category (belongsTo)
Note — link() builds its URL from $post->category->slug, so it throws on a post without a category (the column is nullable in the schema, but the Filament form requires one). Give every post a category, or guard your own calls.
A minimal Folio index page:
{{-- resources/views/pages/blog/index.blade.php --}}
<?php
use function Laravel\Folio\name;
use Devdojo\Blog\Models\Post;
name('blog');
$posts = Post::where('status', 'PUBLISHED')->latest()->paginate(6);
?>
<x-layout>
@foreach ($posts as $post)
<article>
<a href="{{ $post->link() }}">{{ $post->title }}</a>
<p>{{ $post->excerpt }}</p>
</article>
@endforeach
{{ $posts->links() }}
</x-layout>
Categories and caching
Category::getAllCached() returns all categories from a one-hour cache (keyed wave_all_categories), degrading gracefully to a direct query when no cache store is bound. Use it for a category nav that renders on every page, and bust it whenever categories change:
Category::getAllCached(); // cached collection of all categories
Category::clearCache(); // call after create/update/delete
Nesting is schema-level only — parent_id exists (and the admin exposes a parent select), but the model defines no parent() / children() relations, so query by parent_id directly or add the relations in a subclass.
Authors
A post's author is your application's User, related via author_id. No relation is added to your User model by default, so query a user's posts directly:
$post->user; // the author
Post::where('author_id', $user->id)->get();
The author model is resolved from config('devdojo.blog.settings.user_model'), falling back to config('auth.providers.users.model').
How it's wired
BlogServiceProvider does two things: in register() it merges the settings config under devdojo.blog.settings; in boot() (console only) it registers the blog:config and blog:migrations publish groups. That is the entire runtime footprint — no routes, no views, no middleware. The provider is auto-discovered via the package's extra.laravel.providers.
Post::image() resolves the cover URL on config('filament.default_filesystem_disk'), so set that disk (e.g. public) and run php artisan storage:link for images to resolve correctly. Both models declare newFactory() pointing at the standard Database\Factories\PostFactory / CategoryFactory namespace, so Post::factory() and Category::factory() work once you define those factories in your app.
Filament admin
If you use Filament, register the plugin in your panel to get Posts and Categories resources:
use Devdojo\Blog\Filament\BlogPlugin;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->plugin(BlogPlugin::make());
}
- PostResource — title (auto-slug), body (RichEditor with attachments), excerpt, cover image upload, SEO title, author and category selects, meta fields, status, and a featured toggle.
- CategoryResource — name, slug, parent category, and order.
Filament is a suggested (optional) dependency and the resources are built for Filament v4 — the models and migrations work without it. BlogPlugin::register() returns early when config('foundation.features.blog') is false, so the admin resources are only registered while the feature is enabled.
Using with Foundation
When foundation is installed, the blog's Filament admin self-gates on its feature flag:
// config/foundation.php
'features' => [
'blog' => true, // toggle at /foundation/setup to hide the admin
],
Disabling blog unregisters the Post and Category Filament resources, but the models, migrations, and any front-end pages you built remain available — the front-end pages are app-owned, so gate them in your own routing if you need to. Because migrations always run, toggling is lossless. Standalone, with no Foundation present, the flag is absent and the blog defaults to on.
Extension points
- Swap the author model — set
BLOG_USER_MODEL/devdojo.blog.settings.user_modelto relate posts to any model. - Edit the schema — the migrations are published into your app, so add columns (reading time, view counts) directly.
- Own the front-end — build index/category/post pages with Folio, controllers, or Livewire against the models; there is nothing to override.
- Extend the models — subclass or add traits/scopes; both models are plain Eloquent with
$guarded = [].
The blog pairs naturally with changelog for release notes and the components library for building the reading experience.