How to Create a Markdown Blog in PHP With the Slim Framework

January 19, 2022
Written by
Reviewed by

How to Create a Markdown Blog in PHP With the Slim Framework

When it comes to blogging software, you're not starved for choice. However, despite this choice and how feature-rich modern blogging software is, are the available options necessarily the right choice?

Sure, software such as WordPress, Ghost, Gatsby, and Wix are very feature-rich — and their UIs are often very smooth. But do you want the hassle of installing, configuring, and securing them on top of writing your blog content? What's more, can you justify the budget that some blogging software requires?

Maybe, all you want to do is to write your site's content using your preferred editor, using a simple format designed for writing for the web, Markdown, rather than through a user interface.

If that’s the case, then in this tutorial, I’m going to show you how to create a blog that draws its content from Markdown files. The blog uses the Slim Framework (version 4), the Standard PHP Library (SPL), and several external packages, to keep the codebase as small and light as possible.

Let's begin!

Prerequisites

You need the following to follow this tutorial:

How the application will work

Before you dive in, let's quickly review how the application will work so that you know what to expect. At its simplest, the application works with two components:

  1. A Markdown file with YAML front-matter.
  2. A BlogItem entity that models the information in the Markdown file.

The Markdown file's YAML front-matter contains a minimal set of properties that you'd likely expect to see in a blog, including the publish date, slug to use as a link to the article, synopsis, title, an article image for rendering at the top of the article and for sharing on social media, and a set of tags and categories. The Markdown content is the article's content.

The application has two routes:

  1. The default route which displays a list of all available posts.
  2. A route that displays a blog post based on its slug.

When the application loads the default route, it iterates over each of the Markdown files in the application's data directory, creating an array of BlogItem entities, one for each Markdown file. Then, it renders an unordered list of the post titles, linked with the post's slug.

If the user clicks one of the posts in the list, they're taken to the view route, where a BlogItem entity is retrieved (if available) using the slug in the route's path, which is then rendered.

To simplify the process of iterating over the Markdown files, the application makes good use of Iterators from the Standard PHP Library (SPL), both existing and custom ones.

What are Iterators?

Iterators (such as the ArrayIterator, FilesystemIterator, LimitIterator, and RegexIterator) allow you to iterate over traversable objects, such as arrays and file systems, using native PHP looping constructs such as foreach and while loops.

Each iterator supports a particular kind of iteration, indicated by the class' name, such as only traversing arrays or directories, or limiting the number of items that are traversed.

For example, if you want to iterate over a file system, as the application will, you can use the DirectoryIterator. You can see how to use the DirectoryIterator in the example below.

<?php
$iterator = new DirectoryIterator(__DIR__ . '/data/posts');

/** @var SplFileInfo $item */
foreach ($iterator as $item) {
    echo $item->getFilename() . "\n";
}

The DirectoryIterator returns each item in the given directory as an SplFileInfo object. Using SplFileInfo, you can quickly determine if the current item is a file, directory, or a symlink, and if it’s readable, writable, or executable — all the kinds of things you’d need to know about common file system items.

The DirectoryIterator is powerful on its own because of all the code that it saves you from writing, but it becomes even more powerful when you combine it with other iterators.

Say you wanted to paginate over the items in the specified directory, instead of listing them all. To do that, you’d pass the DirectoryIterator to the LimitIterator, which allows for iterating a limited subset of items, and iterate over that instead.

The following example — which is virtually identical to the earlier version — shows how to iterate over the 10th - 20th records in the DirectoryIterator.

<?php
$iterator = new LimitIterator(new DirectoryIterator(__DIR__ . '/data/posts'), 10, 10);

/** @var SplFileInfo $item */
foreach ($iterator as $item) {
    echo $item->getFilename() . "\n";
}

There’s a lot more to iterators than I’ve covered here, but these are the essentials that you need to know, so that the code makes sense. If you'd like to dive deep into iterators, and the SPL, check out Mastering the SPL Library from php[architect].

Create the Markdown blog application

It's time to create the application. To do that, run the commands below to create the core directory and switch into it.

mkdir slim-framework-markdown-blog
cd slim-framework-markdown-blog

