php 73 lines · 10 steps

Building a small Markdown-to-HTML parser in PHP

A line-oriented state machine turns Markdown into HTML by tracking open paragraphs and lists as it walks each line.

Explained by highlit
1<?php
2 
3final class MarkdownParser
4{
5 public function toHtml(string $markdown): string
6 {
7 $lines = preg_split('/\r\n|\r|\n/', $markdown);
8 $html = [];
9 $paragraph = [];
10 $inList = false;
11 
12 $flushParagraph = function () use (&$paragraph, &$html) {
13 if ($paragraph !== []) {
14 $html[] = '<p>' . $this->inline(implode(' ', $paragraph)) . '</p>';
15 $paragraph = [];
16 }
17 };
18 
19 $closeList = function () use (&$inList, &$html) {
20 if ($inList) {
21 $html[] = '</ul>';
22 $inList = false;
23 }
24 };
25 
26 foreach ($lines as $line) {
27 $trimmed = trim($line);
28 
29 if ($trimmed === '') {
30 $flushParagraph();
31 $closeList();
32 continue;
33 }
34 
35 if (preg_match('/^(#{1,6})\s+(.*)$/', $trimmed, $m)) {
36 $flushParagraph();
37 $closeList();
38 $level = strlen($m[1]);
39 $html[] = "<h{$level}>" . $this->inline($m[2]) . "</h{$level}>";
40 continue;
41 }
42 
43 if (preg_match('/^[-*]\s+(.*)$/', $trimmed, $m)) {
44 $flushParagraph();
45 if (!$inList) {
46 $html[] = '<ul>';
47 $inList = true;
48 }
49 $html[] = '<li>' . $this->inline($m[1]) . '</li>';
50 continue;
51 }
52 
53 $closeList();
54 $paragraph[] = $trimmed;
55 }
56 
57 $flushParagraph();
58 $closeList();
59 
60 return implode("\n", $html);
61 }
62 
63 private function inline(string $text): string
64 {
65 $text = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
66 $text = preg_replace('/`([^`]+)`/', '<code>$1</code>', $text);
67 $text = preg_replace('/\*\*([^*]+)\*\*/', '<strong>$1</strong>', $text);
68 $text = preg_replace('/\*([^*]+)\*/', '<em>$1</em>', $text);
69 $text = preg_replace('/\[([^\]]+)\]\(([^)]+)\)/', '<a href="$2">$1</a>', $text);
70 
71 return $text;
72 }
73}
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1Line-oriented parsers stay simple when you track just enough state — an open paragraph and whether a list is active.
  2. 2Deferring output through flush closures lets you accumulate multi-line blocks and emit them only when a boundary appears.
  3. 3Escaping text before applying inline formatting keeps the output safe without breaking the markup you intentionally inject.

Related explainers

Share this explainer

Here's the card — post it anywhere.

Building a small Markdown-to-HTML parser in PHP — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code