mobizel/markdown-docs-bundle
A Symfony bundle that turns plain Markdown files into a browsable documentation site. It handles routing, navigation menus, and Twig rendering, with configurable source paths and templates, so you can write docs in Markdown and let the bundle generate the pages.
The first thing to establish is that mobizel/markdown-docs-bundle is a Symfony bundle, not a Laravel package. Its composer requirements pull in symfony/framework-bundle, symfony/yaml, twig/twig and league/commonmark, and it registers itself through DependencyInjection/MobizelMarkdownDocsExtension.php plus a Resources/config/services.yaml — there is no MarkdownDocsServiceProvider, no Blade views, and no config file under config/. So composer require mobizel/markdown-docs-bundle into a Laravel app gets you dead weight, not routes.
v0.13.0 does not change that premise. It is a maintenance/housekeeping release — PHP 8.1 in the test matrix, a Symfony 6 test-kernel fix, removal of yarn.lock/package.json, and one small feature ("Print action"). Nothing in it introduces a Laravel integration path.
Where to look first, before deciding anything:
README.md — the config block (mobizel_markdown_docs: directories: [...]) and the default route it exposes are the whole surface area.DependencyInjection/Configuration.php — the real list of options; the README drifts from it.Model/Documentation.php and Model/Document.php — this is where the directory→tree→slug mapping lives, and it is the genuinely reusable part.Resources/views/ — the Twig layout, nav partials and (new in v0.13.0) the print template you'd mirror in Blade.composer.json — check the branch alias and Symfony constraints; v0.13.0 is the first release since April 2022, so the version range has finally moved but the project is still pre-1.0.The realistic first use case, regardless of framework, is docs-as-code: a docs/ folder in the repo containing ADRs, onboarding notes and internal API guides, rendered at /docs with a sidebar generated from the folder tree. If you're on Symfony, the minimal path is: require the bundle, enable it in config/bundles.php, point directories at %kernel.project_dir%/docs, and hit /docs. The new print action gives you a printer-friendly rendering of the same page for free. If you're on Laravel — which is the premise here — the correct move is still to read it as a reference implementation and rebuild the ~150 lines that matter on top of league/commonmark, which you almost certainly already have transitively.
Port the model, not the bundle. The valuable logic is: scan configured directories → build a nested tree of files → derive a slug from the relative path → render the matched file. That's a recursive Finder/glob plus a small value object. Everything else (routing, templating, DI) is framework glue you'd rewrite anyway.
A Laravel-shaped version of the core:
// app/Support/Docs/DocRepository.php
public function tree(string $root): array {
$files = collect(File::allFiles($root))
->filter(fn ($f) => $f->getExtension() === 'md')
->map(fn ($f) => [
'slug' => str_replace(['\\', '.md'], ['/', ''], $f->getRelativePathname()),
'title' => $this->titleFrom($f),
'order' => $this->frontMatter($f)['order'] ?? 999,
])
->sortBy('order')
->values();
// group by first path segment for the sidebar
}
Route it narrowly. One route with a constrained parameter beats a catch-all:
Route::get('/docs/{path?}', DocsController::class)
->where('path', '[A-Za-z0-9_\-\/]+')
->name('docs.show');
Constrain the pattern, then still resolve the path against a pre-built whitelist of known slugs rather than concatenating it onto a base directory. The bundle resolves files from a request-supplied path; that is the one place you must not copy its looseness.
Front matter for ordering and titles. The bundle leans on Symfony YAML for this. In Laravel use spatie/yaml-front-matter (or parse the --- block yourself with Symfony\Component\Yaml\Yaml, which is a tiny standalone dependency). Conventions that pay off: title, order, updated, and a folder-level index.md as the section landing page.
Render once, cache hard. Markdown parsing is the only expensive step and it's pure. Cache the HTML keyed on md5($path . filemtime($file)):
$html = Cache::remember("docs:{$slug}:".md5_file($file), now()->addDay(),
fn () => (new CommonMarkConverter([
'html_input' => 'strip',
'allow_unsafe_links' => false,
]))->convert(Str::after($raw, "---\n".explode("---\n", $raw, 3)[1]."---\n"))
->getContent()
);
Configure CommonMark once, not per request. Bind a singleton converter in a service provider with the GitHub-flavoured extension set (league/commonmark's CommonMarkCoreExtension, TableExtension, AutolinkExtension, HeadingPermalinkExtension for anchor links), and add AttributesExtension if you want {#custom-id} on headings.
Templating. Blade layout + Tailwind prose classes gets you 90% of the bundle's Twig output. Use {!! $html !!} only after html_input: 'strip' — and note that strips raw HTML, so if your docs rely on <details> blocks you'll need html_input: 'escape' plus an allowlist pass.
Print view (new in v0.13.0). The bundle now exposes a print action. The cheap Laravel equivalent is a sibling route (/docs/{path}/print, or ?print=1 on the same controller) that renders the identical HTML through a layouts/print.blade.php with expanded nav, no chrome, and @media print CSS. This is the single most useful thing in the release for internal docs — authors get "save as PDF" without a second render pipeline.
Access control. Internal docs belong behind middleware: ->middleware(['auth', 'can:view-docs']). The bundle assumes a public route — including for the new print action — you don't have to.
Static export. If you'd rather not serve at runtime, an artisan command that walks docs/ and writes public/docs/*.html gives you GitHub Pages deployability and zero request-time cost. This is often the better answer than mounting a route.
services.yaml, bundle tags and Symfony's routing don't translate. Budget for a rewrite, not an integration. v0.13.0 did nothing to narrow this gap.composer.lock (or an old constraint) may resolve very differently than the README implies. Re-read Configuration.php against your installed version rather than trusting cached docs — options may have been added or renamed silently in the intervening commits.realpath() a user segment and trust it. Check whether the new print action shares the same resolution code.html_input: 'strip' and allow_unsafe_links: false. Authors who need raw HTML should get an explicit, reviewed escape hatch.--- or a bad YAML indent turns the whole block into page body or throws. Wrap the parse in a try/catch, fall back to the raw content, and log the file path.Getting-Started.md and getting-started.md are different files on Linux and the same on macOS. Normalise slugs to lowercase on generation or your links will 404 only in production. Your print route needs the same normalisation or the two views will disagree on URLs.docs/{path?} route registered before other /docs/* routes swallows them — including a /docs/{path}/print route if you add one. Register the catch-all last, or scope under a prefix like /docs/{section}/{page?}.filemtime on deploy. git clone sets mtimes to checkout time, so mtime-keyed caches all invalidate at once after every deploy — usually fine, but under a warm-up it means a thundering herd. Prefer hashing file contents for the cache key if docs are small.templates/bundles/MobizelMarkdownDocsBundle/ (now including the print template), decorate the documentation service rather than editing the bundle, and register custom CommonMark extensions through the converter service. All three are clean; the rest of the bundle is thin enough that forking the model classes is often simpler than decorating.league/commonmark + a controller is a 30-minute job and has no abandonment risk. Reach for a docs bundle only when you need multi-directory trees, search, print/export and versioned sections — and at that point, compare against a static generator or a hosted docs product before committing.How can I help you explore Laravel packages today?