Blogging with markdown in Laravel

April 8, 2021

Like every good developer, I've rebuilt my personal site many, many times over the years. Way too many times. I've finally settled on a stack that is fast, simple, and (I think) beautiful.

My setup is extremely straightforward: it's a plain 'ol Laravel app, markdown files, and the league/commonmark extension. No database, no JavaScript.

It's hosted on Laravel Vapor so I never even have to worry about servers. It's all deployed straight from a GitHub action, so I can just write a post and git push and it's live to the world.

I used Tighten's Jigsaw for a while and really liked it. I used to build and push the whole site to a static S3 bucket, but ever since Vapor came around I don't really worry about hosting anymore so I prefer to not have a build step and the full power of Laravel if I need it.

Writing the Articles

All of my articles are stored as markdown files in the resources/views/articles directory.

I write them all in standard markdown. For example, here's the previous section up until now:

## Writing the Articles
 
All of my articles are stored as markdown files in the `resources/views/articles` directory.
 
![Screenshot of my filesystem](/images/articles/markdown.png){.mx-auto .max-w-100 .rounded .border .shadow}
 
I write them all in standard markdown. For example, here's the previous section up until now:
Code highlighting powered by torchlight.dev (A service I created!)

I use a couple of markdown extensions (covered below), but the bulk of the work is done by the league/commonmark package. I use Graham Campbell's wrapper around it, because it gives you a few nice Laravel-specific options.

Storing Articles in Sushi

As I mentioned above, I have no database on this site. It's very nice because it keeps everything stupidly simple. I do like being able to work with the posts as an Eloquent collection though, so I've pulled in Caleb Porzio's Sushi pacakge, which bills itself as "Eloquent's missing array driver."

Sushi is great because you just define a rows property, fill it up with your records, and then you can do your normal Eloquent operations on it like Article::first().

Here's the Article.php from this app (rows 23 - 139 are collapsed, click to expand):

app/Article.php

<?php
namespace App;
 
use Illuminate\Database\Eloquent\Model;
 
class Article extends Model
{
use \Sushi\Sushi;
 
protected $casts = [
'date' => 'date',
];
 
protected static function boot()
{
parent::boot();
 
static::addGlobalScope('order', function ($query) {
$query->orderBy('date', 'desc');
});
}
 
protected $rows = [[ ...
'title' => 'Carving the Statue of David',
'date' => '2013-01-30',
'path' => '2013/carving-the-statue',
'description' => '',
], [
'title' => 'Idea Extraction Gone Terribly Wrong',
'date' => '2013-02-06',
'path' => '2013/idea-extraction-gone-terribly-wrong',
'description' => '',
], [
'title' => 'How I Use Evernote and IFTTT',
'date' => '2013-02-26',
'path' => '2013/how-i-use-evernote-and-ifttt',
'description' => '',
], [
'title' => 'Yii, Heroku, and The Asset Pipeline',
'date' => '2013-04-09',
'path' => '2013/yii-heroku-and-the-asset-pipeline',
'description' => '',
], [
'title' => 'Importing Transactions Into Mint',
'date' => '2013-04-11',
'path' => '2013/importing-transactions-into-mint',
'description' => '',
], [
'title' => 'Wrangling Timezones in PHP, MySQL, and Yii',
'date' => '2013-04-21',
'path' => '2013/wrangling-timezones-in-php-mysql-and-yii',
'description' => '',
], [
'title' => 'Automating Yii Migrations on Heroku',
'date' => '2013-05-02',
'path' => '2013/automate-yii-migrations-in-heroku',
'description' => '',
], [
'title' => 'Scheduling Jobs With Yii',
'date' => '2013-05-06',
'path' => '2013/scheduling-jobs-with-yii',
'description' => '',
], [
'title' => 'Yii and the Asset Pipeline: Part 2',
'date' => '2013-06-19',
'path' => '2013/yii-and-the-asset-pipeline-part-2',
'description' => '',
], [
'title' => 'Using MySQL Triggers to Ensure Immutability',
'date' => '2013-06-26',
'path' => '2013/using-mysql-triggers-to-ensure-immutability',
'description' => '',
], [
'title' => 'Encrypting and Encoding Information in URLs with PHP',
'date' => '2013-09-26',
'path' => '2013/encrypting-and-encoding-information-in-urls-with-php',
'description' => '',
], [
'title' => 'Remaking "Snake" In Excel',
'date' => '2013-07-09',
'path' => '2013/remaking-cellphone-snake-in-microsoft-excel',
'description' => '',
], [
'title' => 'My Aperture Backup Strategy',
'date' => '2013-09-09',
'path' => '2013/my-aperture-backup-strategy',
'description' => '',
], [
'title' => 'Hosting An Advanced Yii2 App on Heroku',
'date' => '2014-01-10',
'path' => '2014/hosting-an-advanced-yii2-application-on-heroku',
'description' => '',
], [
'title' => 'Introducing Vue Model',
'date' => '2016-06-08',
'path' => '2016/introducing-vue-model',
'description' => '',
], [
'title' => 'Laravel Pseudo-Daemons',
'date' => '2020-05-17',
'path' => '2020/laravel-pseudo-daemons',
'description' => "The thought of adding each new daemon in Forge, making sure it was killed on deploy, and communicating to the team which commands were daemons sounded like something I didn't want to take on.",
], [
'title' => 'Realtime Spreadsheets with Laravel',
'date' => '2020-08-27',
'path' => '2020/laravel-realtime-spreadsheets',
'description' => 'An article about using Laravel together with Handsontable.',
], [
'title' => 'Ensuring Consistent Polymorphic Relationships with Laravel',
'date' => '2020-10-26',
'path' => '2020/laravel-consistent-morphs',
'description' => "Tips to ensure consistency when using Laravel's polymorphic relationships.",
], [
'title' => 'Handling Large CSVs with Laravel',
'date' => '2020-10-28',
'path' => '2020/large-csvs-with-laravel',
'description' => "Strategies I've developed over the past couple of years that I think might be helpful if you're handling large CSVs.",
], [
'title' => 'Building a Shedquarters in My Backyard',
'date' => '2021-01-20',
'path' => 'shed',
'description' => 'Building a shedquarters in my backyard during a pandemic.',
], [
'title' => 'Is Making Software Sisyphean?',
'date' => '2021-01-14',
'path' => '2021/is-making-software-sisyphean',
'description' => 'Sisyphus, cursed by Zeus to roll a boulder up a hill for all eternity. Is that us?',
], [
'title' => 'Fixing "Laravel PackageManifest.php: Undefined index: name" in GitHub Actions',
'date' => '2021-01-26',
'path' => '2021/fixing-laravel-package-manifest-php-undefined-index-name',
'description' => 'Fixing an error caused by Composer 2.',
],[
'title' => 'Reliably Building Frontend Assets With NVM and Yarn (or NPM)',
'date' => '2021-02-24',
'path' => '2021/reliably-building-frontend-assets-with-nvm-and-yarn-or-npm',
'description' => "Generating reproducible asset bundles without losing your mind."
], [
'title' => 'Blogging with Markdown in Laravel',
'date' => '2021-04-08',
'path' => '2021/blogging-with-markdown-in-laravel',
'description' => "Writing beautiful and simple blogs with Markdown in Laravel."
]];
 
public function getYearAttribute()
{
$year = $this->date->format('Y');
 
if ($year < 2020) {
return 'V old';
}
 
return $year;
}
}