Next, create the application's directory structure, which will have four core directories:

  • data: This holds all the blog post Markdown files.
  • public: This holds the static assets and the bootstrap file, index.php.
  • resources: This holds the route view templates.
  • src: This holds the source files for the application.

To create them, and their subdirectories, run the command below.

mkdir -p \
    data/posts \
    public/{css,images} \
    resources/templates \
    src/Blog/{ContentAggregator,Entity,Iterator,Sorter}

If you’re using Microsoft Windows, run the commands below, instead.

md data\posts public/css ^
    data\posts public/images ^
    resources/templates  ^
    src/Blog/ContentAggregator ^
    src/Blog/Entity ^
    src/Blog/Iterator ^
    src/Blog/Sorter

Install the required packages

Now, it's time to install the external packages that the blog will use; specifically, five:

Package

Description

FrontYAML

This is an implementation of YAML Front matter for PHP. It can parse both YAML and Markdown.

PHP Markdown

The library includes a PHP Markdown parser and one for its sibling, PHP Markdown Extra.

PHP-DI

Marketed as "The dependency injection container for humans", PHP-DI is a pretty straightforward and intuitive DI container. You'll be using it to instantiate certain application resources once and then make them available to the application.

Slim Framework

Naturally, if you're basing the application on the Slim Framework, then you have to make it available.

Slim-PSR7

You're using this library to integrate PSR-7 into the application. It's not strictly necessary, but I feel it makes the application more maintainable and portable.

Slim Framework Twig View

You'll be using this package to render view content using Twig; such as the forms to request a verification code, and upload an image and the simpler views, such as the success output. You may have noticed that Twig isn't in this list. This is because Slim Framework Twig View requires it as a dependency.

Twig Intl Extension

This package is a Twig extension that provides Internationalisation support, including the ability to format dates, which the application will make use of.

To install them, run the command below in your terminal, in the root directory of the project.

composer require --with-all-dependencies \
    mnapoli/front-yaml \
    michelf/php-markdown \
    php-di/php-di \
    slim/psr7 \
    slim/slim \
    slim/twig-view \
    twig/intl-extra

Add a PSR-4 Autoloader configuration to composer.json

As you'll be creating a number of classes, you need to add a PSR-4 Autoloader configuration to composer.json so that the classes will be available to the application. To do that, add the JSON snippet below after the existing require section in composer.json.

"autoload": {
    "psr-4": {
        "MarkdownBlog\\": "src/Blog"
    }
},

Add the Slim Framework code

Now that the required packages are installed, add the core Slim Framework code, which you’ll progressively build on throughout the tutorial. To do that, create a new file, named index.php, in the public directory. Then, paste the code below into the new file.

<?php

declare(strict_types=1);

use DI\Container;
use Psr\Http\Message\{
    ResponseInterface as Response,
    ServerRequestInterface as Request
};
use Slim\Factory\AppFactory;
use Slim\Views\{Twig,TwigMiddleware};
use Twig\Extra\Intl\IntlExtension;

require __DIR__ . '/../vendor/autoload.php';

$container = new Container();
$container->set('view', function($c) {
    $twig = Twig::create(__DIR__ . '/../resources/templates');
    $twig->addExtension(new IntlExtension());
    return $twig;
});

AppFactory::setContainer($container);
$app = AppFactory::create();
$app->add(TwigMiddleware::createFromContainer($app));

$app->map(['GET'], '/', function (Request $request, Response $response, array $args) {
    return $this->get('view')->render($response, 'index.html.twig');
});

$app->run();

The code imports the required classes and sets up the application’s Dependency Injection (DI) container. One service is registered with the container, the application’s view layer, which is powered by Twig. The Twig object itself is initialised with the base path to the application’s templates, so that it knows where to find them.

After that, a new Slim application is initialised ($app). The default route is registered, which only accepts GET requests. Requests to the route won’t do much. It will return the rendered contents of its template. Finally, the application is launched, by calling its run() method.

Create the default route's template

The template isn’t going to do a lot at first; just display the site’s name: “Slim Framework Markdown Blog”.  First, create a new file in resources/templates, named index.html.twig and in it paste the code below.

<!doctype html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <link href="/css/styles.css" rel="stylesheet">
   <title>Slim Framework Markdown Blog</title>
