Skip to content

Start building your next app or website — with AI, right in your browser.

Visit devdojo.com
Docs
Blog

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/support and illuminate/database are 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 User model, related via author_id.
  • A Category can be nested (parent_id self-references categories) 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 /blog pages with Folio, controllers, or Livewire.
  • Migrations are publish-only, so the posts and categories tables land in your app's database/migrations and 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)

Notelink() 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_model to 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.

© 2026 DevDojo Edit this page