Collin O'Connell

Creating a Markdown Driven Blog with Symfony

In this post I will walk through how I created my blog using Markdown files with Symfony 6.

Why Markdown?

The reason I chose to use markdown instead of use a database, because I want to make sure I am able to keep my backup on Github.

I know I could probably save a database dump to a repository on Github, but keeping it in markdown also allows others to read it there along with from the site.

It also makes it a lot simpler to write. I can get an Idea an jot it down somewhere else and then just paste it here without having to worry about some CMS editor.

Why Symfony?

I originally made this site using Laravel, but I because of how much "Magic" happens behind the scenes I switched to Symfony. I feel as of right now it's the closest to PHP. I like that hey have already tapped into Attributes, and are already looking into the fix that will happen come 9.0.

I need to do a deeper dive in to performance, because the quick overview of Laravel with Octane increases performance. But with the recent release of FrankenPHP, the performance of PHP applications is really going to start to improve.

Reading Markdown Files

The biggest undertaking was reading files from a directory. The first option I need was a way to list out the different posts. The second is reading a single file to display the post.

As with most markdown driven sites I decided to opt for the naming convetion of date-slug 2023-10-08-creating-a-markdown-blog-with-symfony.md

I then created an Entity and Repository for the posts. Then in the constructor of the PostRepository scan the directory for all files at the root of the application in the posts directory.

    use App\Entity\Post;
    use App\Kernel;
    use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
    use Doctrine\Common\Collections\ArrayCollection;
    use Doctrine\Persistence\ManagerRegistry;
    
    class PostRepository extends ServiceEntityRepository
    {
        private ArrayCollection $filenames;
        
        private string $postsDir;
        
        public function __construct(
            ManagerRegistry $registry,
            Kernel $appKernel
        ) {
            parent::__construct($registry, Post::class);
            
            $this->postsDir = $appKernel->getProjectDir() . '/posts';
            
            $postFiles = array_filter(scandir($this->postsDir), static function ($item) {
              return $item[0] !== '.';
            });
    
            usort($postFiles, static function ($fileA, $fileB) {
                $fileA = $this->postsDir . '/' . $fileA;
                $fileB = $this->postsDir . '/' . $fileB;
                if (!file_exists($fileB)) {
                    return -1;
                }
                if (!file_exists($fileA)) {
                    return 1;
                }
    
                return filemtime($fileA) < filemtime($fileB);
            });
    
            $this->filenames = new ArrayCollection($postFiles); 
        }
    }

We inject the Kernel to get the current project root and then add the 'posts' directory to use later. After that we are filtering the array from the results of scandir and making sure to only get the files within the directory. There is room to improve here, but for now it gets the job done. Next we are sorting the files by there last modified time. And finally assigning them to the filenames ArrayCollection.

Creating the Listing Pages

We will create a few different options to list out the posts. A list of all posts, a list of posts by categories, and then a list of all categories. We will first work on listing all posts.

We add two new functions *getLatest and getPostsCount on the PostRepository

    public function getLatest(int $limit, int $offset = 0): array
    {
        $posts = [];
        
        foreach ($this->filenames->slice($offset, $limit) as $filename) {
            // Build $posts array
        }
        
        return $posts;
    }
    
    public function getCount(): int
    {
        return count($this->filenames);
    }

The first method takes a limit and an offset, allowing us to paginate the posts. We pass in a required parameter of limit. With an optional offset. Because it's just an array we will use the slice method to get the filenames we are looking for. Then we will transform the data later, and finally return the posts array. The second method just simply returns the count of the filenames array, we will later use this to paginate the posts on our pages.

Next, well create the method getPostData to get read each post. But first we will install league/commonmark package to read the Markdown file and convert to HTML.

composer require league/commonmark

Now to implement the getPostData method.

    use League\CommonMark\CommonMarkConverter;
    use League\CommonMark\Environment\Environment;
    use League\CommonMark\Extension\Attributes\AttributesExtension;
    use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
    ...
    
    public function getPostData(string $filename)
    {
        if (!str_contains('.md', $filename)) {
            $filename .= '.md';
        }
        $file = $this->postsDir . '/' . $filename;
        if (!$file_exists($file)) {
            // Do what you want when a file doesn't exist
        }
        
        $post = new Post();
        $post->setSlug(str_replace('.md', '', $filename));
        
        $env = new Environment([]);
        $env->addExtension(new AttributesExtension());
        $converter = new CommonMarkConverter([]);    
    
        $post->setBody($converter->convert(file_get_contents($file)));
        
        return $post;
    }

This method accepts a string of the filename, this can include or exclude the .md extension. We then check if the string doesn't contain .md and append it to the filename. The reason for this is how we are sending the filename as the slug. Next we get the file path and check if the file actually exists. Then creates a new Post where we will set the properties. The slug is set by replacing the .md extension. To convert the files markdown content we create a new Environment, then add any extensions we might want to use. Along with that we create a new CommomMarkConverter. We then use the converter to convert the files contents. This then returns the Post entity that we can now use.

Here is the new getLatest method

    public function getLatest(): array
    {
        $posts = [];
    
        foreach ($this->filenames->slice($offset, $limit) as $filename) {
            $posts[] = $this->getPostData($filename);
        }
    
        return $posts;
    }