</head>
<body>
    <h1>Slim Framework Markdown Blog</h1>
</body>
</html>

Then, download the CSS file to public/css, naming it styles.css. With that, let’s check that the core of the application works. Start the application by running the command below.

php -S 127.0.0.1:8080 -t public

Then, open http://localhost:8080 in your browser of choice, where you should see that it looks like the screenshot below.

The initial view of the Slim Framework Markdown blog&#x27;s default route

After confirming that the application works, press ctrl+c to stop the application.

Create a blog post entity

Now, you'll begin creating the code for the application, starting off with the BlogItem entity. Create a new file, named BlogItem.php in src/Blog/Entity and add the following code to it.

<?php

declare(strict_types=1);

namespace MarkdownBlog\Entity;

use DateTime;
use Michelf\MarkdownExtra;

class BlogItem
{
    private DateTime $publishDate;
    private string $slug = '';
    private string $title = '';
    private string $image = '';
    private string $synopsis = '';
    private string $content = '';
    private array $categories = [];
    private array $tags = [];

    public function __construct(array $options = [])
    {
        $this->populate($options);
    }

    public function populate(array $options = [])
    {
        $properties = get_class_vars(__CLASS__);
        foreach ($options as $key => $value) {
            if (array_key_exists($key, $properties) && !empty($value)) {
                $this->$key = ($key === 'publishDate')
                    ? new \DateTime($value)
                    : $value;
            }
        }
    }

    public function getPublishDate(): DateTime
    {
        return $this->publishDate;
    }

    public function getSlug(): string
    {
        return $this->slug;
    }

    public function getImage(): string
    {
        return $this->image;
    }

    public function getTitle(): string
    {
        return $this->title;
    }

    public function getContent(): string
    {
        $markdownParser = new MarkdownExtra();
        return $markdownParser->defaultTransform($this->content);
    }

    public function getTags(): array
    {
        return $this->tags;
    }

    public function getCategories(): array
    {
        return $this->categories;
    }

    public function getSynopsis(): string
    {
        return $this->synopsis ?? '';
    }
}

The class doesn’t do a whole lot. It sets eight properties ($publishDate, $slug, $title, $image, $synopsis, $content, $categories, and $tags) which will store an aspect of data from a Markdown file. 

The populate method populates, or hydrates, the entity from an associative array of data, and each of the respective properties can be accessed through an accompanying getter method.

The only things worth noting are that $publishDate is initialised as a DateTime object, using the value provided, and getContent converts its Markdown content to HTML before returning it.

Create a filter for Markdown files

Now, it's time to create the MarkdownFileFilterIterator which ContentAggregatorFilesystem made use of, while iterating over the data/posts directory to filter out files that weren't Markdown files. To save time and effort, MarkdownFileFilterIterator will extend FilterIterator, one of the many iterators available as part of the Standard PHP Library (SPL).

If you'd like to dive deep into the SPL, check out Mastering the SPL Library from php[architect].

Why extend the FilterIterator? Well, as the blog sources its content from Markdown files, it needs to iterate over one or more of them. However, a given directory may contain any number of file types, such as text files, AsciiDoc files, spreadsheets, and more, not just Markdown files.

Without the FilterIterator, you'd end up with a rather verbose iteration process, one that checks if a file was a file and not a directory, as well if it's a Markdown file, not a spreadsheet, symlink, etc. To me, that's all rather messy when done that way.

However, by extending the FilterIterator, you can move the file filter logic to a reusable class, allowing the iteration logic to be reused instead of being duplicated throughout your codebase, separating it from — drastically simplifying — the iteration logic.

Create a new PHP file, MarkdownFileFilterIterator.php, in src/Blog/Iterator, and paste the code below into it.

<?php

declare(strict_types=1);

namespace MarkdownBlog\Iterator;

use \DirectoryIterator;
use \SplFileInfo;

class MarkdownFileFilterIterator extends \FilterIterator
{
    public function __construct(DirectoryIterator $iterator)
    {
        parent::__construct($iterator);
        $this->rewind();
    }

