A few months ago I wrote about building a Laravel blog package that reads markdown files instead of a database. Version 1.3.0 is out today, and it is the biggest release since then. Here is what changed and why.

A real query builder

The first version had whereTag(), whereCategory() and latest(), and none of them could be chained. That was fine for a landing page with six posts, but the moment I wanted "tutorials tagged laravel, newest first, nine per page" I was writing collection pipelines in controllers. Now there is a proper builder:

$posts = MdBlog::query()
    ->whereTag(['laravel', 'php'])
    ->whereCategory('tutorials')
    ->search('cache')
    ->latest()
    ->paginate(9);

paginate() returns a normal LengthAwarePaginator, so it drops into a Blade view with links() or into an Inertia page exactly like an Eloquent paginator would. There is also whereMeta() for your own front matter fields, whereYear(), a raw where() closure, orderBy(), skip() and take().

Previous, next and related

Every blog needs the little footer that says "older post" and "newer post", and most want a "you might also like" list. Both are now one call each. MdBlog::previous($post) and MdBlog::next($post) walk the publication order, with the slug as a tie-breaker so two posts on the same day never skip or repeat. MdBlog::related($post) scores the rest of the catalog by shared tags, two points each, plus one point for a shared category, and hands back the top three.

Posts that know more about themselves

Three small additions to the Post object that remove boilerplate from every index page I have written with this package:

  • A cover field in the front matter. A relative path like images/cover.jpg is rewritten to the package's image route, an absolute URL is kept as is, and the result lives on $post->cover.

  • An excerpt when you forgot to write one. It takes the first 160 characters of the body's prose, skips headings and code blocks, and cuts on a word boundary. The length is configurable.

  • A readingTime in whole minutes, based on a configurable words-per-minute figure that defaults to 200.

Absolute URLs

Image URLs used to be root-relative, which is fine on the page and broken everywhere else: in a newsletter, in an RSS reader, in a JSON response read by another host. Set MD_BLOG_URL in your environment and every image and cover becomes absolute. Leave it unset and nothing changes.

Under the hood

find() used to parse every file in the directory to locate one slug. It now reads a cached slug-to-file index and opens a single file. The index rebuilds itself when a file is added, moved, deleted or changes its slug, so there is nothing to invalidate by hand. Cache keys carry a version number, which means upgrading never leaves you with half-unserialized objects from the previous release, and a post cached under one post_class is rebuilt automatically when you switch to another.

One behaviour change to be aware of: MdBlog::all() now returns posts newest first instead of in filesystem order. If you relied on the old order, you were relying on an accident, but it is still a change.

Upgrading

composer require jcfrane/laravel-md-blog:^1.3 and you are done. The only time you need to clear the cache is after setting MD_BLOG_URL, because the rendered HTML is stored with the URLs baked in. Everything else is additive. The package is on GitHub at github.com/jcfrane/laravel-md-blog, and the full release notes are on the v1.3.0 release. Next on the list: scheduled publishing with draft previews, RSS and sitemap routes, and a build-time index for people with a lot more posts than I have.