Now, in my HomeController I call the getLatest method on the PostRepository and get the 5 latest posts.

    namespace App\Controller;
    
    use App\Repository\PostRepository;
    use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
    use Symfony\Component\HttpFoundation\Response;
    use Symfony\Component\Routing\Annotation\Route;
    
    class HomeController extends AbstractController
    {
        #[Route('/', name: 'home')]
        public function index(PostRepository $postRepository): Response
        {
            $posts = $postRepository->getLatest(5);
            
            return $this->render('home/index.html.twig', [
                'posts' => $posts
            ]);
        }
    }

Frontmatter

We are able to get the basic contents of a markdown file. But I don't want to display the whole HTML in the listings. Also I want to be able to categorize posts.

Enter frontmatter - a section of information at the top of the Markdown files were I could set any information.


---

title: Creating a Markdown Blog with Symfony 6
published: 2023-10-13
category: Symfony
excerpt: I walk through how I created this blog using Markdown files with Symfony 6

---

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

composer require spatie/yaml-front-matter

We now update the getPostData function to use YamlFrontMatter to parse the file's content We set the meta of the Post to be set to an array that contains all the fields. The body of the HTML is generated by the body of the parsed post instead of the whole file.

use Spatie\YamlFrontMatter\YamlFrontMatter;

...
	public function getPostData(string $filename)
	{
		if (!str_contains($filename, '.md')) {
			$filename .= '.md';
		}
		$file = $this->postsDir . '/' . $filename;
		if (!file_exists($file)) {
			// Do what you want when a file doesn't exist
		}
		$object = YamlFrontMatter::parse(file_get_contents($file));

		$post = new Post();
		$post->setSlug(str_replace('.md', '', $filename));
		$post->setMeta($object->matter());

		$env = new Environment([]);
		$env->addExtension(new GithubFlavoredMarkdownExtension());
		$env->addExtension(new AttributesExtension());
		$converter = new CommonMarkConverter([]);

		$post->setBody($converter->convert($object->body()));
		return $post;
	}

The files content is now passed directly to the YamlFrontMatter first. Then the meta is set to the objects matter. And the body HTML is being converted from the objects body now. With this we can now change what we display on our listing pages.

Adding Categories

Next, let take a look at how we will get all categories and filter our posts by them. We can make it so that you can either go to a new categories listing page or filter it on our all listings page.

I created a new controller of CategoryController

    class CategoryController extends AbstractController
    {
        #[Route('/posts/{category}', name: 'app_posts_category')]
        public function index(string $category): Response
        {
            return $this->render('category/index.html.twig', [
                'controller_name' => 'CategoryController',
            ]);
        }
    }

We then add a new property to our Post entity of category

...
class Post
{
...
    private ?string $category;
...
    public function getCategory(): ?string
    {
        return $this->category;
    }
    
    public function setCategory(string $category): self
    {
        $this->category = $category;
        return $this;
    }
}
...

Next I added a method to our PostRepository to get posts with our selected category, and also all categories in our files. I added three new methods, getCategory, getAllCategories and getAll. Also have to update the getPostData and set the category for each post

    ...
    public function getPostData(
        ...
    ) {
        ...
        $post->setMeta($object->matter());
        $post->setMeta($object->matter('category'));
    }
    ...
    private function getAll(): ArrayCollection
    {
        return $this->filenames->map(function ($filename) {
            return $this->getPostData($filename);
        });
    }
    
    public function getAllCategories(): array
	{
		$categories = $this->getAll()->map(function ($post) {
			return $post->getCategory();
		})->toArray();

		$categories = array_unique($categories);
		sort($categories);

        return $categories;
	}

	public function getCategory(string $category, int $limit, int $offset = 0): array
	{
		return $this->getAll()->filter(function ($post) use ($category) {
			return ucwords($post->getCategory()) === ucwords($category);
		})->slice($offset, $limit);
	}

The getAll method just loads in all the post data for all the posts, we will use this in other places so we make it private. getAllCategories uses the new method we just created to get an array of all the categories in our posts. Then I filter down with array_unique to only get the unique categories and then sort them alphabetically. The getCategory method accepts the category we are looking for, and then has our pagination parameters of limit and offset. It filters over all the posts again and returns only the posts where the category strictly matches our passed category and then slicing the array. This way works for now but I need to look at a more performant way later on.

Within the CategoryController I now pass the PostsRepository and get all the posts for the current category or redirect back to the index page, if there are no posts for the given category

    class CategoryController extends AbstractController
    {
        #[Route('/categories/{category}', name: 'app_posts_category')]
        public function index(string $category, PostRepository $postRepository): Response
        {
            $posts = $postRepository->getCategory($category, 5);
            if (count($posts) === 0) {
                return $this->redirectToRoute('home');
            }
            return $this->render('category/index.html.twig', [
                'category' => ucwords($category),
                'posts' => $posts
            ]);
        }
    }

Post Detail

Nice and Simple!

class PostController extends AbstractController
{
    #[Route('/posts/{slug}', name: 'app_post')]
    public function show(string $slug, PostRepository $postRepository): Response
    {
		$post = $postRepository->getPostData($slug);

        return $this->render('post/index.html.twig', [
            'post' => $post
        ]);
    }
}

Future Improvements

.....