Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Markdown Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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

  1. The Tempest docs page for the Markdown package (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.
  2. The README benchmark table — useful for the "can we render on request?" question, but it's a docs corpus, not your corpus.
  3. 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.
  4. The changelog/releases — the package is explicit about being a work in progress, and recent releases changed behavior (setext headings, lazy list continuations, tildes, thematic breaks). Read them before upgrading.

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.

Implementation Patterns

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.

Gotchas and Tips

  • There's no Laravel integration to lean on. No auto-discovery, no publishable config, no facade. Budget the wrapper and binding as real work (small, but permanent). Don't scatter new Markdown() — a single construction point is what makes swapping or vendoring a fork tractable.
  • Raw HTML output means {!! !!}. 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.
  • Frontmatter is loosely typed. YAML-ish scalars may come back as strings: '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.
  • Missing or empty frontmatter is the normal case. Files without a frontmatter block still parse; assume frontmatter is empty rather than present, and never index into it without a fallback in a listing loop.
  • Not CommonMark-exact. The lexer intentionally diverges and adds extensions. Diff output against your current renderer across your real corpus before switching, and keep the golden tests. Edge cases that were actively being fixed in 1.3.0 — setext headings, lazy list continuations, bullet markers, standalone dashes, unmatched tildes — are exactly the kind of thing that shows up in hand-written docs.
  • Header anchors may move. Manual header IDs are supported, but auto-slug behavior is not something to assume. If you have inbound deep links, a generated TOC, or a docs sidebar, assert the id attributes in a test before and after adoption.
  • Set 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.
  • Raw HTML/div support is a trust boundary, not a feature toggle you can forget. If you don't need author-supplied HTML, sanitize it away rather than reasoning about which attributes the parser passes through.
  • Long-lived parser instances: upgrade past 1.2.1 and watch memory. That release fixed a 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.
  • Responsive images and highlighting produce markup you didn't write. Check the generated attributes against your CSS, existing <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.
  • The benchmark is not your benchmark. 10.9ms/6.66MB vs 57ms/21.1MB on the Tempest docs is a strong signal, not a guarantee. Time your own largest page and your index-page loop in your runtime before deciding per-request rendering is fine.
  • Pin the version and upgrade deliberately. Frequent releases plus pre-1.x-era churn plus an explicit "work in progress" banner means composer.lock is your friend. Read each changelog; treat minor bumps as potentially output-changing.
  • You are the support contract. 44 stars, zero known dependents, no SLA. That's acceptable for a swappable rendering layer behind an interface; it is not acceptable for something on a security-review critical path without your own tests. Keep the wrapper thin so a fallback to league/commonmark costs hours, not a sprint.
  • Debugging a bad render: reduce the failing Markdown to the smallest snippet that reproduces it, confirm it isn't your pre-processing, check the changelog for that construct, then file an issue with the snippet and expected/actual HTML. Do the reduction before blaming the parser — the majority of "parser bugs" in integration are wrapper escaping or sanitizer config.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table
siberfx/flysystem-wasabi
hollyit/token-replace
ippanelcom/laravel-sdk
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel