jcfrane / laravel-md-blog
Write blog posts as markdown files with YAML front matter. No database, no CMS — just files and a clean query API.
Requires
- php: ^8.2
- illuminate/cache: ^12.0
- illuminate/mail: ^12.0
- illuminate/support: ^12.0
- league/commonmark: ^2.5
- spatie/yaml-front-matter: ^2.0
Requires (Dev)
- orchestra/testbench: ^10.0
- phpunit/phpunit: ^11.0
This package is auto-updated.
Last update: 2026-03-11 18:51:56 UTC
README
Write blog posts as markdown files with YAML front matter. No database, no CMS — just files and a clean query API.
Built for Laravel 12+ / PHP 8.2+.
Installation
composer require jcfrane/laravel-md-blog
Publish the config (optional):
php artisan vendor:publish --tag=md-blog-config
Usage
Writing Posts
Create .md files in resources/markdown/blog/ (configurable) with YAML front matter:
---
title: My First Post
slug: my-first-post
date: 2026-02-28
tags: [laravel, php]
category: tutorials
excerpt: A quick intro to my blog.
published: true
---
# My First Post
Your markdown content here.
Front matter fields:
| Field | Required | Default |
|---|---|---|
title |
No | Filename |
slug |
No | Filename |
date |
No | File modification time |
tags |
No | [] |
category |
No | "" |
excerpt |
No | "" |
published |
No | true |
Any additional front matter fields are available via $post->meta.
Querying Posts
use JCFrane\MdBlog\Facades\MdBlog;
// All published posts
$posts = MdBlog::all();
// Single post by slug
$post = MdBlog::find('my-first-post');
// Filter by tag
$posts = MdBlog::whereTag('laravel');
// Filter by category
$posts = MdBlog::whereCategory('tutorials');
// Published posts, newest first
$posts = MdBlog::latest();
// Clear the cache
MdBlog::clearCache();
All query methods return Laravel Collections (except find() which returns a Post or null). Drafts (published: false) are automatically excluded.
The Post Object
Each post is an immutable Post DTO with these properties:
$post->title; // string
$post->slug; // string
$post->date; // Carbon instance
$post->body; // raw markdown
$post->html; // rendered HTML
$post->tags; // array
$post->category; // string
$post->excerpt; // string
$post->published; // bool
$post->meta; // array (extra front matter fields)
$post->filePath; // string
$post->lastModified; // int (unix timestamp)
Post implements Arrayable, Jsonable, and JsonSerializable for clean Inertia/API integration:
// In an Inertia controller
return Inertia::render('Blog/Show', [
'post' => MdBlog::find($slug),
]);
Images
The package automatically handles images stored within your blog directory.
Relative Images
If you have an image at resources/markdown/blog/images/photo.png, you can reference it in your markdown as:

The package will automatically rewrite this URL to a dedicated route that serves the image securely.
Image Configuration
By default, images are served with a long cache duration (24 hours). You can customize this in the config:
'images' => [
'enabled' => true,
'cache_ttl' => 86400, // in seconds
],
When images.enabled is true (default), the package registers a route at /{route_prefix}/images/{path} to serve your assets.
Send blog posts as emails to your subscribers. The feature is opt-in and disabled by default.
Setup
- Set the environment variables:
MD_BLOG_MAIL_ENABLED=true
MD_BLOG_MAIL_RECIPIENT_MODEL=App\Models\User
- Implement the
EmailRecipientinterface on your model:
use JCFrane\MdBlog\Contracts\EmailRecipient;
use JCFrane\MdBlog\Traits\ReceivesPostMail;
use JCFrane\MdBlog\Post;
class User extends Authenticatable implements EmailRecipient
{
use ReceivesPostMail;
public function shouldReceivePostEmail(Post $post): bool
{
return $this->subscribed_to_newsletter;
}
}
The ReceivesPostMail trait provides defaults for emailRecipients() (returns static::cursor()), getEmailAddress() ($this->email), and getEmailName() ($this->name). Override any of these if your model differs.
Sending
Three ways to send:
# Artisan command — single post or entire directory
php artisan md-blog:send-mail resources/markdown/blog/my-post.md
php artisan md-blog:send-mail resources/markdown/blog/
// Facade
MdBlog::sendPost('resources/markdown/blog/my-post.md');
# HTTP route (POST /{route_prefix}/send-mail, protected by auth middleware)
curl -X POST /md-blog/send-mail -d '{"path": "resources/markdown/blog/my-post.md"}'
Queue Support
Enable queued sending to avoid blocking:
MD_BLOG_MAIL_QUEUE=true
MD_BLOG_MAIL_QUEUE_CONNECTION=redis
MD_BLOG_MAIL_QUEUE_NAME=emails
Customizing the Email Template
Publish the views to customize the email layout:
php artisan vendor:publish --tag=md-blog-views
This copies the template to resources/views/vendor/md-blog/mail/post.blade.php.
Configuration
// config/md-blog.php
return [
'path' => env('MD_BLOG_PATH', 'resources/markdown/blog'),
'cache' => [
'enabled' => env('MD_BLOG_CACHE_ENABLED', true),
'store' => env('MD_BLOG_CACHE_STORE', null), // null = app default
'ttl' => env('MD_BLOG_CACHE_TTL', 3600),
],
'images' => [
'enabled' => env('MD_BLOG_IMAGES_ENABLED', true),
'cache_ttl' => env('MD_BLOG_IMAGES_CACHE_TTL', 86400),
],
'commonmark' => [
'html_input' => 'strip',
'allow_unsafe_links' => false,
],
'mail' => [
'enabled' => env('MD_BLOG_MAIL_ENABLED', false),
'recipient_model' => env('MD_BLOG_MAIL_RECIPIENT_MODEL', null),
'queue' => env('MD_BLOG_MAIL_QUEUE', false),
'queue_connection' => env('MD_BLOG_MAIL_QUEUE_CONNECTION', null),
'queue_name' => env('MD_BLOG_MAIL_QUEUE_NAME', null),
'chunk_size' => env('MD_BLOG_MAIL_CHUNK_SIZE', 50),
'middleware' => ['auth'],
'subject_prefix' => env('MD_BLOG_MAIL_SUBJECT_PREFIX', ''),
],
];
Caching
Posts are cached per-file with automatic staleness detection — when a file is modified, the cached entry is automatically refreshed on the next read. Use MdBlog::clearCache() to force a full cache bust.
Development
Running Tests
composer install
./vendor/bin/phpunit
Workbench (Demo App)
The package includes a workbench powered by Orchestra Testbench:
php vendor/bin/testbench serve
Visit http://localhost:8000/blog to see the demo blog.
License
MIT