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)

Maintainers

Package info

github.com/avhulst/AvhContaoFaqStructuredBundle

Type:contao-bundle

pkg:composer/avh/contao-faq-structured-bundle

Transparency log

Statistics

Installs: 47

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

1.0.1 2026-08-24 04:22 UTC

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

  1. FAQ Content Element — New content type faq_structured in the Texts category. Unlimited question-answer pairs per element.
  2. Schema.org FAQPage JSON-LD — Automatic generation of structured data in FAQPage format. Output in the <head> of the page.
  3. Three Template Variants — Handorgel (default), Bootstrap 5 Accordion, Tailwind CSS with <details> element.
  4. 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

  1. Contao renders the content element faq_structured
  2. FaqStructuredController::getResponse() is called
  3. FAQ entries are deserialized from the serialized faqItems field; empty and malformed entries are dropped
  4. Schema.org FAQPage JSON-LD is built as an array, including an element specific identifier
  5. Template receives: faq_items, schema_org_data, accordion_id
  6. 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

  1. Create content element: In the article section, create a new content element of type FAQ (Structured Data) (category: Texts).
  2. Manage questions and answers: In the FAQ Entries section, add any number of question-answer pairs. Answers support rich text (TinyMCE).
  3. 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 the inputType group for 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: faqItems is declared with exclude => 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-root file: The file contao/templates/twig/.twig-root is 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 under templates/.