Creating reusable Sculpin bundles
Sculpin is built on Symfony, so it can be extended with Symfony bundles.
That means any custom functionality you write for your own site can be extracted and shared, in the same way you'd share a Drupal module or a Symfony bundle.
I recently did this with the table of contents code on this website, moving it into the sculpin-table-of-contents-bundle.
What was there before
The table_of_contents Twig filter started as a project-specific package in this repository, in packages/table-of-contents.
It was autoloaded via packages/autoload.php and registered manually as a Twig extension in app/config/sculpin_kernel.yml:
services:
Opdavies\TableOfContents\TableOfContentsExtension:
tags:
- { name: twig.extension }
That worked, but it was tied to this site.
Extracting it into a bundle means it can be reused elsewhere, tested in isolation, and updated in one place.
The bundle structure
A Sculpin bundle is a Symfony bundle, so it follows the same conventions:
sculpin-table-of-contents-bundle/
├── src/
│ ├── DependencyInjection/
│ │ └── SculpinTableOfContentsExtension.php
│ ├── Resources/
│ │ └── config/
│ │ └── services.yml
│ ├── Twig/
│ │ └── TableOfContentsExtension.php
│ └── SculpinTableOfContentsBundle.php
├── tests/
│ └── TableOfContentsExtensionTest.php
├── composer.json
├── flake.nix
└── README.md
The bundle class
The bundle class itself does nothing except identify the directory as a bundle:
<?php
declare(strict_types=1);
namespace Opdavies\Sculpin\Bundle\TableOfContentsBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class SculpinTableOfContentsBundle extends Bundle
{
}
Symfony finds the dependency injection extension by convention, based on the bundle's name.
The dependency injection extension
The DI extension loads the bundle's service configuration into the container:
<?php
declare(strict_types=1);
namespace Opdavies\Sculpin\Bundle\TableOfContentsBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
class SculpinTableOfContentsExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container): void
{
$loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config'));
$loader->load('services.yml');
}
}
And src/Resources/config/services.yml registers the Twig extension:
services:
Opdavies\Sculpin\Bundle\TableOfContentsBundle\Twig\TableOfContentsExtension:
tags:
- { name: twig.extension }
This is the part that removes the manual service registration from the consuming site.
Once the bundle is registered, the filter is available - there's nothing else to configure.
The Twig extension
The functionality itself is unchanged from the version that lived in this repository, apart from its namespace:
<?php
declare(strict_types=1);
namespace Opdavies\Sculpin\Bundle\TableOfContentsBundle\Twig;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
class TableOfContentsExtension extends AbstractExtension
{
private const int MIN_LEVEL = 2;
public function getFilters(): array
{
return [
new TwigFilter('table_of_contents', [$this, 'extractHeadings']),
];
}
public function extractHeadings(string $content, int $maxLevel = self::MIN_LEVEL): array
{
$flatHeadings = $this->findHeadings($content, $maxLevel);
$index = 0;
return $this->buildTree($flatHeadings, $index, self::MIN_LEVEL);
}
private function findHeadings(string $content, int $maxLevel): array
{
if (trim($content) === '' || $maxLevel < self::MIN_LEVEL) {
return [];
}
$document = new \DOMDocument();
libxml_use_internal_errors(true);
$document->loadHTML(
'<?xml encoding="utf-8" ?><div>' . $content . '</div>',
LIBXML_NOERROR | LIBXML_NOWARNING,
);
libxml_clear_errors();
$xpath = new \DOMXPath($document);
$conditions = [];
for ($level = self::MIN_LEVEL; $level <= $maxLevel; $level++) {
$conditions[] = 'self::h' . $level;
}
$headings = [];
foreach ($xpath->query('//*[' . implode(' or ', $conditions) . ']') as $heading) {
$id = $heading->getAttribute('id');
if ($id === '') {
continue;
}
$headings[] = [
'id' => $id,
'text' => trim($heading->textContent),
'level' => (int) substr($heading->nodeName, offset: 1),
];
}
return $headings;
}
private function buildTree(array $flatHeadings, int &$index, int $level): array
{
$nodes = [];
while ($index < count($flatHeadings) && $flatHeadings[$index]['level'] === $level) {
$heading = $flatHeadings[$index];
$index++;
$children = [];
if ($index < count($flatHeadings) && $flatHeadings[$index]['level'] > $level) {
$children = $this->buildTree($flatHeadings, $index, $flatHeadings[$index]['level']);
}
$nodes[] = [
'id' => $heading['id'],
'text' => $heading['text'],
'children' => $children,
];
}
return $nodes;
}
}
The filter returns a tree of headings rather than rendered markup, so each site can render it however it likes.
Headings without an id attribute are skipped, since there's nothing to link to. Sculpin's Markdown converter adds them automatically.
Package metadata
The composer.json file uses the sculpin-bundle type, which is how Sculpin bundles identify themselves:
{
"name": "opdavies/sculpin-table-of-contents-bundle",
"description": "A Sculpin bundle that provides a Twig filter for extracting a nested table of contents from rendered heading content.",
"license": "MIT",
"type": "sculpin-bundle",
"require": {
"php": ">=8.3",
"ext-dom": "*",
"symfony/config": "^6.0 || ^7.0",
"symfony/dependency-injection": "^6.0 || ^7.0",
"symfony/http-kernel": "^6.0 || ^7.0",
"symfony/yaml": "^6.0 || ^7.0",
"twig/twig": "^3.0"
},
"require-dev": {
"phpunit/phpunit": "^11.0 || ^12.0"
},
"suggest": {
"sculpin/sculpin": "@stable"
},
"autoload": {
"psr-4": {
"Opdavies\\Sculpin\\Bundle\\TableOfContentsBundle\\": "src"
}
},
"autoload-dev": {
"psr-4": {
"Opdavies\\Sculpin\\Bundle\\TableOfContentsBundle\\Tests\\": "tests"
}
}
}
Note that twig/twig and the Symfony components are direct requirements rather than assumed to be present via Sculpin.
The bundle should work in any Symfony application, not only a Sculpin one.
Tests
The tests moved across with the code, and cover the extraction and nesting behaviour:
#[Test]
public function it_nests_h3_headings_under_their_preceding_h2_when_max_level_is_given(): void
{
$extension = new TableOfContentsExtension();
$headings = $extension->extractHeadings(
'<h2 id="a">A</h2><h3 id="a-1">A.1</h3><h3 id="a-2">A.2</h3><h2 id="b">B</h2>',
maxLevel: 3,
);
$this->assertSame([
[
'id' => 'a',
'text' => 'A',
'children' => [
['id' => 'a-1', 'text' => 'A.1', 'children' => []],
['id' => 'a-2', 'text' => 'A.2', 'children' => []],
],
],
['id' => 'b', 'text' => 'B', 'children' => []],
], $headings);
}
Because the extension has no dependencies on Sculpin, the tests run against plain HTML strings with PHPUnit and nothing else.
Installing the bundle
The usual approach is Composer:
composer require opdavies/sculpin-table-of-contents-bundle
This website doesn't use Composer though - Sculpin packages are added via Nix instead - so it's fetched with a derivation that pins a commit:
{
fetchgit,
stdenv,
}:
stdenv.mkDerivation {
pname = "sculpin-table-of-contents-bundle";
version = "unstable-2026-07-27";
src = fetchgit {
url = "https://git.oliverdavies.uk/opdavies/sculpin-table-of-contents-bundle.git";
rev = "90e73c783ddbc93567b575283ef063e5950f5bf6";
hash = "sha256-F0AXdrOyEpjHTLmDg7wRv1TZiAG/zsg8dSvOqr0G3XY=";
};
installPhase = ''
runHook preInstall
cp -r . "$out"
runHook postInstall
'';
}
The store path is then exported as SCULPIN_TABLE_OF_CONTENTS_BUNDLE_PATH, and packages/autoload.php maps it to the bundle's namespace:
$sculpinBundles = [
'SCULPIN_META_NAVIGATION_BUNDLE_PATH' => 'Janbuecker\\Sculpin\\Bundle\\MetaNavigationBundle\\',
'SCULPIN_REDIRECT_BUNDLE_PATH' => 'Mavimo\\Sculpin\\Bundle\\RedirectBundle\\',
'SCULPIN_TABLE_OF_CONTENTS_BUNDLE_PATH' => 'Opdavies\\Sculpin\\Bundle\\TableOfContentsBundle\\',
];
Either way, the bundle needs registering in app/SculpinKernel.php:
class SculpinKernel extends \Sculpin\Bundle\SculpinBundle\HttpKernel\AbstractKernel
{
protected function getAdditionalSculpinBundles(): array
{
return [
\Janbuecker\Sculpin\Bundle\MetaNavigationBundle\SculpinMetaNavigationBundle::class,
\Mavimo\Sculpin\Bundle\RedirectBundle\SculpinRedirectBundle::class,
\Opdavies\Sculpin\Bundle\TableOfContentsBundle\SculpinTableOfContentsBundle::class,
];
}
}
Using it
The filter returns a list of headings, each with an id, text, and any nested children:
{% set headings = content|table_of_contents %}
{# Also include <h3> headings, nested under their <h2> #}
{% set headings = content|table_of_contents(3) %}
Rendering is left to the site, so the markup can match its own styling:
{% if headings|length > 1 %}
<nav aria-labelledby="table-of-contents-heading">
<h2 id="table-of-contents-heading">Contents</h2>
{{ _self.list(headings) }}
</nav>
{% endif %}
{% macro list(headings) %}
<ol>
{% for heading in headings %}
<li>
<a href="#{{ heading.id }}">{{ heading.text }}</a>
{% if heading.children %}
{{ _self.list(heading.children) }}
{% endif %}
</li>
{% endfor %}
</ol>
{% endmacro %}
Using a real <h2> associated with the <nav>, and genuinely nested <ol> elements rather than a flat list styled to look nested, means the table of contents is announced correctly by assistive technology.
What I'd do again
Keeping the bundle focused was the main thing that made this straightforward.
It extracts headings and nothing else - no rendering, no caching, no configuration - so there's very little that can conflict with a consuming site.
Everything else followed from that: the tests only need HTML strings, the dependencies are minimal, and the bundle class and DI extension are boilerplate.
If you have functionality in your own Sculpin site that isn't site-specific, it's likely a bundle waiting to be extracted.