Make Blogging Fun Again (with Laravel!)

Sat, Nov 4, 2023

Since I've been developing applications in Laravel for 3 years now, I've learned that there's no substitute for the excellent developer experience Laravel provides. So, when I started knocking around ideas about how to build a personal site & blog for which I would actually enjoy writing posts on a day-to-day basis, I found I couldn't bear to leave Laravel behind, even with the array of interesting static site generators and content management systems that are out there. Of course, there is Jigsaw to provide a Laravel-like experience, and Statamic that's built on top of Laravel, but I found Statamic would add too much complexity for the basic blog use-case, and Jigsaw didn't give me the opportunity to append features like forms and search to the application. Of course, that's no knock on these great projects; there are some extremely bright people working on them and loads of people are using them in all kinds of projects.

Primary considerations

I didn't really need a rich-text editor or fancy UI such as those of familiar platforms like Wordpress. I use markdown often enough as a developer that I truly prefer it for authoring posts. I also didn't want the overhead of having to insert TEXT columns to a database. Modern text editors and IDEs have great markdown editing features, and I wanted to be able to leverage these natively. And yet, I wanted to be able to represent my posts as Eloquent models. This way I could use Eloquent features to form the backend of the blogging system with ease, as well as spin up search with Laravel Scout.

From Markdown to Eloquent

As a quick Google search for "laravel markdown blog" revealed, this road has been trodden down before by the likes of Aaron Francis, whose article on how to use markdown to blog in Laravel tipped me off, crucially, to the existence of Caleb Porzio's Laravel Sushi package. Sushi is, according to its own literature, the 'missing array driver for Eloquent', and makes it simple to use arrays as the data source of Eloquent models.

So, as Aaron had pointed out, after adding the Sushi trait on my Posts Eloquent model, we could imagine including markdown posts like so by hard-coding them into arrays on the class:

app/Models/Post.php

protected $rows = [
[
'id' => 1,
'title' => 'Hello, world!',
'slug' => 'hello-world',
'published_at' => 1695762720,
'summary' => 'There is perhaps no better way to start a blog than with this tried and true greeting.',
'body' => '...', // Very long markdown post body
],
[
'id' => 2,
'title' => 'My second article',
'slug' => 'my-second-article',
'published_at' => 1698719638,
'summary' => 'For the second post, I thought I\'d write a bit about this application.',
'body' => '...', // Very long markdown post body
],
];

This is a good start, but if I had gone with this approach alone, I wouldn't've been able to take advantage of my IDE's markdown editing features. I also didn't want my PHP class to become mind-bogglingly long, nor did I want to deal with copy-pasting large volumes of text into the single file. So, what to do?

Fortunately, Sushi provides a nice way to provide custom logic to retrieve the array's 'rows'. You can simply override the getRows method, which itself returns an array. This opens up some interesting possibilities, since we could potentially load in our rows from markdown files themselves. But how to include the kind of data we would want in the Eloquent model, such as id, title, slug, etc.? Why, through the well-adopted frontmatter convention! And, since Spatie thinks of everything, I was able to plug in their Yaml Front Matter package to handle the parsing (don't worry—I've just dispatched my postcard).

Using the YamlFrontMatter class' static parse method, all we have to do is pass in the path of the file to get going.

$parsedFile = \Spatie\YamlFrontMatter\YamlFrontMatter::parse($pathToFile);

Assuming we fill out our file's frontmatter with the necessary data...

resources/markdown/blog/hello-world.md

 
---
 
id: 1
title: Hello, world!
slug: hello-world
published_at: 1695762720
 
---
 
My very long post body...

...we can then retrieve these data and the body like so using YamlFrontMatter's convenience methods:

$parsedFile = YamlFrontMatter::parse($pathToFile);
$frontMatter = $parsedFile->matter(); // Returns an associative array
$body = $parsedFile->body(); // Returns the markdown body as a string

Putting this all together into a getRows method, we get this:

app/Models/Post.php

public function getRows(): array
{
$disk = Storage::disk('blog');
 
return collect($disk->files())
->map(function (string $file) use ($disk) {
$parsedFile = YamlFrontMatter::parse($disk->get($file));
 
return [...$parsedFile->matter(), 'body' => $parsedFile->body()];
})->all();
}

Now I can get exactly the same desired multidimensional array structure seen earlier, without having to do any copy-pasting or hard-coding the data into the Post model class. Pretty cool! Now we can freely take advantage of Eloquent features with our markdown files.

use App\Models\Post;
 
// Get posts in descending order of publication date
$posts = Post::orderByDesc('published_at')->get();
 
// Get posts with a publication date earlier than yesterday
$filteredPosts = Post::where('published_at', '<', now()->yesterday())->get();

And, of course, we can now take the body attribute from our Post model and render our markdown as HTML:

<?php
 
$post = Post::find(1);
 
?>
 
<x-content>
{!! Markdown::convert($post->body) !!}
</xcontent>

An aside on caching posts

Sushi anticipated that folks might want to load in 'rows' from an external source, since it has some caching features that watch for filesystem-level changes in a specified file to determine when the cache should be invalidated (the cache itself is actually a SQLite database). This is especially helpful if we'd like to load in data from a CSV or JSON file. In my case, I appreciate being able to cache my posts, but I would need Sushi to check for changes in the markdown directory. This isn't possible, because Sushi calls filemtime() on the cache reference path set by overriding sushiShouldCache on our Eloquent model, and compares it to the filemtime() of the cache path to determine whether the cache is stale:

