apxcde / markdown-blog
Markdown-powered blog package for Laravel apps
Requires
- php: ^8.3
- laravel/framework: ^13.0
- spatie/laravel-package-tools: ^1.92
Requires (Dev)
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.2
- pestphp/pest-plugin-arch: ^4.0
This package is auto-updated.
Last update: 2026-08-18 15:28:47 UTC
README
A Laravel package that turns a directory of markdown files into blog articles.
It gives you:
- recursive discovery of article files under a configured directory
- frontmatter parsing for common article metadata
- normalized article payloads as plain arrays
- a generated excerpt when an article declares no description
- display-ready date formatting
- slug-based article lookup, newest first
It deliberately stops there: routes, controllers, Livewire components, and views stay in your application.
Requirements
- PHP 8.3+
- Laravel 13+
Installation
composer require apxcde/markdown-blog
The service provider and the MarkdownBlog facade alias are registered
automatically through Laravel package discovery.
Configuration
Publish the config file to change where articles live or how they are formatted:
php artisan vendor:publish --tag=markdown-blog-config
// config/markdown-blog.php return [ 'articles_path' => resource_path('markdown/articles'), 'article_filename' => 'page.md', 'excerpt_length' => 220, 'date_format' => 'M j, Y', ];
| Key | Purpose |
|---|---|
articles_path |
Directory scanned for articles. A missing directory yields an empty collection rather than an error. |
article_filename |
Only files with this exact name are treated as articles. |
excerpt_length |
Truncation length for the excerpt generated when description is absent. An ellipsis is appended, so the result runs a few characters longer. |
date_format |
PHP date format applied to formatted_date. |
Article structure
Each article is a directory containing one page.md. The directory is scanned
recursively, so you are free to group articles into subdirectories.
resources/
└── markdown/
└── articles/
├── first-post/
│ └── page.md
└── 2024/
└── another-post/
└── page.md
--- title: "Infinite Scroll with Laravel and Livewire" description: "Infinite scrolling is a popular feature for content-heavy pages." author: "Rick Mwamodo" date: "2024-01-17" slug: "infinite-scroll-with-laravel-and-livewire" --- Article body goes here.
Frontmatter fields
Every field is optional. When one is absent, the package falls back as
follows. Note that a key which is present but blank (title:) counts as a
value — you get an empty string, not the fallback.
| Field | Fallback |
|---|---|
slug |
The article's parent directory name. Either way the value is passed through Str::slug(). An article that resolves to an empty slug is skipped. |
title |
Str::headline() of the slug, so first-post becomes First Post. |
description |
An excerpt built from the body: markdown rendered, tags stripped, whitespace collapsed, truncated to excerpt_length. |
author |
An empty string. |
date |
An empty string, and formatted_date is then null. |
Frontmatter syntax
This is not a YAML parser. It handles a small, deliberate subset: flat
key: value pairs, one per line. Values may be unquoted, single-quoted, or
double-quoted; surrounding quotes are stripped and every value is returned as a
string, so 42 and true are not cast. Blank lines, # comments, and lines
with no colon are skipped.
Richer YAML is not rejected — it is quietly mis-read, so avoid it:
| You write | You get |
|---|---|
author: then an indented name: Rick |
Indentation is ignored, so name is hoisted to a top-level key and author becomes ''. |
tags: then - php, - laravel |
The - lines have no colon and are dropped; tags becomes ''. |
items: then - name: foo |
That line does contain a colon, so you get a literal key - name. |
tags: [php, laravel] |
Kept verbatim as the string '[php, laravel]'. |
Keep frontmatter flat and scalar, and parse richer values (a comma-separated list, for example) in your own code.
Usage
Facade
use apxcde\MarkdownBlog\Facades\MarkdownBlog; $articles = MarkdownBlog::all(); $article = MarkdownBlog::findBySlug('infinite-scroll-with-laravel-and-livewire');
Resolved service
use apxcde\MarkdownBlog\MarkdownBlog; $blog = app(MarkdownBlog::class); $articles = $blog->all(); $article = $blog->findBySlug('infinite-scroll-with-laravel-and-livewire'); $repository = $blog->repository();
Repository
use apxcde\MarkdownBlog\ArticleRepository; $repository = app(ArticleRepository::class); $articles = $repository->all(); $article = $repository->findBySlug('infinite-scroll-with-laravel-and-livewire');
In a controller
use apxcde\MarkdownBlog\Facades\MarkdownBlog; Route::get('/blog', fn () => view('blog.index', [ 'articles' => MarkdownBlog::all(), ])); Route::get('/blog/{slug}', function (string $slug) { abort_if(! $article = MarkdownBlog::findBySlug($slug), 404); return view('blog.show', ['article' => $article]); });
API
all(): Illuminate\Support\Collection
Returns every article as an array, sorted by date descending — newest first.
Articles with no date, or with a date Carbon cannot parse, sort last. If
articles_path does not exist, you get an empty collection.
findBySlug(string $slug): ?array
Returns the matching article, or null. The argument is run through
Str::slug() first, so Infinite Scroll and infinite-scroll both match.
Returned article shape
[
'slug' => 'infinite-scroll-with-laravel-and-livewire',
'title' => 'Infinite Scroll with Laravel and Livewire',
'description' => 'Infinite scrolling is a popular feature for content-heavy pages.',
'author' => 'Rick Mwamodo',
'date' => '2024-01-17',
'formatted_date' => 'Jan 17, 2024',
'content' => 'Article body goes here.',
]
date is returned exactly as written in the frontmatter. formatted_date is
that date parsed by Carbon and rendered with date_format; it is null when no
date is set, and falls back to the raw string when the date cannot be parsed.
content is the markdown body with the frontmatter block removed, trimmed, and
left unrendered — render it in your view.
Testing
composer test
Contributing
This package is developed inside ApexCode's Turbine platform monorepo and published here as a one-way mirror. Please open issues on this repository.
Pull requests are welcome, but note that this repository is machine-managed: its branches are overwritten by the next sync from the monorepo, so a PR cannot simply be merged here. Accepted changes are ported upstream and land back through a later sync, with credit preserved.
License
The MIT License (MIT). See LICENSE.md for details.