    public function accept(): bool
    {
        /** @var SplFileInfo $item */
        $item = $this->getInnerIterator()->current();

        if (!$item instanceof SplFileInfo) {
            return false;
        }

        if ($item->isDot() || !$item->isFile() || !$item->isReadable()) {
            return false;
        }

        if (!in_array($item->getExtension(), ['md', 'markdown'])) {
            return false;
        }

        return true;
    }
}

The class' constructor takes a DirectoryIterator, which iterates over a single directory and any files which it contains, and sets it as the class' inner iterator. The DirectoryIterator will also iterate over any directories in the specified directory, but won't descend into any one of them.

The accept method is where the magic happens. There, it checks if the current item is an SplFileInfo instance. This is taken care of for us by using the DirectoryIterator. It then (rather verbosely) filters out any file that:

  • Is a dotfile or a directory.
  • Is not readable.
  • Doesn't have an extension of either .md or .markdown.

Create a content aggregator to aggregate the Markdown content

Next, you’re going to create a content aggregator, composed of an interface (ContentAggregatorInterface) and two classes (ContentAggregatorFilesystem, ContentAggregatorFactory), which iterates over all the supplied Markdown files and hydrates an array of BlogItem entities for each one.

The ContentAggregatorInterface

In src/Blog/ContentAggregator, create a new file named ContentAggregatorInterface.php, and add the code below to it.

<?php

declare(strict_types=1);

namespace MarkdownBlog\ContentAggregator;

use MarkdownBlog\Entity\BlogItem;

interface ContentAggregatorInterface
{
    public function findItemBySlug(string $slug): ?BlogItem;
    public function getItems(): array;
}

This interface defines two methods, getItems and findItemBySlug which ContentAggregatorFilesystem will implement. It isn’t strictly necessary, however, I prefer to code against an interface, not a concrete specification.

The ContentAggregatorFilesystem

Next, create a second new file in src/Blog/ContentAggregator, named ContentAggregatorFilesystem.php, and add the code below to it.

<?php

namespace MarkdownBlog\ContentAggregator;

use MarkdownBlog\Iterator\MarkdownFileFilterIterator;
use MarkdownBlog\Entity\BlogItem;
use Mni\FrontYAML\Document;
use Mni\FrontYAML\Parser;

class ContentAggregatorFilesystem implements ContentAggregatorInterface
{
    protected Parser $fileParser;
    protected MarkdownFileFilterIterator $fileIterator;
    private array $items = [];

    public function __construct(
        MarkdownFileFilterIterator $fileIterator,
        Parser $fileParser
    ) {
        $this->fileParser = $fileParser;
        $this->fileIterator = $fileIterator;

        $this->buildItemsList();
    }

    public function getItems(): array
    {
        return $this->items;
    }

    protected function buildItemsList(): void
    {
        foreach ($this->fileIterator as $file) {
            $article = $this->buildItemFromFile($file);
            if (! is_null($article)) {
                $this->items[] = $article;
            }
        }
    }

    public function findItemBySlug(string $slug): ?BlogItem
    {
        foreach ($this->items as $article) {
            if ($article->getSlug() === $slug) {
                return $article;
            }
        }

        return null;
    }

    public function buildItemFromFile(\SplFileInfo $file): ?BlogItem
    {
        $fileContent = file_get_contents($file->getPathname());
        $document = $this->fileParser->parse($fileContent, false);

        $item = new BlogItem();
        $item->populate($this->getItemData($document));

        return $item;
    }

    public function getItemData(Document $document): array
    {
        return [
            'publishDate' => $document->getYAML()['publish_date'] ?? '',
            'slug' => $document->getYAML()['slug'] ?? '',
            'synopsis' => $document->getYAML()['synopsis'] ?? '',
            'title' => $document->getYAML()['title'] ?? '',
            'image' => $document->getYAML()['image'] ?? '',
            'categories' => $document->getYAML()['categories'] ?? [],
            'tags' => $document->getYAML()['tags'] ?? [],
            'content' => $document->getContent(),
        ];
    }
}

The class implements ContentAggregatorInterface, and is responsible for aggregating blog content from files on the local file system. It's reasonably long, so let's step through it a piece at a time.

public function __construct(
    MarkdownFileFilterIterator $fileIterator,
    Parser $fileParser
) {
    $this->fileIterator = $fileIterator;
    $this->fileParser = $fileParser;

    $this->buildItemsList();
}

