Laravel Articles
Laravel Videos
General Articles
YouTube Journey Series
17th January 2021 • Laravel
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.
The result was a really quick site build and something very simple for me to maintain.
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 this 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.
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:
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:
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.
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:
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:
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:
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.
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.
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.
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:
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'));
}
}
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:
---
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.
composer require spatie/yaml-front-matter
So let’s use this when building the post information:
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.
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:
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:
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:
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:
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!
Finally, I needed a post detail page. Here’s the route:
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:
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!
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 package to generate a static site as part of my deployment process.
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!