HTML table of contents generator, PHP-powered

HTML table of contents generator

An HTML table of contents generator is often based on JavaScript. There are PHP-powered HTML table of contents generators. Those often use regex to seek for headings.

I have a different HTML table of contents generator for you, which does not rely on regex.

Instead, it relies on PHP's built-in HTML parser. This generator does not require you to manually add an ID to every single heading, as well. It automatically inserts an ID, if no ID is specified, based on the contents of the heading and the parent-headings (hierarchical).

Sounds great? I'll tell you how to implement this HTML table of contents generator.

Solution for automatic table of contents

Before you can copy-paste my PHP-code and use it, my solution needs to know what content you want to present to the visitor of your webpage; HTML-content.

In order to start collecting the HTML, you have to add ob_start(); at the beginning of the PHP-code that is executed when a visitor loads your page. If you've figured that out, you should add $html = ob_get_clean(); at the end of the PHP-code that is executed when a visitor loads your page. Now, this $html variable holds the content that we need to be able to generate a table of contents. The following code will generate a table of contents:

$doc = new DOMDocument();
$doc -> loadHTML($buffer);
$xPath = new DOMXPath($doc);

function getHeadingDepth(DOMElement $heading) {
  return intval(substr($heading -> tagName, 1));
function idEncode($str) {
  return preg_replace('/s/', '-', htmlspecialchars(strtolower($str)));

$tableOfContents = $doc -> createElement('ol');
$tableOfContents -> setAttribute('class', 'table-of-contents auto-toc');

$currentOL = $tableOfContents;
$previousOLs = [];

$previousHeading = false;
$allHeadings = $xPath -> query('//main//h2 | //main//h3 | //main//h4 | //main//h5 | //main//h6');
$iHeading = 0;                  // ^^ change main to whatever you use to indicate the main content of a page (excluding the header and footer, or any repeating blocks). Read about xPath if you would like to target specific class names or IDs.

$previousLI = false;

$generatedIDs = [];

foreach($allHeadings as $heading) {
  $headingDepth = getHeadingDepth($heading);
  if($previousHeading) {
    $previousHeadingDepth = getHeadingDepth($previousHeading);
  } else {
    $previousHeadingDepth = 2;
  if($headingDepth > $previousHeadingDepth) {
    /* If heading is deeper than previous heading, then remember previous OL and
     * create new OL, set it as current OL and append the OL to the previous OL
    $previousOLs[$previousHeadingDepth] = $currentOL;
    $currentOL = $doc -> createElement('ol');
    if($previousLI) {
        $previousLI -> appendChild($currentOL);
    } else {
        $previousOLs[$previousHeadingDepth] -> appendChild($currentOL);
  } elseif($headingDepth < $previousHeadingDepth) {
    /* If heading is less deep than previous heading, then set the
     * current OL as the previous OL corresponding to the current depth
    $currentOL = $previousOLs[$headingDepth];
  // Finally, append a new LI to the current OL.
  $currentLI = $doc -> createElement('li');
  $currentAnchorLink = $doc -> createElement('a');
  $currentAnchorLink -> textContent = $heading -> textContent;
  if($heading -> hasAttribute('id')) {
    $currentAnchorLink -> setAttribute('href', '#' . $heading -> getAttribute('id'));
  } else {
    $headingTraceback = [];
    $olInTrace = $currentOL; 
    while($olInTrace !== $tableOfContents && $olInTrace -> tagName == 'ol') {
      array_push (
        idEncode($olInTrace -> previousSibling -> textContent)
      $olInTrace = $olInTrace -> parentNode;
    $newHeadingID = implode('-', array_reverse($headingTraceback)) . '-' . idEncode($heading -> textContent);
    $heading -> setAttribute('id', $newHeadingID);
    $heading -> setAttribute('data-auto-toc-id', $newHeadingID);
    $currentAnchorLink -> setAttribute('href', '#' . $newHeadingID);
    array_push (
  $currentLI -> appendChild($currentAnchorLink);
  $currentOL -> appendChild($currentLI);
  // Set previous <li>
  $previousLI = $currentLI;

  // Set previous heading
  $previousHeading = $heading;
  // Set previous heading depth
  $previousHeadingDepth = $headingDepth;

$allAutomaticTablesOfContents = $doc -> getElementsByTagName('auto-toc');
foreach($allAutomaticTablesOfContents as $automaticTableOfContents) {
  $tableOfContentsToUse = $tableOfContents -> cloneNode(true);
  if($automaticTableOfContents -> hasAttribute('class')) {
    $tableOfContentsToUse -> setAttribute (
      $tableOfContentsToUse -> getAttribute('class') . ' ' . $automaticTableOfContents -> getAttribute('class')
  $automaticTableOfContents -> parentNode -> replaceChild (

echo '<!DOCTYPE html>' . $doc -> saveHTML($doc -> documentElement); // echo the final HTML

How to place an automatic ToC?

Simply add <auto-toc></auto-toc> and that will get replaced by the table of contents. You can add classes to this element as well, the generator will simply merge the existing classes with the classes that indicate that it is an automatic table of contents.

<auto-toc class="some-custom-class"></auto-toc>

I hope this solution helped you and that you never have to adjust your table of contents everytime you've added or changed headings. This solution has been tested and is actually used in a documentation for the page builder I've made for this website. That table of contents links to 189 anchors which nest all the way down to <h5>-headings.

Looking for more flexibility in your website's design? Take a look at a few posts I wrote about CSS solutions I provide you to bring more flexibility into your responsive designs.