The class’ constructor takes two parameters, a MarkdownFileFilterIterator and a \Mni\FrontYAML\Parser object. The first is an iterator that allows for rapid traversal of the available Markdown data files. The second provides the functionality to parse the relevant information from those files.

After two class member variables are initialised from the two parameters, the buildItemsList method is called to aggregate the blog item data from the Markdown files.

protected function buildItemsList(): void
{
    foreach ($this->fileIterator as $file) {
        $article = $this->buildItemFromFile($file);
        if (! is_null($article)) {
            $this->items[] = $article;
        }
    }
}

public function getItems(): array
{
    return $this->items;
}

Next, two functions are defined: buildItemsList and getItems:

  • buildItemsList is where the iteration over the Markdown files using the MarkdownFileFilterIterator happens. For each file in the iterator, a BlogItem object is initialised by passing the file to the buildItemFromFile method, which is then added to the items array.
  • getItems returns the list of BlogItem objects aggregated in buildItemsList.
public function findItemBySlug(string $slug): ?BlogItem
{
    foreach ($this->items as $article) {
        if ($article->getSlug() === $slug) {
            return $article;
        }
    }
    return null;
}

The next function, findItemBySlug, takes an article slug, searches through the available BlogItem objects for one with a matching slug, and returns it if found. Otherwise, it returns null.

public function buildItemFromFile(\SplFileInfo $file): ?BlogItem
{
    $fileContent = file_get_contents($file->getPathname());
    $document = $this->fileParser->parse($fileContent, false);

    $item = new BlogItem();
    $item->populate($this->getItemData($document));

    return $item;
}

buildItemFromFile takes an SplFileInfo object, and uses it to retrieve the contents of a file, which is then passed to the getItemData method. This method parses out the YAML front-matter and Markdown content into an array, which is then used to hydrate and return a new BlogItem object.

public function getItemData(Document $document): array
{
    return [
        'publishDate' => $document->getYAML()['publish_date'] ?? '',
        'slug' => $document->getYAML()['slug'] ?? '',
        'synopsis' => $document->getYAML()['synopsis'] ?? '',
        'title' => $document->getYAML()['title'] ?? '',
        'image' => $document->getYAML()['image'] ?? '',
        'categories' => $document->getYAML()['categories'] ?? [],
        'tags' => $document->getYAML()['tags'] ?? [],
        'content' => $document->getContent(),
    ];
}

getItemData takes an \Mni\FrontYAML\Document object, which contains both YAML front-matter and Markdown content. This object is used to populate and return an associative array of information aggregated from the file.

The ContentAggregatorFactory

Finally, create a third new file in src/Blog/ContentAggregator, named ContentAggregatorFactory.php, and add the code below to it.

<?php

declare(strict_types=1);

namespace MarkdownBlog\ContentAggregator;

use MarkdownBlog\Iterator\MarkdownFileFilterIterator;

class ContentAggregatorFactory
{
    public function __invoke(array $config): ContentAggregatorInterface
    {
        $iterator = new MarkdownFileFilterIterator(
            new \DirectoryIterator($config['path'])
        );
        return new ContentAggregatorFilesystem($iterator, $config['parser']);
    }
}

The class’ __invoke method instantiates a MarkdownFileFilterIterator with a DirectoryIterator, that in turn is instantiated with the path to the Markdown files directory. The MarkdownFileFilterIterator and a \Mni\FrontYAML\Parser object are then used to instantiated and return a ContentAggregatorFilesystem object.

Update Composer’s autoloader

Now that the core classes have been created, you need to update Composer’s autoloader, so that they’ll be found. To do that, run the following command in the project’s root directory.

composer dump-autoload

Register the content aggregation service with the DI container

With the classes and interface ready, you now need to register a new service with the DI container, so that you can access the aggregated content throughout the application. To do that, in public/index.php, update the code up to the definition of the first route as highlighted in the code example below.

hl_lines="6 7 25 26 27 28 29 30 31"
<?php

declare(strict_types=1);

