tempest/markdown
A fast, extensible server-side Markdown parser for PHP that uses a simple lexer instead of regex. Built-in support for code highlighting, tables, divs, responsive images, and frontmatter, with benchmarks well ahead of commonmark and Parsedown Extra.
Install and first call
composer require tempest/markdown
use Tempest\Markdown\Markdown;
$markdown = new Markdown();
$parsed = $markdown->parse(file_get_contents(resource_path('content/docs/intro.md')));
echo $parsed->frontmatter['title']; // YAML frontmatter, parsed for you
echo $parsed->html; // rendered HTML string
That's the entire public surface you need on day one: a Markdown instance, parse(string), and a result object with html and frontmatter. There is no service provider, facade, config file, or Blade directive — this is a framework-agnostic PHP library, not a Laravel package. The first thing you write is a thin wrapper (see below), and the first thing you should do after that is render ten real files from your actual content corpus and eyeball the output against whatever renders them today.
Where to look first
tempestphp.com/3.x/packages/markdown) — it is the only authoritative reference for constructor options, maxNestingDepth, and how to register custom rules. Read it before writing extensions.src/ in the repo — the lexer and rule set. This is where you go when a construct renders wrong or when you need to add custom syntax.Your first useful use case: a file-based content page
A controller or route that maps a slug to resources/content/{slug}.md, parses it, and passes html + frontmatter to a Blade view is the canonical fit. Add meta.title into the layout <title>, meta.description into a meta tag, and you've replaced a docs generator with ~20 lines. Everything else (indexes, sitemaps, RSS, draft filtering) builds on that same call.
1. Bind it once, wrap it once
Don't new Markdown() in controllers, commands, and jobs. Register a singleton and put the parser behind your own class so you can swap renderers later without touching call sites.
// app/Providers/AppServiceProvider.php
public function register(): void
{
$this->app->singleton(\Tempest\Markdown\Markdown::class);
$this->app->singleton(\App\Support\MarkdownRenderer::class);
}
// app/Support/MarkdownRenderer.php
namespace App\Support;
use Tempest\Markdown\Markdown;
final class MarkdownRenderer
{
public function __construct(private Markdown $markdown) {}
/** @return array{html: string, meta: array<string, mixed>} */
public function render(string $source): array
{
$parsed = $this->markdown->parse($source);
return ['html' => $parsed->html, 'meta' => $parsed->frontmatter];
}
}
Keeping the return shape ['html' => ..., 'meta' => ...] rather than leaking the package's result object is the single highest-value decision here — it is what makes the "pilot, not platform bet" framing real.
2. Render in Blade via a component, not a directive
A Blade component keeps escaping decisions visible at the call site:
{{-- resources/views/components/prose.blade.php --}}
@props(['content'])
{!! app(\App\Support\MarkdownRenderer::class)->render($content)['html'] !!}
<x-prose :content="$page->html" />
Use {!! !!} deliberately — the parser returns an HTML string, so {{ }} will print escaped tags. Reserve the component for content you trust; route user-authored content through a sanitizer component variant instead (pattern 5).
3. Cache rendered HTML keyed by file mtime
Parsing is fast (~11ms for a full docs set in the benchmark), but hot pages and pages with code highlighting are still worth caching, and per-request parsing of every file for an index page adds up.
public function renderFile(string $path): array
{
$key = 'md:'.sha1($path.'|'.filemtime($path).'|'.filesize($path));
return Cache::remember($key, now()->addDay(), fn () => $this->render(file_get_contents($path)));
}
Key on mtime so edits invalidate naturally, and on deploy run php artisan cache:clear (or use a versioned prefix) so old keys don't accumulate.
4. Frontmatter as a file-based CMS
Frontmatter is the reason to pick this over a bare Markdown renderer. Use it to drive listings, draft filtering, and SEO without a database or a YAML dependency.
$posts = collect(File::files(resource_path('content/posts')))
->map(fn ($file) => [
'slug' => $file->getBasename('.md'),
...$this->renderFile($file->getPathname()),
])
->filter(fn ($p) => empty($p['meta']['draft']))
->sortByDesc(fn ($p) => $p['meta']['date'] ?? '')
->values();
The same collection feeds a blog index, an RSS feed, a sitemap, and previous/next links. Treat meta as untrusted-shaped data (see gotchas) — cast dates and default anything optional.
5. Untrusted content: parse, then sanitize
Markdown-to-HTML is inherently an XSS surface, and this package supports raw HTML, divs, and HTML attributes. If users author content, run the output through an HTML sanitizer (e.g. mews/purifier) with an allowlist before it reaches {!! !!}, and keep a Pest test file of payloads:
it('strips hostile markdown payloads', function (string $payload) {
$html = app(MarkdownRenderer::class)->render($payload)['html'];
expect(app('purifier')->clean($html))->not->toContain('<script');
})->with([
'<img src=x onerror=alert(1)>',
'[x](javascript:alert(1))',
'<div onclick="alert(1)">hi</div>',
"```html\n<script>alert(1)</script>\n```",
]);
Recent releases fixed XSS via HTML attributes and code blocks — good sign, but "fixed" is not "guaranteed safe for arbitrary input." Your sanitizer is the guarantee.
6. Extend the lexer for house syntax instead of pre-processing
Mentions, callouts (> [!NOTE]), embeds, issue links — the package already ships a social-handle rule and a @@ token, so the extension seam exists. Write a rule rather than running regexes over the Markdown before parsing; pre-processing corrupts code fences and inline code. Read the docs' extension section first, write the rule, and pin the version, because the rule API is young and can shift between minors. Cover each custom rule with a golden-file test so an upstream minor bump fails loudly instead of silently changing output.
7. Batch conversion in Artisan commands and queues
Because it's pure PHP with no Node toolchain, an artisan content:render command that converts a directory of .md to static HTML/JSON works well in CI or a queue worker. Parse sequentially in a single process, reuse the Markdown instance for the batch, and chunk very large imports so peak memory stays predictable.
8. Golden-file tests as your upgrade harness
Store input .md fixtures and their expected .html, assert exact equality in CI, and re-run on every dependabot bump. This is the cheapest possible protection against the "not strictly CommonMark" problem and against behavior changes in fast-moving releases.
new Markdown() — a single construction point is what makes swapping or vendoring a fork tractable.{!! !!}. The two failure modes are the same bug seen from both sides: {{ }} printing visible tags, or a well-meaning htmlspecialchars() in your wrapper producing double-escaped entities. Pick one convention and enforce it in the component.'2024-01-05' is a string, 2024 may be an int, draft may be true/'true'. Cast explicitly (Carbon::parse($meta['date'])), and always guard with ?? — a missing title should be a null-safe default, not an "undefined array key" warning in production.frontmatter is empty rather than present, and never index into it without a fallback in a listing loop.id attributes in a test before and after adoption.maxNestingDepth for untrusted input. The option exists specifically to bound pathological nesting (deep blockquotes/lists), which is a cheap DoS vector. A modest ceiling plus a request-size limit is the right combination; also cap raw input length before parsing.forToken cache leak in the parser. Under Octane, Horizon workers, or long batch jobs, monitor RSS over hundreds of parses; if it climbs, instantiate per job and re-measure.<figure> wrappers, and any client-side lightbox. If you need to post-process, do it with a DOM parser after parse(), never with regex on the Markdown or the HTML string.composer.lock is your friend. Read each changelog; treat minor bumps as potentially output-changing.league/commonmark costs hours, not a sprint.How can I help you explore Laravel packages today?