Ryan Chandler

Adding "Suggested Posts" to My Laravel Blog

6 min read

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?