warmar / laravel-blog
A blog/news plugin for Laravel + Livewire
Requires
- php: ^8.1
- illuminate/support: ^11.0|^12.0|^13.0
- livewire/livewire: ^3.0|^4.0
README
Docs: https://warmardev.com/docs/laravel-blog.html
A fully-featured, drop-in blog package for Laravel 11+ built on Livewire 4 and Tailwind CSS v4. Write articles in a live rich-text editor that saves to structured JSON — making every piece of content 100% compatible with Laravel's localization system and AI translation pipelines out of the box.
Why JSON instead of HTML?
Most blog editors save raw HTML. That makes translation impossible without parsing and re-rendering unpredictable markup.
Laravel Blog saves content as a structured JSON document where every text node is a discrete string. When rendering, each string passes through Laravel's __() helper — meaning your entire article body is automatically translatable via Laravel's standard language files or any AI translation pipeline that hooks into handleMissingKeys. No custom directives, no proprietary syntax.
{
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Hello world", "marks": [{ "type": "bold" }] }
]
}
]
}
Requirements
- PHP 8.2+
- Laravel 11 or 12
- Livewire 4.x
- Tailwind CSS v4
php artisan storage:linkalready run
Installation
composer require warmar/laravel-blog
Then run the install command:
php artisan blog:install
The installer will ask you:
- Whether to enable comments
- Whether to enable categories
It then publishes config, migrations, views, models and services to your application.
Run migrations
php artisan migrate
Add routes
In routes/web.php:
use App\Livewire\Blog\BlogHome; use App\Livewire\Blog\BlogShow; use App\Livewire\Blog\BlogAdmin; Route::get('/blog', BlogHome::class)->name('blog.home'); Route::get('/blog/{slug}', BlogShow::class)->name('blog.show'); Route::get('/blog-admin', BlogAdmin::class)->name('blog.admin');
Protecting the admin route
The package does not include built-in auth — protect the admin route using your project's existing middleware:
Route::get('/blog-admin', BlogAdmin::class) ->name('blog.admin') ->middleware(['auth', 'verified']); // or your own admin middleware
Configuration
After publishing, your config/blog.php will look like this:
<?php return [ 'features' => [ 'comments' => true, 'categories' => true, ], 'pagination' => [ 'per_page' => 10, ], 'categories' => [ // 'Laravel', // 'Tutorials', // 'News', // 'Releases', ], ];
Categories
Categories are defined in config — no database table needed. Just uncomment or add strings to the categories array:
'categories' => ['Laravel', 'Tutorials', 'News'],
These populate the category dropdown in the admin editor and the filter sidebar on the blog home page. Each article stores its category as a plain string in the data_articles.category column.
File Structure
After publishing, files land in the following locations in your Laravel application:
app/
├── Livewire/
│ └── Blog/
│ ├── BlogAdmin.php # Admin panel component
│ ├── BlogHome.php # Blog listing component
│ └── BlogShow.php # Single article component
├── Models/
│ └── Blog/
│ ├── Article.php # Article model
│ └── ArticleComment.php # Comment model
└── Services/
└── Blog/
└── RichTextRenderer.php # JSON → HTML renderer
resources/views/
├── layouts/
│ └── blog/
│ └── blog.blade.php # Blog layout
└── livewire/
└── blog/
├── blog-admin.blade.php
├── blog-home.blade.php
└── blog-show.blade.php
database/migrations/
└── xxxx_create_blog_table.php
Database
A single table data_articles is created:
| Column | Type | Notes |
|---|---|---|
| id | bigint | Primary key |
| category | varchar(255) | Nullable, indexed |
| metatitle | text | SEO title |
| metadesc | text | SEO description |
| metakeywords | text | SEO keywords |
| title | varchar(255) | Article title |
| slug | varchar(255) | Unique URL slug |
| article | longtext | JSON content |
| published_at | timestamp | Null = draft |
| created_at | timestamp | |
| updated_at | timestamp |
Comments are stored in article_comments.
The Admin Editor
Navigate to /blog-admin (protect with your own middleware).
Article list
- All articles with title, category, slug and publish status
- Edit or delete any article
- One-click to view the live article
Editor — 3-panel layout
Left panel (tools) — sticky toolbar with:
- Font size dropdown: S / M / L / XL
- Bold, Italic, Underline, Strikethrough
- Highlight with custom color picker (full color wheel + hex input)
- Bullet list and ordered list
- Insert image (opens Image Manager)
- New block (+¶)
- Insert link
Center panel (canvas) — live editable surface. Div-based — no semantic heading elements, so browser bold-context bugs don't interfere with formatting.
Right panel (inserted images) — every image in the article listed here. Per image:
- Edit alt text
- Set alignment: left / center / right
- Set size: 10–100% slider
Keyboard behaviour
| Key | Action |
|---|---|
| Enter (in paragraph) | Line break within same block |
| Enter (in list) | New list item |
| Shift+Enter (in list) | Exit list, new paragraph block below |
Image Manager
Click the 🖼 button to open the image manager modal.
- Folders: create, rename, delete. Navigate via sidebar tree or folder grid.
- Upload: select multiple images at once. Uploaded to
storage/blog-media/. - Select existing: click any image to add it to the pending tray.
- Pending tray: add alt text per image, then click Insert to place at cursor position.
- Image operations: rename, copy or delete via hover overlay.
Images are served via /storage/blog-media/... relative URLs — no APP_URL dependency.
Content Blocks (JSON Schema)
| Block type | Description |
|---|---|
paragraph |
Default text block (div-based) |
bulletList |
Unordered list with items |
orderedList |
Ordered list with items |
listItem |
Individual list row |
image |
Image with src, alt, align, width attrs |
spacer |
Empty vertical space |
hardBreak |
Line break within a block |
Text marks:
| Mark | Description |
|---|---|
bold |
<strong> |
italic |
<em> |
underline |
<u> |
strike |
<s> |
highlight |
Inline background color (custom, stored as rgb/hex) |
fontSize |
sm / md / lg / xl (em-based) |
link |
Anchor with href |
RichTextRenderer
use App\Services\Blog\RichTextRenderer; {!! RichTextRenderer::render($article->article, translate: true) !!}
When translate: true (default), every text node passes through __(). Pass translate: false to show raw content without translation.
Public Blog Pages
Blog Home (/blog)
- Search bar (live, debounced)
- Category filter sidebar (from config)
- Article grid with category pill, date, excerpt
- Laravel pagination
Blog Show (/blog/{slug})
- Sticky top bar with back link
- Publish date and category badge
- Rendered article content via
RichTextRenderer - Comments section (if enabled)
Blog Admin (/blog-admin)
Protect with your own middleware. No built-in auth.
Comments
Disable in config:
'features' => ['comments' => false],
Features:
- Threaded replies (one level deep)
- Edit and delete your own comments
- 3000 character limit
- Login required — redirects to
route('login')
Localization
All blade strings use __(). Article content is also translated through __() at render time.
To translate article content automatically, hook into Lang::handleMissingKeysUsing() or use a package like Laravel AI Translate.
Customisation
All views, models and services are published and yours to modify. To re-publish:
php artisan vendor:publish --tag=laravel-blog-views --force
⚠️ This overwrites your changes. Back up first.
Credits
Laravel Blog is built and maintained by Warmar.
Built with Laravel · Livewire · Tailwind CSS · Alpine.js
License
MIT