A tiny HTTP service that renders Markdown to HTML. PHP 8.2, Slim 4, league/commonmark. One endpoint, safe-by-default, GFM-capable, with heading extraction for table-of-contents generation.
Rendering Markdown is a need every CMS preview, doc site, forum, and AI chat UI eventually grows. Teams usually bolt a markdown library into each app that needs it. A ~100-line HTTP service is the clean separation.
POST /render— JSON in, JSON out (HTML + word count + headings)POST /render/html— JSON in, raw HTML out (no envelope; drop-in forinnerHTML)GET /render?text=...— same thing for short inputs you can curl by handGET /health— liveness + version probeGET /— a minimal textarea + live preview demo page
Full request/response schema: openapi.yaml.
{
"markdown": "# Hello\n\nThis is **bold**.",
"flavor": "gfm",
"safe": true
}flavor:"gfm"(default) or"commonmark". GFM adds tables, task lists, strikethrough, and bare-URL autolinks on top of the CommonMark spec.safe:true(default) escapes raw HTML inside the markdown source so that user input can't inject<script>tags. Set tofalseonly if you trust the input — internal docs pipelines, trusted content authors, etc.
{
"html": "<h1>Hello</h1>\n<p>This is <strong>bold</strong>.</p>\n",
"word_count": 4,
"headings": [
{ "level": 1, "text": "Hello", "anchor": "hello" }
]
}The heading list is extracted from the rendered HTML, not the source
markdown, so setext headings and HTML blocks are handled consistently.
Duplicate anchors are disambiguated with -1, -2, ... suffixes so you can
feed the result straight into a table-of-contents renderer.
composer install
composer serve
# → http://localhost:8000docker build -t markdown-api .
docker run --rm -p 8000:8000 markdown-apiImage is a multi-stage build on alpine:3.19 + PHP 8.2 copied from the
official image and trimmed. Runs as a non-root user. Final image weighs under
120 MB.
| var | default | meaning |
|---|---|---|
MAX_MARKDOWN_LEN |
100000 | Max input length in bytes. 413 on overflow. |
curl -sS -X POST http://localhost:8000/render \
-H "Content-Type: application/json" \
-d '{"markdown": "# Hello\n\nThis is **bold** and *italic*.\n\n- a\n- b\n- c"}'# raw HTML for direct innerHTML insertion
curl -sS -X POST http://localhost:8000/render/html \
-H "Content-Type: application/json" \
-d '{"markdown": "- [ ] task\n- [x] done"}'# safe mode (default) strips <script> tags
curl -sS -X POST http://localhost:8000/render \
-H "Content-Type: application/json" \
-d '{"markdown": "<script>alert(1)</script>"}'
# → {"html":"<script>alert(1)</script>", ...}composer install
vendor/bin/phpunit --no-coverageOr inside the built image:
docker run --rm --entrypoint /app/vendor/bin/phpunit markdown-api \
--no-coverage -c /app/phpunit.xml29 PHPUnit tests cover the renderer, heading extractor, middleware, and every HTTP route (including safe/unsafe mode, empty input, oversize input, invalid flavor, GFM tables/tasks/strikethrough/autolinks, and duplicate-anchor disambiguation).
- Slim 4, not Laravel. Slim is the minimal PSR-7 glue that exists for microservices exactly like this one. No ORM, no queue, no service container to learn. Three files do the whole thing.
- league/commonmark as the renderer. It is the CommonMark-compliant reference implementation in PHP, and its GFM extensions are first-class — not bolted on.
- Safe mode on by default. If you forget to pass
"safe": falseyou still get a safe-to-embed output. You have to opt out explicitly. - Heading extraction as a bonus endpoint payload. Computing the TOC is cheap if you already have the rendered HTML, and it's what most downstream consumers want next anyway.
- PHP's built-in web server is the default runtime. For an internal service rendering a few requests per second behind a CMS preview route, there is no reason to drag in FPM, Apache, or nginx. Put a cache in front and forget about it.
MIT. Copyright (c) 2026 SEN 合同会社.