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

Djot Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. 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/.
  2. 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).
  3. 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.

Implementation Patterns

Bind a singleton, don't new it in views

Parsers 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.

Render on write, cache the HTML

// 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.

Blade directive for inline content

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.

Rich rendering: headings, IDs, TOC, link rewriting

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.

Mentions and tags via 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:

  • Tags are opt-in. Djot has no inline tag syntax, so #tag is only interpreted once you provide tagUrlTemplate or a tagResolver. Mentions resolve with just resolver.
  • Refusals degrade gracefully. Return null from a resolver and the mention/tag stays plain text in HTML, Markdown, and plain-text output — no broken href="#" litter.
  • URLs are safety-checked. Resolved URLs pass the package's URL safety gate, so a resolver returning javascript: is neutralised for you. Still sanitize (see below) — the gate is not a full sanitizer.
  • Resolvers run per render. If a resolver hits the DB, you have an N+1 hidden inside your renderer. Batch-load mentionable users before conversion, or memoize inside the closure using resolverContext.

Importing Markdown and HTML (0.1.34)

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.

Sanitize before it reaches the browser

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.

Store the AST when re-rendering matters

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.

Testing pattern: fixtures, not assertions on prose

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.

Migration from Markdown: convert, diff, then cut over

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.

Gotchas and Tips

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.

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.
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle
adachsoft/api-version-bundle
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
cgv/localized-resource