use DI\Container;
use MarkdownBlog\ContentAggregator\ContentAggregatorFactory;
use MarkdownBlog\ContentAggregator\ContentAggregatorInterface;
use Mni\FrontYAML\Parser;
use Psr\Http\Message\{
    ResponseInterface as Response,
    ServerRequestInterface as Request
};
use Slim\Factory\AppFactory;
use Slim\Views\{Twig,TwigMiddleware};
use Twig\Extra\Intl\IntlExtension;

require __DIR__ . '/../vendor/autoload.php';

$container = new Container();
$container->set('view', function($c) {
        $twig = Twig::create(__DIR__ . '/../resources/templates');
        $twig->addExtension(new IntlExtension());
        return $twig;
});
$container->set(
    ContentAggregatorInterface::class,
    fn() => (new ContentAggregatorFactory())->__invoke([
        'path' => __DIR__ . '/../data/posts',
        'parser' => new Parser(),
    ])
);

AppFactory::setContainer($container);
$app = AppFactory::create();
$app->add(TwigMiddleware::createFromContainer($app));

The changes register a new service which will be a ContentAggregatorFilesystem object, initialised by ContentAggregatorFactory with ContentAggregatorInterface::class as the key.

Create a route to view all blog posts

The next thing to do is to refactor the default route so that it renders the blog items. To do that, first, update the default route in public/index.php to match the code example below.


$app->map(['GET'], '/', function (Request $request, Response $response, array $args) {
    $view = $this->get('view');
    /** @var ContentAggregatorInterface $contentAggregator */
    $contentAggregator = $this->get(ContentAggregatorInterface::class);
    return $view->render(
        $response,
        'index.html.twig',
        ['items' => $contentAggregator->getItems()]
    );
});

The updates retrieve the ContentAggregatorInterface service from the DI container and set a view template item, items, that contains a list of BlogItem objects.

Update the default route's template to render blog post items

As the default route's template now has access to a list of blog post items, it needs to be updated to render them. To do that, update resources/templates/index.html.twig to match the code below.


<!doctype html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <link href="/css/styles.css" rel="stylesheet">
   <title>Slim Framework Markdown Blog</title>
</head>
<body>
<h1>Slim Framework Markdown Blog</h1>
<h2>Items</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-4 sm:gap-y-8">
    {% for item in items %}
        <div>
            <img src="/images/posts/{{ item.image }}" class="rounded-lg">
            <a class="no-underline" href="/item/{{ item.slug }}"><h3>{{ item.title }}</h3></a>
            {{ item.publishDate|format_datetime(pattern="dd.MM.Y") }}
        </div>
    {% endfor %}
</div>
<footer>
   <p>&copy; Matthew Setter. <a href="/">Impressum</a>. <a href="/">Privacy Policy</a>. <a href="/">Terms of Use</a>.
      <a href="/">Disclaimer</a> </p>
</footer>
</body>
</html>