Routing

To route web requests to the articles, I have an extremely open catch-all route at the end of my web.php file:

web.php

Route::get('/{any?}', 'ViewController@show')->where([
'any' => '.+',
]);

This is my last defined route, and it matches basically anything. It sends requests into a ViewController that checks within the views folder to see if there is a *.blade.php file that matches the request, and if not, it will check the articles folder to see if it finds one there.

ViewController.php

<?php
 
namespace App\Http\Controllers;
 
use App\Article;
 
class ViewController extends Controller
{
protected $disk;
 
public function __construct()
{
$this->disk = Storage::disk('views');
}
 
public function show($view = 'index')
{
if ($this->disk->exists($view.'.blade.php')) {
return view($view, [
'view' => $view,
]);
}
 
return $this->article($view);
}
 
public function article($view)
{
$article = Article::where('path', $view)->first();
 
if (!$article) {
return 0;
}
 
return view('_post', [
'view' => 'articles/' . $view,
'article' => $article,
'title' => $article->title,
'date' => $article->date,
]);
}
}

See that we can use standard Eloquent methods (->first()) against an array of records, thanks Sushi!

Heading Links

You may be familiar with the little hashtags # next to each header from either GitHub READMEs, Laravel's documentation, or any number of other places. These function as anchor links so that you can link to a particular part of a document.

Go ahead, try clicking the one a few lines up!

These are fantastic to have, but a pain to manually keep up to date, so I use a commonmark extension for this.

In my markdown.php file, I have several extensions defined, but the one we want to look at right now is the HeadingPermalinkExtension.

config/markdown.php

'extensions' => [
// Torchlight syntax highlighting
TorchlightExtension::class,
 
// The location of our static assets varies based on wherever
// Vapor puts them. This will point them to the right spot.
VaporAssetWrapping::class,
 
// Add `#` and links to headers.
HeadingPermalinkExtension::class,
 
// Add attributes straight from markdown.
AttributesExtension::class
],
 
'heading_permalink' => [
'html_class' => 'permalink',
'id_prefix' => 'user-content',
'insert' => 'before',
'title' => 'Permalink',
'symbol' => '#',
],

This extension is one of the default ones provided by The League itself, so there is nothing else to install.

You can configure the symbol, along with several other attributes just by passing through your preferred configuration into heading_permalink.

