avh / contao-faq-structured-bundle
Contao content element for FAQ sections with Schema.org FAQPage JSON-LD and three template variants (Handorgel, Bootstrap 5, Tailwind CSS)
Package info
github.com/avhulst/AvhContaoFaqStructuredBundle
Type:contao-bundle
pkg:composer/avh/contao-faq-structured-bundle
Requires
- php: ^8.3
- contao/core-bundle: ^5.7
- doctrine/dbal: ^3.6 || ^4.0
- mvo/contao-group-widget: ^1.6
- symfony/dependency-injection: ^7.4
- symfony/http-foundation: ^7.4
- symfony/http-kernel: ^7.4
Requires (Dev)
- contao/contao-rector: dev-main
- contao/easy-coding-standard: ^6.13
- contao/manager-plugin: ^2.13
- contao/test-case: ^5.7
- phpstan/phpstan: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-symfony: ^2.0
- phpunit/phpunit: ^12.4
- rector/rector: ^2.0
- shipmonk/composer-dependency-analyser: ^1.8
- symplify/easy-coding-standard: 13.2.3
This package is auto-updated.
Last update: 2026-08-24 04:25:14 UTC
README
Package name: avh/contao-faq-structured-bundle
Short description: Contao content element for FAQ sections with automatic Schema.org FAQPage JSON-LD output and three template variants (Handorgel, Bootstrap 5, Tailwind CSS). The structured data is machine-readable for search engines, AI answer engines and your own tooling. It no longer produces a dedicated result appearance in Google Search — see Structured data and search results.
Target Audiences
- Developers: Technical integration of the bundle into Contao 5.7, template customization, extending FAQ logic.
- Project Managers: Feature overview for client consulting and project planning, plus a realistic assessment of what FAQ structured data does and does not deliver today.
Core Features
- FAQ Content Element — New content type
faq_structuredin the Texts category. Unlimited question-answer pairs per element. - Schema.org FAQPage JSON-LD — Automatic generation of structured data in FAQPage format. Output in the
<head>of the page. - Three Template Variants — Handorgel (default), Bootstrap 5 Accordion, Tailwind CSS with
<details>element. - Rich-Text Answers — Answers are edited via TinyMCE and support HTML formatting (links, lists, bold text, etc.).
Technical Architecture
Directory Structure
packages/AvhContaoFaqStructuredBundle/
├── .editorconfig # Code style configuration
├── .github/workflows/ci.yml # GitHub Actions quality gate
├── .gitignore # Git ignore rules
├── .gitlab-ci.yml # Publishing job for the GitLab Composer registry
├── CHANGELOG.md # Changelog
├── LICENSE # MIT license
├── README.md # Documentation (English)
├── README_DE.md # Documentation (German)
├── cliff.toml # git-cliff changelog configuration
├── composer.json # Package definition, dependencies
├── depcheck.php # composer-dependency-analyser configuration
├── ecs.php # Easy Coding Standard configuration
├── phpstan.neon # PHPStan configuration (level 6)
├── phpunit.xml.dist # PHPUnit configuration
├── rector.php # Rector configuration
├── config/
│ └── services.php # Service autowiring
├── contao/
│ ├── dca/
│ │ └── tl_content.php # Backend field definitions (palette, fields)
│ ├── languages/
│ │ ├── de/default.php # German labels
│ │ └── en/default.php # English labels
│ └── templates/twig/
│ ├── .twig-root # Namespace root marker
│ └── content_element/
│ ├── faq_structured.html.twig # Default template (Handorgel)
│ └── faq_structured/
│ ├── bootstrap.html.twig # Bootstrap 5 Accordion
│ └── tailwind.html.twig # Tailwind CSS Details
├── src/
│ ├── ContaoFaqStructuredBundle.php # AbstractBundle (loads services.php)
│ ├── ContaoManager/
│ │ └── Plugin.php # Bundle registration via BundlePluginInterface
│ └── Controller/
│ └── ContentElement/
│ └── FaqStructuredController.php # Content element logic
└── tests/
├── Controller/ContentElement/
│ └── FaqStructuredControllerTest.php # Controller unit test
└── JsonLd/
└── FaqPageGraphTest.php # Regression test: one graph node per element
Request Flow
- Contao renders the content element
faq_structured FaqStructuredController::getResponse()is called- FAQ entries are deserialized from the serialized
faqItemsfield; empty and malformed entries are dropped - Schema.org FAQPage JSON-LD is built as an array, including an element specific
identifier - Template receives:
faq_items,schema_org_data,accordion_id - JSON-LD is injected into the
<head>via{% do add_schema_org(schema_org_data) %}
Dependencies
| Package | Version | Purpose |
|---|---|---|
php |
^8.3 | Minimum PHP version |
contao/core-bundle |
^5.7 | Contao framework |
doctrine/dbal |
^3.6 || ^4.0 | MySQLPlatform constant for the faqItems blob column |
mvo/contao-group-widget |
^1.6 | Repeatable field groups in the backend (inputType group) |
symfony/dependency-injection |
^7.4 | Service configuration in config/services.php |
symfony/http-foundation |
^7.4 | Request/Response in the controller |
symfony/http-kernel |
^7.4 | AbstractBundle base class |
Development dependencies (PHPUnit, contao/test-case, ECS, PHPStan, Rector, composer-dependency-analyser) are listed under Development.
Setup / Installation
Requirements:
- Contao Managed Edition 5.7
- PHP 8.3+
mvo/contao-group-widget^1.6 (automatically installed as a dependency)
Installation:
The bundle lives as a local package under packages/AvhContaoFaqStructuredBundle/ and is wired in through a path repository in the project's composer.json:
"repositories": { "local-packages": { "type": "path", "url": "packages/*", "options": { "symlink": true } } }, "require": { "avh/contao-faq-structured-bundle": "*@dev" }
ddev composer install
After changes to Plugin.php, ddev composer install must be run again — a cache:clear is not sufficient.
Configuration & Usage
Backend Operation
- Create content element: In the article section, create a new content element of type FAQ (Structured Data) (category: Texts).
- Manage questions and answers: In the FAQ Entries section, add any number of question-answer pairs. Answers support rich text (TinyMCE).
- Select template: Use the Custom Template field (
customTpl) to choose one of the three variants. Without selection, the Handorgel template is used.
Available Backend Fields
| Field | Type | Description |
|---|---|---|
faqItems |
Group widget | Repeatable question-answer pairs |
faqQuestion |
Text | Question text (max. 512 characters, required) |
faqAnswer |
Textarea (RTE) | Answer text with TinyMCE editor (required) |
Template Variants
| Variant | File | CSS/JS Dependency | Behavior |
|---|---|---|---|
| Handorgel (default) | faq_structured.html.twig |
contao-components/handorgel (CSS + JS, automatically included) |
First item open, only one item at a time (multiSelectable: false) |
| Bootstrap 5 | faq_structured/bootstrap.html.twig |
Bootstrap 5 must be available in the project | Accordion with data-bs-toggle="collapse", first item open |
| Tailwind CSS | faq_structured/tailwind.html.twig |
Tailwind CSS must be available in the project | Native <details>/<summary> element, no JavaScript, first item open |
Template Selection Guide
- Handorgel: Standalone solution without framework dependency. Suitable for projects without a CSS framework.
- Bootstrap 5: When the project already uses Bootstrap 5. Seamless integration with existing accordion styling.
- Tailwind CSS: Minimal footprint, no JavaScript. Uses native browser behavior (
<details>). Ideal for Tailwind projects.
Schema.org / Structured Data
The bundle automatically generates FAQPage JSON-LD for each FAQ element. The structure is inserted into the <head> via Contao's add_schema_org() function in the template's metadata block.
Example Output
Contao collects all schema.org nodes of a page in a single @graph. One FAQ element contributes one FAQPage node:
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "FAQPage",
"@id": "#/schema/faq-structured/42",
"mainEntity": [
{
"@type": "Question",
"name": "What is Contao?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Contao is an open-source content management system."
}
}
]
}
]
}
One node per element
Every faq_structured element writes its own FAQPage node with a stable identifier of the form #/schema/faq-structured/<tl_content.id>, which Contao serializes to @id.
That identifier is not cosmetic. Contao stores the node under $jsonLd['identifier'] ?? Graph::IDENTIFIER_DEFAULT, and Spatie\SchemaOrg\Graph::set() overwrites an existing entry without any collision check. Without an own identifier, two FAQ elements on the same page would both be stored as FAQPage/default and the second one would silently replace the first. The prefix deliberately differs from contao/faq-bundle, which occupies #/schema/faq/<tl_module.id>.
Known limitation: the JSON-LD is written into the response context of the current request. If you force the fragment renderer to esi and the reverse proxy actually resolves the ESI tag, the fragment renders in a separate request and its structured data is lost. The default renderer keeps the response context, so this does not apply out of the box.
Answer text in the JSON-LD
In the JSON-LD output, questions and answers are converted to plain text with Contao's HtmlDecoder — insert tags are resolved, input encoding is reverted, HTML tags are stripped and whitespace is normalized. This is the exact conversion contao/faq-bundle applies to its own FAQs (ModuleFaq::getSchemaOrgData()).
The rendered frontend output is unaffected and keeps the full rich-text formatting.
Structured data and search results
FAQ rich results have been discontinued. Google removed the FAQ result appearance from Google Search on 7 May 2026. Support in the Rich Results Test and in the Search Console rich result report was withdrawn in June 2026, and Search Console API support ends in August 2026. The earlier exception for government and health sites no longer applies to anyone. FAQPage markup therefore no longer produces expandable questions inside a Google search result — and this bundle does not claim that it does.
The markup stays in place anyway. Google states explicitly that there is no need to proactively remove it: structured data that is not being used does not cause problems for Search. Reasons to keep emitting it:
- FAQPage is an unchanged, valid part of the schema.org vocabulary. What Google retired was a presentation, not the type.
- Other consumers still read it: other search engines, AI answer engines and LLM crawlers, site search, analytics and content-audit tooling, and your own pipelines.
- Removing it would be a breaking change for every installation that reads the JSON-LD, for no gain.
What this bundle guarantees: valid, machine-readable FAQPage JSON-LD in the <head> of the page, one node per content element, each with its own @id. It guarantees nothing about the presentation in any particular search engine.
Do not expand FAQ markup to more pages in the expectation of a visibility effect. The self-contained question-and-answer HTML structure is the part that still carries weight for quotability — independently of the JSON-LD.
Validation: use a generic schema.org validator such as https://validator.schema.org/. The Google Rich Results Test no longer reports FAQPage.
Development
The bundle ships a full quality gate. Run it with a single command:
composer install
composer all # rector --dry-run, ecs check --fix, phpstan, depcheck, phpunit
| Command | Tool | Purpose |
|---|---|---|
composer rector |
Rector + contao/contao-rector |
Automated refactoring, dry-run only |
composer ecs |
contao/easy-coding-standard |
Contao/Symfony coding standard, auto-fixing |
composer phpstan |
PHPStan level 6 | Static analysis of src/ and tests/ |
composer depcheck |
composer-dependency-analyser |
Unused, shadow and misplaced dependencies |
composer tests |
PHPUnit | Unit and regression tests |
tests/JsonLd/FaqPageGraphTest.php is the regression test for the identifier described under One node per element: it drives Contao's real SchemaOrgRuntime/JsonLdManager chain and asserts that two elements produce two FAQPage nodes. Removing the identifier from the controller makes it fail with one node — the original bug.
CI runs the non-mutating forms of all five steps on PHP 8.3, plus the test suite on PHP 8.4 (.github/workflows/ci.yml).
Important Notes
- Dependency
mvo/contao-group-widget: The bundle uses theinputTypegroupfor repeatable field groups. Without this package, the backend form will not work. - Cache: After changes to FAQ content, the page cache must be refreshed for the updated JSON-LD data to be served.
- Empty entries: Question-answer pairs without a question or answer are automatically filtered out and appear neither in the frontend nor in the JSON-LD. Malformed data in the serialized blob is skipped instead of raising a
TypeError. - Field permissions:
faqItemsis declared withexclude => true, so it is subject to Contao's field-level permissions. After updating from 1.0.0, grant the field to your editor groups under Allowed fields — otherwise they can no longer edit FAQ entries. .twig-rootfile: The filecontao/templates/twig/.twig-rootis mandatory for Contao to correctly recognize the template variants.- Extensibility: Custom template variants can be added as subfolder templates under
contao/templates/twig/content_element/faq_structured/. Alternatively, templates can be overridden in the project undertemplates/.