php-collective/djot
PHP parser and renderer for Djot, the lightweight markup language created by CommonMark's author. Convert Djot markup to HTML through a clean, extensible API—a modern alternative to Markdown offering more consistent syntax, better footnotes, and richer built-in features.
Install and confirm the package name. The repo is djot-php, but the Composer name matters more than the repo name:
composer require php-collective/djot
composer show php-collective/djot # confirm version + autoload namespace
Where to look first, in this order:
README.md — the canonical usage example and the current entry-point class names. As of 0.1.34 there are three entry points worth knowing about: the Djot parser/renderer, the Markdown importer (MarkdownToDjot), and the HTML importer. Namespace/class naming has shifted across early releases, so verify FQCNs against src/.src/ — find the converter facade, the renderer directory, and the extensions directory (MentionsExtension lives here). You want two things: the "one-shot string in, HTML out" class, and the AST node classes (you'll need the AST the moment you want a table of contents, anchor IDs, or link rewriting).tests/ — this is the real spec. Snapshot/fixture tests are the fastest way to learn Djot's edge cases without reading the Djot spec cover-to-cover.Minimal first use case — render a Djot body field to HTML:
use Djot\DjotConverter; // verify exact FQCN against src/
$converter = new DjotConverter();
$html = $converter->convert("# Hello\n\nThis is _djot_, not *markdown*.");
In a Laravel app, prove it works before wiring anything up:
php artisan tinker
>>> app()->make(\Djot\DjotConverter::class)->convert("# Hi\n\n- one\n- two");
If the container can't autowire it (constructor args), bind it explicitly in a service provider — see below.
First real feature: a posts.body_djot column plus a generated posts.body_html column, rendered on save rather than on every page view. That single decision (render-on-write, not render-on-read) is the difference between this package being cheap and being a latency tax on every request.
new it in viewsParsers are stateless per-conversion but hold configuration; construct once per request (or once per worker) and share it.
// app/Providers/DjotServiceProvider.php
public function register(): void
{
$this->app->singleton(DjotConverter::class, fn () => new DjotConverter(
// pass options here if your pinned version supports them
));
}
Wrap it in your own thin service (App\Support\DjotRenderer) rather than type-hinting the vendor class in controllers. That interface is your insurance policy if the package stalls — see Gotchas.
// app/Casts/DjotHtml.php (or a model observer / ::saving hook)
protected static function booted(): void
{
static::saving(function (Post $post) {
if ($post->isDirty('body_djot')) {
$post->body_html = app(DjotConverter::class)->convert($post->body_djot ?? '');
}
});
}
Then {!! $post->body_html !!} in Blade. Backfill existing rows with a queued job so you can re-render the whole corpus after a package upgrade without a deploy freeze.
Useful for CMS pages, marketing copy, and docs where the Djot lives in the DB but isn't a full model:
Blade::directive('djot', function (string $expression) {
return "<?php echo app(\Djot\DjotConverter::class)->convert($expression); ?>";
});
@djot($page->intro)
Note this is unescaped output — only feed it content you control or content you've sanitized.
When you need more than a string, walk the AST instead of regexing the HTML afterwards:
$doc = $converter->parse($source); // AST node tree
// walk $doc->getChildren() recursively:
// - Heading nodes -> slugify text, set id attribute, collect for a TOC array
// - Link nodes -> rewrite external hosts, add rel="noopener"
// - Image nodes -> swap to signed storage URLs
$html = $converter->render($doc);
This is also where you implement "internal wiki links", footnotes scoped per document, and syntax-highlighting hooks on code blocks.
MentionsExtension (new in 0.1.34)If your Djot content is community-facing (@alice, #release-notes), you no longer need a regex pass over the HTML. MentionsExtension takes optional resolver and tagResolver closures plus a shared resolverContext:
$extension = new MentionsExtension(
resolver: fn (string $name, $ctx) => route('users.show', $name),
tagResolver: fn (string $tag, $ctx) => $ctx->tagBase."/{$tag}",
resolverContext: (object) ['tagBase' => '/tags'],
);
Behaviour worth knowing before you wire it up:
#tag is only interpreted once you provide tagUrlTemplate or a tagResolver. Mentions resolve with just resolver.null from a resolver and the mention/tag stays plain text in HTML, Markdown, and plain-text output — no broken href="#" litter.javascript: is neutralised for you. Still sanitize (see below) — the gate is not a full sanitizer.resolverContext.The migration path now has real tooling rather than "run both parsers and diff":
$djot = (new MarkdownToDjot())->convert($markdown);
// trusted corpora only (your own repo docs, CMS migration from a controlled source):
$djot = (new MarkdownToDjot(trustedRawHtml: true))->convert($markdown);
trustedRawHtml defaults to false — raw HTML blocks in the Markdown source stay inert instead of becoming live Djot raw HTML. That default is correct for user content; flip it only for content you already trust. HTML import is likewise faithful now (inline whitespace, blockquote runs, nested quotes, and ASCII vs typographic punctuation preserved), which makes "paste a doc, get Djot" viable.
Djot intentionally supports raw HTML blocks and inline raw spans (``` =html ... ```, `text`{=html}). If any of your Djot input is user-supplied — comments, forum posts, AI-generated content, pasted-from-anywhere drafts — pipe the output through a sanitizer (HTMLPurifier or mews/purifier) with an allowlist. Treat the parser as a correctness tool, not a security tool.
$html = clean(app(DjotConverter::class)->convert($userInput), 'djot-user');
Maintain two conversion paths: trusted (authors/admins, raw HTML allowed) and untrusted (sanitized, raw HTML stripped). Pick the path at the call site, not inside the renderer.
If you ever need to change rendering (new heading anchors, different link policy), storing only body_djot means a full re-parse. Storing the AST as JSON alongside the source lets you re-render without re-parsing. For a blog this is overkill; for a docs platform with 20k pages and build-time rendering, it's worth it.
it('renders definition lists', function () {
$djot = file_get_contents(__DIR__.'/fixtures/def-list.djot');
$expected = file_get_contents(__DIR__.'/fixtures/def-list.html');
expect(app(DjotConverter::class)->convert($djot))->toBe($expected);
});
Every time you hit a rendering surprise in production, drop the input into a fixture. This is the cheapest way to survive a package upgrade. 0.1.34 is a good reminder of why: a fenced block holding one blank line used to render identically to an empty fence (#280), and emphasis/strong marker seams (___, ***) could take a fast path that disagreed with the authoritative parser (#281) — both fixed, both exactly the class of bug a fixture catches.
Djot is not Markdown. Even with MarkdownToDjot, convert a sample with both parsers and diff the HTML — the importer preserves references, entities, link titles and pointy destinations, but it cannot preserve rendering semantics you got from a Markdown extension. The divergences that bite are emphasis markers (_x_ and *x* behave differently than you expect), setext headings (not supported), and attribute syntax that previously rendered as literal text.
Djot is not Markdown, and your authors will assume it is. Emphasis, list looseness, blank-line handling, and link/attribute syntax all differ. Budget a short authoring guide; the "why is my *bold* not bold" support ticket is guaranteed. Notably, Djot leans on _/* for emphasis and ** for strong, with no setext (=== underlines) headings.
Raw HTML is a feature, so XSS is your problem, not the package's. Anything user-submitted needs sanitizing. This is the single biggest risk in this integration and the easiest to forget because the parser "just works" on trusted docs during development. The URL safety checks on mention/tag resolvers (0.1.34) close one hole; they do not close raw HTML blocks, attributes, or image src.
No Laravel integration ships with it. No config file to publish, no facade, no Blade components, no HasDjot trait. You write the service provider, the cast/observer, the sanitization policy, and the caching. That's ~80 lines — fine — but don't go looking for config/djot.php.
Low bus factor, near-zero dependents. 25 stars and no dependents means: pin the exact version ("php-collective/djot": "0.1.34"), commit composer.lock, and keep the vendor class behind your own DjotRenderer interface so a fork or swap to league/commonmark is a one-file change. Pre-1.0, minor numbers carry behaviour changes: 0.1.34 altered fenced-code blank-line output and Markdown serialisation, both of which will show up as diffs in your golden-file tests.
Footnote and heading IDs collide across documents. Multiple Djot fragments rendered on one page (a feed of posts, a comment thread) will produce duplicate id="fn-1" / heading anchors, breaking in-page links and aria references. Either render one document per page, or post-process IDs with a document prefix. Mention/tag links do not change this — they add anchors, not namespacing.
Don't parse in the Blade loop. @djot($post->body) inside @foreach($posts as $post) re-parses on every iteration for every request. Render-on-write or an html column is mandatory at any real volume. This applies doubly if you attach MentionsExtension with a DB-backed resolver.
Debug by dumping the AST and reading the warnings, not by staring at the HTML. When output looks wrong, dd($converter->parse($input)) and check whether the parser or your renderer/extension is at fault. Most "parser bugs" turn out to be a missing blank line in the source — Djot, like Markdown, is whitespace-sensitive in ways that only the AST makes visible. Since 0.1.34, inline warnings (undefined references, undefined footnotes, unattached attributes) report the authored line and column even inside lists, blockquotes, tables, divs and footnotes — so surface them in your editor preview or a CI check rather than swallowing them, and the reported position will point at the real source line, not the post-strip offset.
"Ignoring unattached attribute" is a typo detector, not a parser failure. An attribute block that opens a paragraph ({#id} on its own line before text) produces this warning at its authored position, matching djot.js. If an author says "my {#anchor} did nothing", check for the missing blank line before it.
Check UTF-8 end-to-end. Multibyte input plus a mis-set DB column charset produces truncated text and confusing parse failures. utf8mb4 on the column, and validate incoming strings before conversion.
Extension point is the renderer (or an extension), not string replacement. If you find yourself doing str_replace('<h2>', ...) on the output, stop and subclass/wrap the renderer, walk the AST, or use the bundled extension API. Post-processing HTML is how you get broken markup on nested lists and code blocks containing literal <h2>. Prefer the extension hooks — MentionsExtension is the reference implementation for how resolvers and context are meant to be threaded through.
Smart punctuation may surprise you. Djot applies typographic transforms (quotes, dashes, ellipses) by default in prose. That's usually desired, but it will rewrite things inside attribute values or generated content where you expected literal characters — check the renderer options before shipping copy that depends on exact punctuation. 0.1.34 tightened HTML import so authored ASCII punctuation stays distinct from typographic punctuation on the way in; it does not change what the renderer emits on the way out.
How can I help you explore Laravel packages today?