The template now uses a for loop to iterate over the available blog post items (items), rendering their title, slug, and publish date (formatted using Twig's format_date function). The post item's image, in the public/images/posts directory, is also rendered using the post's image value.

Create a route to view an individual blog post

In the changes to the default route's template, the list wrapped each item's title in an anchor tag which linked to /item{{ item.slug }}, that might render as /item/hello-world, for example.

Given that, you need to create a route that can handle these requests. To do that, add a second route, under the default one, in public/index.php, with the code below.

$app->map(['GET'], '/item/{slug}', function (Request $request, Response $response, array $args) {
    $view = $this->get('view');
    /** @var ContentAggregatorInterface $contentAggregator */
    $contentAggregator = $this->get(ContentAggregatorInterface::class);
    return $view->render(
        $response,
        'view.html.twig',
        ['item' => $contentAggregator->findItemBySlug($args['slug'])]
    );
});

Similar to the default route, the view item route retrieves the ContentAggregatorInterface and view services from the application's DI container. It then retrieves a BlogItem object by calling the content aggregator's findItemBySlug method, passing to it the item's slug retrieved from the request. The BlogItem is then passed as a view template variable, and the template is rendered and returned.

Create the view route's template

Next, you have to create the route's view template. To do that, create a new file in resources/templates named view.html.twig, and paste the code below into it.


<!doctype html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <link href="/css/styles.css" rel="stylesheet">
   <title>{{ item.title }} | Slim Framework Markdown Blog</title>
</head>
<body>
<header class="grid grid-cols-2 gap-2 mb-2 md:mb-4">
   <div class="text-left">
      <a href="/" 
        class="text-sm md:text-base text-stone-800 font-bold no-underline hover:underline transition ease-in-out delay-150 duration-300 underline-offset-4 decoration-2"
            >Slim Framework Markdown Blog</a>
   </div>
   <div class="text-right">
      <a href="/" title="Back to the home page" 
        class="text-sm md:text-base text-stone-500 hover:underline hover:text-stone-600"
            >&larr; Back to the home page</a>
   </div>
</header>
<div class="content border-t-4 border-slate-700 pt-2 md:pt-6 mt-4">
   <h1 class="mb-0 pb-0">{{ item.title }}</h1>
    <p class="border-slate-700 font-bold mt-2 pt-0 pb-2 md:pb-2 mb-2">
        By Matthew Setter, {{ item.publishDate|format_datetime(pattern="dd.MM.Y") }}
    </p>
    <img src="/images/posts/{{ item.image }}" class="rounded-lg mb-4">
    <p class="synopsis">{{ item.synopsis }}</p>
    {{ item.content|raw }}
</div>
<footer>
   <p>&copy; Matthew Setter. <a href="/">Impressum</a>. <a href="/">Privacy Policy</a>. <a href="/">Terms of Use</a>.
      <a href="/">Disclaimer</a> </p>
</footer>
</body>
</html>

It’s quite similar to the default route’s template. The main differences are that it displays the details of one blog item, and prefixes the page’s title with the blog item’s title.

Create the Markdown source files

Now, create an initial blog post, so that there’ll be some content to iterate over and view. Create a new file named hello-world.md in data/posts, and add the following content to it.

---
publish_date: 10.01.2022
slug: hello-world
synopsis: Hello and welcome to the blog. In this, the first post, I'll step you through what the blog is about and all the awesome things you're going to learn about by reading it.
title: Hey! Welcome to the blog!
image: hello-world.png
categories:
  - General
tags:
  - Getting Started
---
# Hello world!
Welcome to the Slim Framework Markdown Blog. This is your first post. Edit or delete it, then **start writing!**

Feel free to change the YAML front-matter and post's Markdown content as you see fit. Next, download a set of pre-made Markdown files to the data/posts directory, and a set of post images to the public/images/posts directory, so that the application is more like a live version.

Test the application

Now that you have all the code in place, before testing that the application works, start it, by running the command below

php -S 127.0.0.1:8080 -t public

Then, open http://localhost:8080 in your browser of choice. This time, you should see a list with one or more blog posts, like the screenshot below.

If you click on the post’s name, you’ll be able to view the post, which will look like the screenshot below.

A web browser displaying a yellow page with multiple blog posts formatted into a grid.

Create a sorter to sort the posts in reverse date order

While the application works, you likely noticed that the order in which the posts render is based on the Markdown file names, not based on the post's publish date. That's not a logical, nor intuitive thing to do. Let's improve the application to sort the posts from newest to oldest, as you'd expect on a modern blog.

To do that, you're going to create a class which we can pass to PHP's usort function. If you're not familiar with the function, it iterates over and array and sorts it based on a custom callback.

The callback compares the current and next element in the array and returns an integer less than, equal to, or greater than zero if the first argument is considered to be respectively less than, equal to, or greater than the second. The point of comparison for the two blog posts will be their publish date.

Create a new file named SortByReverseDateOrder.php in src/Sorter. Then, in that file, paste the code below.

<?php

declare(strict_types=1);

namespace MarkdownBlog\Sorter;

use MarkdownBlog\Entity\BlogItem;

class SortByReverseDateOrder
{
    public function __invoke(BlogItem $a, BlogItem $b): int
    {
        $firstDate = $a->getPublishDate();
        $secondDate = $b->getPublishDate();

        if ($firstDate == $secondDate) {
            return 0;
        }

        return ($firstDate > $secondDate) ? -1 : 1;
    }
}

The class is a callable, as it implements the __invoke magic method. The method takes two BlogItem objects and compares them based on their publish dates. Here's how the result of the comparison will work:

  • If the publish dates are the same, the method returns 0 because the order doesn't need to change.
  • If the publish date of the first one is greater than that of the second, the method returns -1 as the first blog post needs to be sorted before the second.
  • Otherwise, the method returns 1, as the second blog post needs to be sorted before the first.

With the classes ready to use, update the body of the first route in public/index.php to match the code below. I've highlighted the lines that need to be changed.


$app->map(['GET'], '/', function (Request $request, Response $response, array $args) {
    $view = $this->get('view');
    /** @var ContentAggregatorInterface $contentAggregator */
    $contentAggregator = $this->get(ContentAggregatorInterface::class);
    $items = $contentAggregator->getItems();
    $sorter = new \MarkdownBlog\Sorter\SortByReverseDateOrder();
    usort($items, $sorter);
    return $view->render(
        $response,
        'index.html.twig',
        ['items' => $items]
    );
});

In this version, the aggregated items are first retrieved, and then sorted by passing them, along with a SortByReverseDateOrder instance, to PHP’s usort method. Then, the sorted items are passed as a view variable.

If you open the app again, you’ll see that the list of posts is now sorted in reverse date order, as in the screenshot below.

A web browser displaying a yellow page with multiple blog posts sorted by publish date.

Create a filter for published posts

It’s time for one, final, class, one that filters out any posts that are scheduled at some time in the future, named PublishedItemFilterIterator. This one will compare today’s date with a blog item’s publish date, and return false if the publish date is in the future. In src/Iterator, create a new file, named  PublishedItemFilterIterator.php and paste the code below into it.

<?php

declare(strict_types=1);

namespace MarkdownBlog\Iterator;

use DateTime, Iterator;
use MarkdownBlog\Entity\BlogItem;

class PublishedItemFilterIterator extends \FilterIterator
{
    public function __construct(Iterator $iterator)
    {
        parent::__construct($iterator);
        $this->rewind();
    }

    public function accept(): bool
    {
        /** @var BlogItem $episode */
        $episode = $this->getInnerIterator()->current();

        return $episode->getPublishDate() <= new DateTime();
    }
}

Update the default route to view published posts

Next, in public/index.php, replace the default route with the version below.


$app->map(['GET'], '/', function (Request $request, Response $response, array $args) {
    $view = $this->get('view');
    /** @var ContentAggregatorInterface $contentAggregator */
    $contentAggregator = $this->get(ContentAggregatorInterface::class);
    $sorter = new \MarkdownBlog\Sorter\SortByReverseDateOrder();
    $items = $contentAggregator->getItems();
    usort($items, $sorter);
    $iterator = new \MarkdownBlog\Iterator\PublishedItemFilterIterator(
        new ArrayIterator($items)
    );
    return $view->render(
        $response,
        'index.html.twig',
        ['items' => $iterator]
    );
});

In the highlighted lines, you can see that after the array has been sorted, it is used to initialise a new ArrayIterator, which, in turn, is used to initialised a PublishedItemFilterIterator ($iterator). $iterator is then passed to the rendered template as the source of the blog post items.

If you open the app again, you’ll see that no posts with publish dates in the future are now visible, as in the screenshot below.

That's how to create a Markdown blog with PHP

A web browser displaying a blog without blog posts with an upcoming publish date.

Using the Slim Framework, the Standard PHP Library (SPL), and two external packages, you've just learned how simple it can be to create a Markdown blog in PHP.

As it draws its content from Markdown files with YAML front-matter, you don't need to learn a new interface to use it. Rather, you can start writing your blog’s content straight away, just by storing Markdown files in the data/posts directory. Go forth, write, and enjoy.

If you'd like to see a complete version of the code, you can find it on GitHub.

One last thing. The Slim framework doesn't get as much attention as frameworks such as Laravel and Symfony — nor is it backed by a commercial organization. So if you use the Slim framework, please consider supporting it via Open Collective or Tidelift. You can also follow them on Twitter.

Matthew Setter is a PHP Editor in the Twilio Voices team and (naturally) a PHP developer. He’s also the author of Docker Essentials. When he’s not writing PHP code, he’s writing and editing great PHP articles here at Twilio. You can find him at msetter@twilio.com, Twitter, and GitHub.