As for styling, I just went through and manually adjusted the margin so that they would all line up nicely:

app.css

.permalink {
@apply text-gray-300 absolute font-light no-underline;
 
&:hover {
@apply text-gray-600;
}
}
 
h1 .permalink { margin-left: -33px; }
h2 .permalink { margin-left: -28px; }
h3 .permalink { margin-left: -22px; }
h4 .permalink { margin-left: -22px; }
h5 .permalink { margin-left: -19px; }
h6 .permalink { margin-left: -18px; }
Code highlighting powered by torchlight.dev (A service I created!)

Code Highlighting

For code highlighting, I'm using a service that I built called Torchlight.

Over the years I've tried Prism JS, Highlight JS, along with a few other that I've since forgotten. I finally decided to build my own exactly the way I wanted, once and for all.

There were a lot of things I wanted from a highlighter, and Torchlight has it all:

  • Uses the VS Code tokenizer, so it gets everything right, even the wacky stuff.
  • Supports all VS Code themes, even random ones you find online
  • Requires no JavaScript
  • Requires no external CSS
  • Is lightning fast
  • Supports git-style diffing
  • Supports line numbers, line highlight, line focusing
  • Supports collapsing code blocks, again, without JavaScript

All it takes to set up is to get an API token and install the commonmark extension.

config/markdown.php

'extensions' => [
// Torchlight syntax highlighting
TorchlightExtension::class,
 
// The location of our static assets varies based on wherever
// Vapor puts them. This will point them to the right spot.
VaporAssetWrapping::class,
 
// Add `#` and links to headers.
HeadingPermalinkExtension::class,
 
// Add attributes straight from markdown.
AttributesExtension::class
],

That's it! No JavaScript to set up, no CSS theme files to add, nothing.

If you want to change the theme, you can do so in the config file:

config/torchlight.php

<?php
 
return [
'theme' => 'material-theme-palenight',
'theme' => 'nord',
 
// Your API token from torchlight.dev.
'token' => env('TORCHLIGHT_TOKEN'),
 
// If you want to register the blade directives, set this to true.
'blade_directives' => true,
];

Images on Laravel Vapor

This section only applies if you're hosting your blog on Laravel Vapor (which you should be!) When using Vapor, the path of your static assets changes on each deploy. Laravel gives us a nice asset() helper to solve for this, but when you're writing in markdown you don't have access to that.

Fortunately it's very easy to write a commonmark extension.

In my case, I have a commonmark extension that looks for images with relative paths, and then uses the asset helper to update them to the right path.

VaporAssetWrapping.php

<?php
class VaporAssetWrapping implements ExtensionInterface
{
public function register(ConfigurableEnvironmentInterface $environment)
{
$environment->addEventListener(DocumentParsedEvent::class, [$this, 'onDocumentParsed']);
}
 
public function onDocumentParsed(DocumentParsedEvent $event)
{
$walker = $event->getDocument()->walker();
 
while ($event = $walker->next()) {
$node = $event->getNode();
 
if (!$node instanceof Image || !$event->isEntering()) {
continue;
}
 
if (Str::startsWith($node->getUrl(), '/')) {
$node->setUrl(asset($node->getUrl()));
}
}
}
}

Again, you can just add that to your markdown.php and now all your assets will work on Vapor.

config/markdown.php

'extensions' => [
// Torchlight syntax highlighting
TorchlightExtension::class,
 
// The location of our static assets varies based on wherever
// Vapor puts them. This will point them to the right spot.
VaporAssetWrapping::class,
 
// Add `#` and links to headers.
HeadingPermalinkExtension::class,
 
// Add attributes straight from markdown.
AttributesExtension::class
],

Extra Attributes

The last commonmark extension I use is one that lets me add attributes straight from markdown. It's another 1st party commonmark extension called attributes. It just lets you sprinkle HTML attributes right into your markdown files.

For example, whenever I write

markdown.php {.filename}

it will render it as

<span class="filename">markdown.php</span>

That's exactly how I add those filename tags to all my code samples, as well as sprinkling in Tailwind CSS classes where necessary.

Image Previews

The final thing that I do to make my blog polished and complete is generate social images based on the content of the blog. I use a service called Placid that lets me generate them on-the-fly instead of having to make a social card for each post.

Here's what the one for this post looks like.

This image is auto-generated from the title of the post based on a template I defined at Placid.

If you share this post on Twitter (which you should!) then this is the image that will pop up.

Markdone

I hope you've gotten a thing or two from this post, and I hope you feel empowered to roll your own, barebones blog! It's a lot of fun to work with vanilla Laravel and have everything exactly like you want it!

Me

Thanks for reading! My name is Aaron and I write, make videos , and generally try really hard .

If you ever have any questions or want to chat, I'm always on Twitter.

You can find me on YouTube on my personal channel or my behind the scenes channel.

If you love podcasts, I got you covered. You can listen to me on Mostly Technical .