Creating a Markdown Driven Blog using Laravel 8
Tags: laravel, php, markdown, webdev
Published: 2021-01-19 11:24:35

In this post I’m going to walk you through how I created my blog using Markdown files with Laravel 8, and why I chose to go that route instead of a blogging platform like [Wordpress](https://wordpress.org) or [Ghost](https://ghost.org).

The result was a really quick site build and something very simple for me to maintain.

## Why Markdown with Laravel?
So the first question is why I went this route?

Well, I wanted to get something live as quickly as possible. I’ve tried blogging in the past and got too hung up in the site’s design or features.

This time I wanted to focus on the content.

The sensible choice would be Wordpress or Ghost right? Actually, not for me. It’s a long time since I’ve built anything in Wordpress, and while I’ve played around with Ghost and it looks very cool there would still be a learning process.

Everything I build these days is in Laravel, so the quickest way to get it up and live would be to go that way.

Plus I don’t need a full CMS interface to manage the content. I’m more than happy to write posts in Markdown in my text editor.

And so I got to work building something that would read Markdown files from a directory and display them on screen. Nice and simple.

## Reading Markdown Files
So the main challenge with this build was to figure out how to read Markdown files in a directory.

I knew I’d need to do this in 2 different situations.

First, getting a list of posts in date order for listing screens. Secondly, reading a single file to display a single post.

I decided to include the publish date in the file names, and also to use the file names as the post slugs. For example:

```bash
2021-01-16-creating-a-markdown-blog-with-laravel-8.md
```

Then, I created a **Post** class, and in the constructor created a collection of all of the filenames in the **app/Posts** directory. Here’s the code:

```php
use Illuminate\Support\Facades\File;

class Post
{
    public $filenames;

    public function __construct()
    {
        $this->filenames = collect(File::allFiles(app_path('Posts')))
            ->sortByDesc(function ($file) {
                return $file->getBaseName();
            })
            ->map(function ($file) {
                return $file->getBaseName();
            });
    }
}
```

Here I’m using the **Illuminate\Support\Facades\File** class to list files in the **app/Posts** directory. Then using **sortByDesc** I ensure they are in date order.

Finally, I use the map method to store just the file name in the collection array.

## Creating the Listings Pages
There are 2 types of listings pages - the homepage lists all posts, and the category pages list posts in a particular category. I started by looking at the first situation.

To achieve this, I would need to use the **Illuminate\Filesystem\Filesystem** class, so I create an instance of this in the constructor:

```php
use Illuminate\Support\Facades\File;
use Illuminate\Filesystem\Filesystem;

class Post
{
    public $filenames;
    public $filesystem;

    public function __construct()
    {
        $this->filenames = collect(File::allFiles(app_path('Posts')))
            ->sortByDesc(function ($file) {
                return $file->getBaseName();
            })
            ->map(function ($file) {
                return $file->getBaseName();
            });
        
        $this->filesystem = new Filesystem();
    }
}
```

Next, I created a **getLatest** method on the Post class:

```php
public function getLatest($limit)
{
    $posts = [];

    foreach ($this->filenames->take($limit) as $filename) {
        // Build $posts array
    }

    return collect($posts);
}
```

This method gets a number of posts specified by the limit parameter. Because the filenames are a collection I can use the take method for this.

Next, I created a **getPostData** method to read each post:

```php
use League\CommonMark\Environment;
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Extension\Attributes\AttributesExtension;

...

public function getPostData($filename)
{
    $file = $this->filesystem->get(app_path('Posts/' . $filename));

    $post['slug'] = str_replace('.md', '', $filename);

    $environment = Environment::createCommonMarkEnvironment();
    $environment->addExtension(new AttributesExtension());
    $converter = new CommonMarkConverter([], $environment);

    $post['body'] = $converter->convertToHtml($file);

    return $post;
}
```

As you can see here I am making use of the **league/commonmark** package to read the Markdown file and convert it to HTML.

```bash
composer require league/commonmark
```

> **What's the AttributesExtension?**
>
> The attributes extension allows me to add attributes to elements in the Markdown itself. I use it to create callouts just like this one by assigning the **.callout** class to a blockquote. [You can read more here](https://commonmark.thephpleague.com/1.5/extensions/attributes/).

I also generate the slug by removing the **.md** extension.

An array of post data is returned, and so back in the getLatest method I call getPostData on each post.

```php
public function getLatest($limit)
{
    $posts = [];

    foreach ($this->filenames->take($limit) as $filename) {
        $posts[] = $this->getPostData($filename);
    }

    return collect($posts);
}
```

Now, in my **HomepageController** I call the **getLatest** method to get the 5 latest posts, and pass the returned data to the view:

```php
namespace App\Http\Controllers;

use App\Models\Post;

class HomepageController extends Controller
{
    public function index()
    {
        $postClass = new Post;

        $posts = $postClass->getLatest(5);

        return view('homepage', compact('posts'));
    }
}
```

## Using Frontmatter
This was a good start, however I didn’t want to display the full HTML in the listings. I also wanted to categorise posts.

Enter frontmatter - a section of information at the top of the Markdown file where I could set any of this information. It looks like this:

```md
---
title: Creating a Markdown Driven Blog using Laravel 8
published: 2021-01-16
category: Laravel
excerpt: In this post I’m going to walk you through how I created this website using Markdown files with Laravel 8, and why I chose to go that route instead of a blogging platform like Wordpress or Ghost.
---
```

Now you can see I have the post title, date, category and an excerpt set.

This information can be retrieved using the **spatie/yaml-front-matter** package.

```bash
composer require spatie/yaml-front-matter
```

So let’s use this when building the post information:

```php
use Spatie\YamlFrontMatter\YamlFrontMatter;

...

public function getPostData($filename)
{
    $file = $this->filesystem->get(app_path('Posts/' . $filename));
    $object = YamlFrontMatter::parse($file);

    $post['meta'] = $object->matter();
    $post['slug'] = str_replace('.md', '', $filename);

    $environment = Environment::createCommonMarkEnvironment();
    $environment->addExtension(new AttributesExtension());
    $converter = new CommonMarkConverter([], $environment);

    $post['body'] = $converter->convertToHtml($object->body());

    return $post;
}
```

As you can see, the file’s contents are now parsed by **YamlFrontMatter** first.

I’ve added meta to the returned array that contains all of these fields.

And the body HTML is generated by passing the body of the parsed post rather than the entire file.

This means I can now update my homepage view to just display the title, date, category and excerpt.

## Adding Categories
Next, let’s look at categories. We have the category in our frontmatter, I would like to have dedicated category pages using the URL **articles/{category}** so the first step is to create that route:

```php
use App\Http\Controllers\CategoryController;

...

Route::get('articles/{category}', [CategoryController::class, 'index']);
```

So in order to do this filtering I need to read each file. So I created a **getCategory** method in the Post class:

```php
public function getCategory($category, $limit)
{
    $posts = [];

    foreach ($this->filenames as $filename) {
        $posts[] = $this->getPostData($filename);
    }

    return collect($posts)->where('category', ucwords($category))->take($limit);
}
```

This method loops through all of the posts getting all data. Then on the collection returned I use the where method to filter the posts that are returned.

In order for this to work, I expanded the **getPostData** method to set a category key in the post array, so that it's there for the where method to filter by:

```php
public function getPostData($filename)
{
    $file = $this->filesystem->get(app_path('Posts/' . $filename));
    $object = YamlFrontMatter::parse($file);

    $post['meta'] = $object->matter();
    $post['category'] = $object->matter('category');
    $post['slug'] = str_replace('.md', '', $filename);

    $environment = Environment::createCommonMarkEnvironment();
    $environment->addExtension(new AttributesExtension());
    $converter = new CommonMarkConverter([], $environment);

    $post['body'] = $converter->convertToHtml($object->body());

    return $post;
}
```

Now, in the **CategoryController** I can call the **getCategory** method to get the latest posts in the category requested:

```php
namespace App\Http\Controllers;

use App\Models\Post;

class CategoryController extends Controller
{
    public function index($category)
    {
        $postClass = new Post;

        $posts = $postClass->getCategory($category, 5);

        if ($posts->count() == 0) {
            return redirect('/');
        }

        return view('category', compact('posts'));
    }
}
```

> **Note:** Right now I think this approach is fine as I only have a small number of posts on the site. As the site grows with more posts I would like to look into a more efficient way to achieve this filtering so I don’t need to process every single post first!

## Post Detail
Finally, I needed a post detail page. Here’s the route:

```php
use App\Http\Controllers\ArticleController;

...

Route::get('{slug}', [ArticleController::class, 'index']);
```

And then the **ArticleController** calls **getPostData** on the filename requested by the slug in the URL:

```php
namespace App\Http\Controllers;

use App\Models\Post;

class ArticleController extends Controller
{
    public function index($slug)
    {
        $postClass = new Post;

        $post = $postClass->getPostData($slug . '.md');
        
        return view('article', compact('post'));
    }
}
```

Nice and simple!

## Future Improvements
This setup allowed me to get live and start posting content super quickly - a couple of evenings work and I was ready to go live!

As mentioned in a note above, I’m not 100% happy with the category filtering and I’d like to look into that as it could impact performance as the number of posts increase.

That said, another improvement I am considering is to use the [spatie/laravel-export](https://github.com/spatie/laravel-export) package to generate a static site as part of my [deployment process](https://dev.to/stef686/how-i-deploy-my-laravel-website-with-envoy-3ndb).

This way I should be able to improve performance even further on the site by hosting just static files. But it would reduce the impact of any potential category filtering issues as it would only happen at build time.

I would also like to add an RSS feed, and a suggested article feature that appears at the bottom of the post detail page.

Thanks for reading, hopefully you found this article useful. If you use a similar setup or if you give this a go yourself let me know on Twitter [@CodingWithStef](https://twitter.com/CodingWithStef) or in the comments below!