Adding "Suggested Posts" to My Laravel Blog
This post was published 2 years ago. Some of the information might be outdated!
My blog is very, very simple. It's a Laravel app powered by a set of flat Markdown files which can be interacted with using Eloquent.
I've noticed that the bounce rate on my blog is pretty poor. I don't write a lot of content these days but I have written quite a few posts in the past. My theory is that I'm not suggesting content to people so they're never actually visiting other pages.
I want to try to improve that since the content I've written in the past can easily get lost amongst the latest posts.
Here's my grand plan:
- Add a field to store keywords on my blog posts
- Write a script to fill in keywords based on category and title
- Add a
keywords
field to Filament - Start recommending blog posts based on matching keywords
Storing keywords
As I mentioned, my blog uses Orbit to store content in the form of a Markdown file and interact with those files using Eloquent models.
Adding a new keywords
field to the Post
model is really simple. We just need to update our model's schema.
public static function schema(Blueprint $table)
{
$table->string('title');
$table->string('slug');
$table->text('excerpt')->nullable();
$table->text('content')->nullable();
$table->json('keywords')->nullable();
$table->timestamp('published_at')->nullable();
$table->string('category_slug')->nullable();
}
We'll also need a cast since we want this to return a PHP array
.
protected $casts = [
'published_at' => 'datetime',
'keywords' => 'json',
];
Now our Post
model is able to store keywords inside of the Markdown content files.
Filling in missing keywords
All of the posts on my blog are categorised. They belongTo
a single Category
model.
I'm going to write a command that loops over each Post
, checks the Category
it belongs to and fills in the keywords
field with some standard values.
The command is only going to be run once, so I'm not going to bother with a dedicated Command
class. Instead I'll opt for the simpler approach and write all of the logic inside of the routes/console.php
file.
We need to grab all of the posts on the site.
Artisan::command('script:fill-missing-keywords', function () {
$posts = Post::all();
// ...
});
The only thing left to do is loop over each of the posts and update the keywords accordingly.
Artisan::command('script:fill-missing-keywords', function () {
$posts = Post::all();
$posts->each(function (Post $post) {
$post->update([
'keywords' => match ($post->category?->slug) {
'alpinejs' => ['alpine', 'alpinejs', 'frontend', 'javascript', 'js'],
'css' => ['css', 'style', 'styling', 'tailwind'],
'github-actions' => ['actions', 'ci', 'cd', 'github'],
'insights' => ['insights'],
'javascript' => ['javascript', 'js', 'alpine', 'alpinejs'],
'laravel' => ['php', 'laravel', 'laravelphp', 'blade', 'model', 'eloquent'],
'livewire' => ['livewire', 'laravel', 'php', 'reactive', 'ssr', 'wire'],
'php' => ['php', 'server', 'laravel', 'programming'],
'programming-languages' => ['pl', 'plt', 'programming-language', 'programming'],
'tailwind-css' => ['css', 'style', 'tailwind', 'tailwindcss'],
'tips-tricks' => ['tips', 'tricks', 'tips-tricks', 'php', 'livewire',],
'tooling' => ['tooling', 'tips'],
default => [],
}
]);
});
});
Each Category
has a slug
column. Matching against that we can set the value of keywords
to some array of strings. This could run a little faster if we eager-load the category
relationship but it's a one-time script so I'm not too bothered.
Running this command will update the Markdown files. Progress!
Setting keywords from Filament
I normally write my content in Markdown files directly but sometimes I need to fix typos. It's quicker for me to do that through an admin panel so I use Filament.
To update keywords from Filament, we just need to add a new field to the PostResource
. I'm going to use a TagsInput
field that accepts a list of strings.
public static function form(Form $form): Form
{
return $form
->schema([
Card::make([
// ...
TagsInput::make('keywords')
->columnSpan(2)
->nullable(),
// ...
])
->columns(2)
]);
}
Finding related posts
With all of that in place, we can finally write the method to find related posts. I'm going to do this in a method on the Post
model directly.
Before you look at the code, it's worth mentioning that Orbit uses an SQLite database under-the-hood to proxy the flat content files between the filesystem and Eloquent. This is a trick that was first introduced by Caleb Porzio with Sushi.
public function relatedPosts(): Collection
{
return static::query()
->select('posts.*')
->fromRaw('posts, json_each(posts.keywords)')
->where($this->getKeyName(), '!=', $this->getKey())
->whereIn(DB::raw('json_each.value'), $this->keywords)
->distinct()
->get();
}
There's a little bit of SQLite wizardry going on here. If this were a MySQL query you could run a JSON_CONTAINS()
check on the keywords
column and check if the array of keywords contains any matching values.
Sadly SQLite doesn't have the same level of JSON support as MySQL and Postgres. To work around this, we instead need to use the json_each()
function.
The json_each()
function will produce a separate row in the query result for each value found in the JSON column. If a row in the posts
table contains 7 keywords, that row will be returned 7 times each with the respective keyword. The string from the JSON column will get put into a kind of virtual table for that query called json_each
so we can run comparison checks on the json_each.value
column.
I couldn't find a method to add the json_each
call natively in Laravel so I went with a fromRaw()
call that adds in the posts
table and the json_each
call.
We also want to exclude the current post to make sure we're not suggesting the same post the visitor is currently reading.
Some of you might be questioning the choice to use an Eloquent query here instead of a plain DB
query. Using an Eloquent
query will ensure that the query results are still transformed into Post
model instances.
Outputting related posts
The Post::relatedPosts()
method can now be used inside of our Blade template to output a couple suggestions at the very end of the blog post.
@php($relatedPosts = $post->relatedPosts())
@if($relatedPosts->isNotEmpty())
<div id="suggestions" class="space-y-4">
<h4>You might also enjoy...</h4>
<div class="space-y-2">
@foreach($relatedPosts->shuffle()->take(2) as $post)
<x-post-card :post="$post" :excerpt="false" />
@endforeach
</div>
</div>
@endif
Pretty simple Blade logic here. If we find some related posts, we loop over them and output the x-post-card
Blade component. The excerpt is disabled to save some vertical space.
We also shuffle the posts and take the first 2 results so that we don't end up suggesting the same 2 posts to every visitor.
Result
With all of that in place, you should be able to see some post suggestions at the end of this page.
If you want to see all of the code changes, the pull request can be found here.
Probably worth clicking through and reading one of those suggested posts, right?