Sushi.php

switch (true) {
case ! $instance->sushiShouldCache():
$states['no-caching-capabilities']();
break;
 
case file_exists($cachePath) && filemtime($dataPath) <= filemtime($cachePath):
$states['cache-file-found-and-up-to-date']();
break;
 
case file_exists($cacheDirectory) && is_writable($cacheDirectory):
$states['cache-file-not-found-or-stale']();
break;
 
default:
$states['no-caching-capabilities']();
break;
}

I don't yet have an ideal solution implemented for this. I'm considering submitting a pull request to Sushi or creating a fork of it to answer this use case, but in the meantime, I'm just using an Artisan command to delete the cache file on deployment. Since Laravel Forge will be handling the final step of my deployments, it'll be a piece of cake to just reference the command in my deployment script. Good enough for now!

Odds & Ends

Although I don't intend for this post to be an exhaustive explication of every single implementation detail in my application, I thought I'd address a couple more important blogging features you might be wondering about.

Drafts

Here, the system I've devised is pretty simple, which suits me just fine. I just set published_at to null in the post frontmatter for any post I consider to be a draft.

resources/markdown/blog/my-newest-post.md

 
---
 
id: 3
title: My newest post
slug: my-newest-post
published_at: null
summary: This is a very new post.
 
---
 
My very long post body...which is not ready to see the light of day...

Then, I define a query scope on the model.

app/Models/Post.php

/**
* Scope a query to fetch only published posts.
*/
public function scopePublished(Builder $query): void
{
$query->whereNotNull('published_at');
}

Now it's easy to grab only published posts for any purpose.

$publishedPosts = \App\Models\Post::published()->get();

Search

I don't know about you, but I've never found the 'traditional' blog navigation setup of clicking around in lots of expanding date filters to be very easy to use. I typically read a few of the latest posts on the front page when I'm checking out a blog, and then use the search to find anything more specific. So, I've decided to implement a simple search feature here in lieu of any complicated navigation.

At my job, I would usually reach for Elasticsearch/ Opensearch for its feature-richness, since, in the e-commerce context, we really need all those bells and whistles. For the purposes of a simple blog, however, that really isn't the case. It's also a bit complicated to use Elasticsearch with Laravel Scout, and I'm truly aiming for simplicity here (although it is becoming much easier in recent days thanks to the valiant efforts of Ivan Babenko). So, instead, I've opted for one of the drivers that works out of the box in Laravel Scout, namely the 'blazing fast' (it really is) Meilisearch.

The setup isn't too complicated, especially since we've already done the lift to represent our posts as Eloquent models, so I won't go into all the details since the documentation does a more-than-adequate job, though I will demonstrate how I implemented it in the context of the blog.

app/Models/Post.php

use Laravel\Scout\Searchable;
 
public function shouldBeSearchable(): bool
{
return !is_null($this->published_at);
}
 
public function toSearchableArray(): array
{
if (!$this->shouldBeSearchable()) {
return [];
}
 
return [
'title' => $this->title,
'slug' => $this->slug,
'published_at' => $this->published_at
? (int) $this->published_at->timestamp
: null,
'body' => strip_markdown($this->body),
];
}

Once we use the Searchable trait, we are technically good to go, but I like to customize what gets indexed in a couple ways: first of all, by overriding shouldBeSearchable, I can exclude drafts. Then, I can manipulate and filter the data a bit more in toSearchableArray. Notably, it's important to ensure the published_at timestamp is always a UNIX timestamp for the purposes of Meilisearch , so I've taken some effort to ensure that happens. You may have noticed I have also defined a handy strip_markdown global function so that I can send up just the body text, markup-free, as the primary piece of searchable data:

app/Helpers/functions.php

function strip_markdown(string $markdown): string
{
return strip_tags(Markdown::convert($markdown));
}

So, after all is said and done, I can grab the posts quite easily inside a Livewire component. Here's what my render method looks like:

app/Livewire/Search.php

public function render()
{
if (strlen($this->searchQuery) > 0) { // Meilisearch returns all results with an empty query
$this->results = Post::search($this->searchQuery)
->take(6)
->get()
->toBase();
} else {
$this->results = new Collection;
}
 
return view('livewire.search');
}

Of course, Sushi doesn't actually fire any Eloquent events, so the Scout features that automagically keep the index in sync with our Eloquent models aren't available with this setup. For now, I'm all right with that, since it's just me authoring posts, and I can take care of syncing the index during deployment, as with the Sushi cache:

app/Console/Commands/SyncPosts.php

[$searchablePosts, $unsearchablePosts] = $posts->partition->shouldBeSearchable();
 
$searchablePosts->searchable();
$unsearchablePosts->unsearchable();

Syntax Highlighting & Fancy Markdown

Here I'm once again indebted to Aaron Francis for the idea of using Torchlight for syntax highlighting in conjunction with the Laravel Markdown package for some fancier markdown features. Aaron also has a cool method of rendering those clickable anchors for headers you see everywhere, but I haven't implemented this just yet. I recommend going to his blog for the details, since I don't have much to add in this area.

Future plans

All right; that's pretty much it, folks. This implementation is likely to change, but I think this it's a great way to get a simple, developer-friendly, easy to maintain blog going quickly while still leaving room to add more robust features like a commenting system. Speaking of which, if you have any questions or comments, feel free to send me a message since my own commenting system has yet to be implemented.

Time to get blogging!