TYPO3 extension Questionnaire 

Extension key

pn_questionnaire

Package name

proudnerds/pn-questionnaire

Version

main

Language

en

Authors

Jacco van der Post, Emile Blume — ProudNerds, and contributors

License

This document is published under the Open Publication License.

Rendered

Wed, 02 Sep 2026 14:04:55 +0000


Build multi-step questionnaires, self-assessments, tests and decision trees from backend records: conditional advice per answer, a personalised result page, and an anonymously stored result the visitor can retrieve later or mail to themselves — without writing a line of code.


Table of Contents:

Introduction 

What it does 

pn_questionnaire lets editors build multi-step questionnaires entirely in the TYPO3 backend. Every part of a questionnaire is a record: the questionnaire itself, its questions, the answer options, the visibility conditions between them, the result pages and the advice blocks on those pages. Answers decide which questions appear next and which advice the visitor ends up with.

Typical use cases:

  • Step-by-step decision trees that route a visitor to the right service
  • Scored self-assessments — health checks, sustainability scans, skills tests
  • Guided quizzes with a personalised result at the end
  • Multi-path flows where earlier answers determine which questions follow

Features 

  • Five question types: single choice, multiple choice, yes/no, scale (radio buttons or a range slider) and informational screens without input
  • Visibility conditions per question, on a specific answer or on the value of a scale question, combined with AND/OR
  • Result pages selected by score range, by a specific answer, by a scale answer, by a combination, or as a catch-all
  • Advice blocks shown conditionally per answer or score, with the option to invert a condition so a block appears when an answer was not given
  • Group headings that give a long result a nested heading structure; a heading appears only when at least one block in its group is visible
  • Outcomes rendered inline, or as a redirect to a page, an external URL or the detail view of a record from another extension
  • Anonymous result storage with a retrieval link, and a mail-to-self form with a rate limit
  • Usage counters per questionnaire for starts and completions
  • A frontend aimed at WCAG 2.2 AA, working without JavaScript

Integration 

  • Configured per plugin instance through a FlexForm; TypoScript sets the site-wide defaults
  • Fluid templates, partials and layouts overridable from a site package
  • BEM class names on semantic markup — no design imposed
  • English, Dutch and German labels included

Compatibility 

TYPO3 PHP Extension
14.3 8.2 – 8.4 1.0 and up
13.4 8.2 – 8.4 1.0 and up
12.4 8.2 – 8.4 1.0 and up

One codebase serves all three versions; each is verified with a full run-through. Privacy is a design constraint rather than a feature: a stored result holds no name, no IP address and no e-mail address, not even hashed.

Examples 

Answering a question 

A questionnaire step with a progress bar at 20 percent, a required-field message and three radio options

A single-choice question with the progress bar in completed mode and the client-side message for a required question.

What is visible here:

  • The progress bar with the percentage beside it. It counts the questions already answered, not the one on screen: 20% means one of five questions is behind the visitor.
  • The asterisk before the question text marks it as required; the message below appears when the visitor tries to continue without answering.
  • The three answer options of a single choice question, and the Next button, whose label can be changed per plugin instance.

The result page 

A result page with grouped advice blocks, a retrieval link with copy button and a mail-to-self form

An inline result with group headings, the retrieval link and the mail form — both switched on in the plugin settings.

What is visible here:

  • The result headline and body text from the result page record, followed by the advice blocks that matched the visitor's answers.
  • Four group headings, each with the blocks that belong under it. A heading appears only when at least one of its blocks is visible.
  • Bewaar je uitslag — the retrieval link, with the date it expires and a copy button, plus a link to the privacy statement.
  • Mail je uitslag naar jezelf — the mail form. The address is used for that one mail and is not stored.

Installation 

Quick start 

  1. Install the extension (see Composer below) and run the Database Analyser in the Install Tool.
  2. Load the TypoScript: on TYPO3 v13 and v14 add the Site Set proudnerds/pn-questionnaire to the site configuration, on v12 include the static template Questionnaire / Test / Decision tree. See Loading the TypoScript.
  3. Create a Questionnaire record in the List module — a dedicated sysfolder keeps things tidy.
  4. Add questions, answer options and at least one result page to that record. See Building a questionnaire.
  5. Place the Questionnaire / Decision Tree plugin on a page and select the questionnaire record in its FlexForm.

Composer 

composer require proudnerds/pn-questionnaire
Copied!

TYPO3 picks the extension up automatically; there is nothing to activate by hand.

Classic mode 

Install Questionnaire / Test / Decision tree from the TYPO3 Extension Repository through Admin Tools > Extensions, then activate it there.

Both routes 

After the first install, run the Database Analyser in the TYPO3 Install Tool to create the seven tables, and load the TypoScript as described below.

Upgrading 

Run the Database Analyser again after any update that adds a field; the changelog says so per release. Version 1.1.0 for instance added introduction_footer_text to the questionnaire table. Existing records and plugin settings keep working as they are — no migration wizard is needed.

Loading the TypoScript 

The TypoScript itself lives in Configuration/TypoScript/setup.typoscript and constants.typoscript — a location every supported TYPO3 version understands. It is offered through two routes, both pointing at those same two files:

TYPO3 version Route
14.3 Site Set proudnerds/pn-questionnaire (preferred)
13.4 Site Set proudnerds/pn-questionnaire (preferred)
12.4 Static template Questionnaire / Test / Decision tree

Site Sets were introduced in TYPO3 v13.1 and are ignored by v12, which is why the static template exists. The files in Configuration/Sets/PnQuestionnaire/ contain nothing but an @import of the files above.

On TYPO3 v13 and v14 — Site Set 

Add the set to any site that should use the questionnaire plugin:

config/sites/your-site/config.yaml
dependencies:
  - proudnerds/pn-questionnaire
Copied!

The set name is defined in Configuration/Sets/PnQuestionnaire/config.yaml:

EXT:pn_questionnaire/Configuration/Sets/PnQuestionnaire/config.yaml
name: proudnerds/pn-questionnaire
label: Questionnaire / Test / Decision tree
Copied!

TYPO3 automatically includes setup.typoscript and constants.typoscript from the set directory when the dependency is active.

On TYPO3 v12 — static template 

The extension registers Configuration/TypoScript/ as a selectable static template in Configuration/TCA/Overrides/sys_template.php. Go to Web > Template, open the root template record and add Questionnaire / Test / Decision tree (pn_questionnaire) to Include static (from extensions).

Include it before any site package that overrides the view path constants, otherwise those overrides are undone again.

Do not combine the static template with a site package that already imports EXT:pn_questionnaire/Configuration/TypoScript/setup.typoscript directly — the values are identical so setup stays correct, but a second pass over the constants can override a site package's view path overrides depending on include order.

The retrieval route 

Storing results produces a link of the form /{plugin-page}/saved-result/{token}. An extension cannot register route enhancers itself, so the site configuration has to import the shipped one:

config/sites/your-site/config.yaml
imports:
  - resource: 'EXT:pn_questionnaire/Configuration/Routing/SavedResult.yaml'
Copied!

Without that import the retrieval link still works, but as a query-string URL. See The retrieval route for the details.

Backend search on TYPO3 v14 

On v12 and v13 the records of this extension declare their backend search fields explicitly. TYPO3 v14 dropped that mechanism and derives searchability from the field type instead, so on v14 a search in the list module covers every text-like field of these tables rather than a curated subset. The extension follows each version's own convention; nothing needs configuring.

Configuration 

The extension is configured on two levels:

Level Where Scope
FlexForm set in the backend, on the plugin Per plugin instance
TypoScript Configuration/TypoScript/ Per site or page tree

For every setting that exists on both levels, the FlexForm value wins over the TypoScript default. An empty FlexForm field falls back to TypoScript, and where TypoScript is empty too, to the installation's own setting. Leaving a field empty is therefore meaningful: it means "use what is configured one level up".

All TypoScript lives under plugin.tx_pnquestionnaire_questionnaire .

View paths 

Override the template paths per site package using the constants. You only need to place the files you want to override; TYPO3 merges these with the extension defaults using indexed path arrays, where index 1 overrides index 0.

plugin.tx_pnquestionnaire_questionnaire {
  view {
    templateRootPath = EXT:your_sitepackage/Resources/Private/Templates/PnQuestionnaire/
    partialRootPath  = EXT:your_sitepackage/Resources/Private/Partials/PnQuestionnaire/
    layoutRootPath   = EXT:your_sitepackage/Resources/Private/Layouts/PnQuestionnaire/
  }
}
Copied!

view.templateRootPath

view.templateRootPath
Type
string
Default
EXT:pn_questionnaire/Resources/Private/Templates/

Where the extension looks for Questionnaire/Intro.html, Question.html and Result.html. See Templates, styling and JavaScript.

view.partialRootPath

view.partialRootPath
Type
string
Default
EXT:pn_questionnaire/Resources/Private/Partials/

Where the answer type partials, the advice block, the progress bar and the button label partial are looked up.

view.layoutRootPath

view.layoutRootPath
Type
string
Default
EXT:pn_questionnaire/Resources/Private/Layouts/

Where Default.html is looked up. That layout also loads the JavaScript, so an override has to keep the asset tags or replace them.

Plugin defaults 

These settings have a FlexForm counterpart. The value here is what a plugin instance uses as long as its own field is untouched.

plugin.tx_pnquestionnaire_questionnaire.settings {
  show_score          = 0
  show_answer_summary = 0
  introduction_screen = 1
}
Copied!

settings.show_score

settings.show_score
Type
boolean
Default
0

Show the calculated score on an inline result page. Overridden by settings.show_score.

settings.show_answer_summary

settings.show_answer_summary
Type
boolean
Default
0

Show a recap of the given answers on the result page. Overridden by settings.show_answer_summary.

settings.introduction_screen

settings.introduction_screen
Type
boolean
Default
1

Show a dedicated introduction screen with a start button before the first question. Overridden by settings.introduction_screen.

What the progress bar measures 

settings.progress_mode

settings.progress_mode
Type
string
Default
completed

Two conventions are in common use for a questionnaire, and the site picks one. There is deliberately no FlexForm counterpart: this is a design decision for the whole site, and having one questionnaire count differently from the next would only confuse visitors.

completed
Counts the questions already answered, so the bar never claims to be finished while there are still answers to give, and the result page is what completes it.
position
Follows the step number, the convention many questionnaires use, at the cost of a bar that is already full on the closing question.
Value Question 1 of 5 Question 5 of 5 Result page
completed (default) 0% 80% 100%
position 20% 100% 100%

The counter above the bar reads "step X of Y" either way — that number is the visitor's position and does not change with this setting. Under completed it therefore says "step 1 of 5" next to an empty bar, which is intentional: the step is the one being answered, the bar is what lies behind it.

Stored results and mail 

Fallbacks for the fields on the Storing and mailing the result tab of the plugin. Storing and mailing themselves are switched on per plugin instance only; there is no TypoScript equivalent for the two on/off fields, by design — a site-wide default that starts writing visitor data would be the wrong default.

plugin.tx_pnquestionnaire_questionnaire.settings {
  db_save_result_storage_pid   =
  db_save_result_lifetime_days = 365
  mail_from_address            =
  mail_from_name               =
}
Copied!

settings.db_save_result_storage_pid

settings.db_save_result_storage_pid
Type
page uid
Default
(empty)

The folder stored results are written to. Empty means the plugin's own page is used, and a warning is written to the log — the FlexForm field is the intended place to set this. Overridden by settings.db_save_result_storage_pid.

settings.db_save_result_lifetime_days

settings.db_save_result_lifetime_days
Type
int
Default
365

How many days a stored result stays available. Applied when the result is stored, by writing the expires timestamp; changing it afterwards does not move existing rows. Overridden by settings.db_save_result_lifetime_days.

settings.mail_from_address

settings.mail_from_address
Type
string
Default
(empty)

Sender address of the result mail. Empty falls back to $GLOBALS['TYPO3_CONF_VARS']['MAIL']['defaultMailFromAddress'] . Overridden by settings.mail_from_address.

settings.mail_from_name

settings.mail_from_name
Type
string
Default
(empty)

Sender name of the result mail. Empty falls back to defaultMailFromName of the installation.

Domain record outcomes 

The Domain record outcome type redirects the visitor to the detail view of a record from another extension. One target can be configured per site, and it takes both TypoScript and a TCA override.

Step 1 — TypoScript 

plugin.tx_pnquestionnaire_questionnaire.settings {
  domain_record_target {
    pageUid    = 42    # UID of the page that hosts the detail plugin
    extension  = News  # Extension name (CamelCase, no vendor prefix)
    controller = News
    action     = detail
    argument   = news  # Argument name the action expects
    plugin     = Pi1   # Plugin name as registered with ExtensionUtility
  }
}
Copied!

settings.domain_record_target

settings.domain_record_target
Type
array

The single record type that may be used as a domain_record outcome. All six keys are required; the service builds the redirect URL from them with UriBuilder . Leave the whole block out when no result page uses this outcome type.

Step 2 — TCA override 

The extension ships record_uid as a plain number, because it cannot know which table your site links to. The site package names that table, and turns the field into a record browser while it is at it:

EXT:your_sitepackage/Configuration/TCA/Overrides/tx_pnquestionnaire_result_page.php
$config = &$GLOBALS['TCA']['tx_pnquestionnaire_result_page']['columns']['record_uid']['config'];
$config['type'] = 'group';
$config['allowed'] = 'tx_news_domain_model_news';
$config['foreign_table'] = 'tx_news_domain_model_news';
$config['size'] = 1;
Copied!

type and allowed give you the record browser; foreign_table is what Extbase needs to resolve the record. Both table names must match the TypoScript target — the service reads foreign_table from $GLOBALS['TCA'] at runtime, so there is a single source of truth.

Plugin settings (FlexForm) 

These live on the plugin instance, so the same questionnaire record can behave differently on different pages. The Editor Guide describes what they do in practice; the reference below names the fields.

Tab: Settings 

settings.questionnaire

settings.questionnaire
Type
record uid
Required
true

Backend label: Questionnaire.

Which questionnaire record to display. The record is looked up regardless of the folder it is stored in.

settings.introduction_screen

settings.introduction_screen
Type
boolean
Default
(from TypoScript: 1)

Backend label: Show introduction screen.

Show the introduction screen with a start button before the first question. Without it, the visitor lands on question one straight away.

settings.show_score

settings.show_score
Type
boolean
Default
(from TypoScript: 0)

Backend label: Show score on result.

Show the calculated score on an inline result page. Advice blocks with a score condition work whether or not the score is shown.

settings.show_answer_summary

settings.show_answer_summary
Type
boolean
Default
(from TypoScript: 0)

Backend label: Show answer summary.

Show a recap of the visitor's given answers on the result page.

settings.statistics_enabled

settings.statistics_enabled
Type
boolean
Default
0

Backend label: Count usage.

Keep a tally on the questionnaire record of how often it was started and completed. See Usage counters.

Tab: Button labels 

Every button the visitor sees carries a translated standard text that follows the language of the site. A field left empty keeps that standard text, which is shown as the field's placeholder — there is no need to fill in all of them.

The last two fields only have an effect once their function is switched on: the copy button belongs to the retrieval link, the send button to the mail form.

settings.button_start

settings.button_start
Type
string
Default
Start

Backend label: Start button.

Label of the button on the introduction screen.

settings.button_previous

settings.button_previous
Type
string
Default
Previous

Backend label: Previous button.

Label of the button that goes back one question.

settings.button_next

settings.button_next
Type
string
Default
Next

Backend label: Next button.

Label of the submit button on every question but the last.

settings.button_finish

settings.button_finish
Type
string
Default
Finish

Backend label: Finish button.

Label of the submit button on the last visible question.

settings.button_reset

settings.button_reset
Type
string
Default
Start over

Backend label: Start over button.

Label of the button that clears the session and returns to the start. It appears once at least one answer has been given.

settings.button_change_answers

settings.button_change_answers
Type
string
Default
Change my answers

Backend label: Change answers button.

Label of the button on the result page that returns to the questions with the given answers intact.

settings.button_mail_submit

settings.button_mail_submit
Type
string
Default
Send

Backend label: Send mail button.

Label of the submit button of the mail-to-self form.

Tab: Storing and mailing the result 

settings.db_save_result_enabled

settings.db_save_result_enabled
Type
boolean
Default
0

Backend label: Store the result.

Store the result and offer the visitor a link to return to it later. See Stored results and the result mail.

settings.db_save_result_storage_pid

settings.db_save_result_storage_pid
Type
page uid
Default
(from TypoScript, then the current page)

Backend label: Folder for the stored results.

The folder the stored results are filed in. Required as soon as storing is on.

settings.db_save_result_lifetime_days

settings.db_save_result_lifetime_days
Type
int
Default
(from TypoScript: 365)

Backend label: Keep the result for (days).

After this many days a stored result is due for removal by the purge command.

settings.mail_result_enabled

settings.mail_result_enabled
Type
boolean
Default
0

Backend label: Allow mailing the result.

Show a form on the result page where the visitor can mail the result to themselves.

settings.mail_from_address

settings.mail_from_address
Type
string
Default
(from TypoScript, then the installation)

Backend label: Sender address of the mail.

Sender address of the result mail for this plugin instance.

settings.mail_from_name

settings.mail_from_name
Type
string
Default
(from TypoScript, then the installation)

Backend label: Sender name.

Sender name of the result mail for this plugin instance.

settings.mail_intro_text

settings.mail_intro_text
Type
text
Default
(standard text)

Backend label: Opening text of the mail.

Replaces the standard opening line of the mail.

settings.mail_rate_limit_disabled

settings.mail_rate_limit_disabled
Type
boolean
Default
0

Backend label: Switch off the send limit.

Switches off the send limit of the mail form.

Building a questionnaire 

This chapter explains how to build and manage questionnaires in the TYPO3 backend. No technical knowledge is required.

Concepts 

Term What it is
Questionnaire The top-level record that holds everything — questions, settings and result pages
Question A single step shown to the visitor, one per screen
Answer option A selectable choice within a question
Condition A rule that shows or skips a question based on a previous answer
Result page What happens when the visitor finishes — shown inline or as a redirect
Advice block An optional content section within an inline result, conditionally visible
Score A numeric value on an answer option, summed into a total at the end

Step 1 — Create a questionnaire record 

  1. Open the List module and navigate to the folder where questionnaire records should be stored. A dedicated sysfolder keeps the list module tidy.
  2. Create a new Questionnaire record.
  3. Fill in the Title. This is an internal label, never shown to the visitor.
  4. On the Introduction screen tab, optionally fill in:

    Introduction text
    Appears before the first question, above the start button.
    Text below the start button
    A closing remark — for instance how long the questionnaire takes, or what happens with the answers.

    Both are only shown when the introduction screen is enabled in the plugin settings, and both may be left empty.

  5. Save the record.

A questionnaire record carries the standard TYPO3 access fields as well: hide it to take the questionnaire offline, or set a publish date and an expiry date to have it appear and disappear on its own. A hidden or expired questionnaire renders nothing in the frontend.

Step 2 — Place the plugin on a page 

  1. Edit the page where the questionnaire should appear.
  2. Add a new content element and pick Questionnaire / Decision Tree from the plugins tab.
  3. On the Configuration tab, select the questionnaire record in the Questionnaire field.
  4. Adjust the remaining settings if needed — see Plugin settings (FlexForm).
  5. Save.

Step 3 — Add questions 

Inside the questionnaire record, open the Questions tab and click Create new Question.

The Questions tab of a questionnaire record with the question type dropdown open, showing five types

A question record with the Question type dropdown open. The fields below it change with the type that is chosen.

Field Description
Question The question text shown to the visitor (supports rich text)
Help text Optional short instruction below the question
Context content element Optional link to an existing content element — useful for adding an image, a video or formatted text as context
Question type The answer type — see Question types
Required Whether the visitor must answer before proceeding
Scale minimum / maximum Lower and upper bound of the numeric scale (scale questions only)
Scale display Radio buttons or a range slider (scale questions only)

Questions are shown in the order they appear in the list. Drag and drop to reorder, and hide a question to take it out of the flow without deleting it.

Step 4 — Add answer options 

For Single choice, Multiple choice and Yes / No questions, open the Answer Options tab of the question and add one option per selectable answer.

Field Description
Label The text shown to the visitor
Value Internal identifier, used in conditions and result triggers. Use something descriptive: employed, yes, option_a
Score Optional numeric score, positive or negative, decimals allowed — only relevant for score-based results

Drag and drop answer options to reorder them.

Step 5 — Add conditions (branching) 

A condition makes a question only visible when a specific previous question was answered in a specific way. This is how a decision tree is built. There are two condition types.

Condition type: Specific answer 

  1. Open the Visibility Conditions tab of the question that should be conditionally shown.
  2. Click Create new Condition.
  3. Set Condition type to Specific answer.
  4. Select the Reference question — the earlier question whose answer decides visibility.
  5. Select the Reference answer — the answer option that must have been chosen.
  6. Save.

Condition type: Scale value 

Use this when the reference question is a Scale / Rating question and visibility depends on the number the visitor picked.

  1. Open the Visibility Conditions tab.
  2. Click Create new Condition.
  3. Set Condition type to Scale value.
  4. Select the Reference question; only scale questions qualify.
  5. Choose an Operator: >=, <=, >, < or =.
  6. Enter a Value to compare against.
  7. Save.

For example: show a follow-up question only when the visitor rated satisfaction >= 8.

Multiple conditions 

A question can have more than one condition. Each condition has an Operator field of its own:

Operator Meaning
AND This condition must also pass — all AND conditions must be met
OR Passing this condition is enough on its own

Conditions are evaluated in the order they appear in the list, top to bottom. See Condition evaluation for the exact evaluation order.

Rules 

  • Conditions can only reference questions that appear earlier in the questionnaire. Keeping to that is the editor's responsibility; nothing enforces it.
  • A question with no conditions is always shown.
  • Skipped questions count neither toward the progress nor the score.
  • For a Scale value condition: when the referenced question has not been answered, the condition fails and the dependent question stays hidden.

Example 

Q1: What is your situation?
    → "Employed"           → show Q2a (work questions)
    → "Self-employed"      → show Q2b (entrepreneur questions)
    → "Looking for work"   → show Q2c (job-seeker questions)
Copied!

Q2a has one condition: type Specific answer, reference question Q1, reference answer "Employed". Q2b and Q2c work the same way with their own answer.

Step 6 — Add result pages 

Open the Result Pages tab of the questionnaire and click Create new Result Page.

The Result Pages tab with a result page record: outcome type inline, headline, content, button fields and a list of advice blocks

A result page with outcome type Inline. Headline and content are shown to the visitor, followed by the advice blocks listed at the bottom.

Each result page answers two questions: when does it apply, and what happens then.

Trigger — when does this result apply? 

Trigger type When it matches
Catch-all Always — use as the last result page, as a fallback
Score range When the total score is between score min and max, inclusive
Specific answer When the visitor selected a specific answer option, anywhere in the questionnaire
Combination When both the score range and the specific answer match
Scale answer range When the numeric answer for a specific scale question falls within the configured range, inclusive

When Scale answer range is selected, three extra fields appear: the Scale question whose answer is checked (only scale questions are listed), and the inclusive Minimum value and Maximum value.

Outcome — what happens when it applies? 

Outcome type What happens
Inline Result content — headline, body text, advice blocks, button — is shown within the questionnaire on the same page
Internal page The visitor is redirected to a TYPO3 page you select from the page tree
External URL The visitor is redirected to a URL you enter
Domain record The visitor is sent to the detail view of a record from another extension: a news article, event, vacancy, ad or activity. Requires configuration by a developer, see Domain record outcomes

The fields of an inline outcome:

Field Description
Headline Result headline
Content Main result text (rich text — links can go in here)
Button label Call-to-action button text, optional
Button link Call-to-action button target, optional
Advice blocks Conditional content sections, see Step 7

Step 7 — Add advice blocks 

Advice blocks are conditional content sections within an inline result page. They show personalised advice depending on the visitor's score or a specific answer, within the same result. They are only available when the outcome type is Inline.

An advice block record with a scale range condition, an invert checkbox, a group heading reference, a headline and rich text content

An advice block with a scale answer range condition, the invert checkbox, and a reference to the group heading it belongs under.

Field Description
Show this block When to show it: Always / Score range / Specific answer / Scale answer range / Group heading above other blocks
Score min / max Visible score range (score range condition only)
When answer The answer that must have been given (specific answer condition only)
Invert Show the block when the chosen answer was not given
Scale question The scale question whose value is checked (scale answer range only)
Minimum / Maximum value Inclusive range the scale answer must fall within
Part of group Places this block under a group heading
Headline Block heading
Content Block content (rich text)

Advice blocks appear in the order of the list, and only those whose condition is met are rendered.

Inverting a condition 

Invert turns a specific answer condition around: the block appears when the visitor did not choose that answer.

That is useful for advice which only applies to people who skipped something. Take a question about organ donation with an option "I have already registered my choice". Attach a block to that option, tick Invert, and everyone who did not register gets the reminder — while the people who did are spared a message that does not apply to them.

Grouping blocks under a heading 

A long result page reads better in sections. Set Show this block to Group heading above other blocks to turn a block into a heading, then set Part of group on every block that belongs underneath it.

What to keep in mind:

  • A group heading has no condition of its own. It appears automatically when at least one block in its group is visible, and disappears when none are. The heading's visibility never has to be maintained by hand.
  • A group heading has no content — only a headline.
  • The headline of a grouped block moves one heading level deeper than an ungrouped one. That matters for the rich text in the body: sub-headings inside a grouped block belong on Heading 5, not Heading 4. Using the wrong level skips a level in the document outline, which screen readers and search engines both read as an error. See Heading levels, and why the RTE needs Heading 4 and Heading 5 for what an administrator has to make available in the RTE.
  • Blocks that are not part of any group keep their normal level and can sit before, between or after the groups.

Question types 

Type Input Use for
Single choice Radio buttons — one answer Most questions
Multiple choice Checkboxes — one or more answers "Select all that apply"
Yes / No Two radio buttons, with labels you define Binary questions
Scale / Rating Numbered scale from min to max, as radio buttons or a range slider Assessments, NPS, ratings
Informational No input — a Next button only Intermediate explanation, section break

Scale questions have a Scale display field:

Option Description
Range slider (default) A draggable slider showing the selected value live. Starts at the midpoint of the range when no answer is stored yet
Radio buttons Individual numbered radio inputs, one per step in the range

Tips and rules of thumb 

Questionnaire structure 

  • Start with a sketch on paper of the question flow and the possible outcomes before building anything in the backend.
  • Use descriptive internal titles on questionnaire and result page records; the next editor will thank you.
  • Store questionnaire records in a dedicated sysfolder.

Conditions 

  • Only reference questions that appear above the current one in the list.
  • Test the branching by walking through the questionnaire as a visitor — condition mistakes are easy to miss in the backend.
  • Use the Start over button while testing to reset the session. It only appears after the first answer has been submitted.

Result pages 

  • Always add a catch-all result as the last entry. Without it, a visitor who matches no trigger sees an error message.
  • For score-based questionnaires, make sure the score ranges cover every possible total without gaps.

Scoring 

  • Scores can be positive or negative: +2 for a healthy choice, -1 for a risk factor.
  • The score is only shown to the visitor when settings.show_score is on.
  • Advice blocks with a score condition work whether or not the score is displayed.

Reuse 

  • The same questionnaire record can be placed on multiple pages — useful for embedding the same tool in different contexts.
  • Each placement has its own settings, so the score can be shown on one page and hidden on another.

Stored results and the result mail 

Both are off by default and are switched on per plugin instance, with settings.db_save_result_enabled and settings.mail_result_enabled. This chapter covers what happens once they are on — the editor-facing side as well as the technical one.

What the visitor gets 

With storing on, the result page gains a link the visitor can save or share. Opening it later brings back the same result, without a login and without the visitor's session.

Nothing identifying is stored: no name, no IP address, no e-mail address, not even in hashed form. What is stored is the given answers, the score and a random code that appears in the link. Someone without the link cannot find the result, and the answers cannot be traced back to a person.

Two consequences worth knowing:

  • The result disappears once the retention period expires. Say so in the text around the link if visitors are likely to rely on it.
  • The link is the only key. A visitor who loses it cannot recover the result, and neither can an administrator.

Point settings.privacy_link at the page where your organisation explains this. The extension only links to it — the text itself is yours to write, and it should mention the retention period and that no personal data is stored.

With mailing on, the result page shows a form asking for an e-mail address. The visitor receives the full result plus the retrieval link. The address is used to send that one mail and is not stored — not in the database, not in a log, not on the stored result.

The table 

tx_pnquestionnaire_saved_result holds one row per completed run.

Column Purpose
token 32 hex characters, 128 bits of entropy, from random_bytes(). Unique index. The only key to the row
result_url The generated retrieval URL, cached so the mail and the result page do not have to rebuild it
questionnaire The questionnaire the run belongs to
answers The given answers, as JSON
score The calculated score
expires Unix timestamp after which the row is due for removal
crdate, tstamp, deleted Standard TYPO3 fields; the table uses soft delete

The crdate timestamp is the only field that could in theory be correlated with an access log, which is a reason not to extend the retention period beyond what is needed.

The retrieval route 

Configuration/Routing/SavedResult.yaml provides a route enhancer producing /{plugin-page}/saved-result/{token}. The site configuration has to import it; see The retrieval route.

Without that import the retrieval link still works, but as a query-string URL. The first segment follows the site language — saved-result, bewaarde-uitslag, gespeichertes-ergebnis. Add a locale to the localeMap for another language, or override the whole PnQuestionnaireSavedResult key after the import to change the wording. The token requirement is pinned to [0-9a-f]{32} so the enhancer never claims an unrelated URL on a page without the plugin.

After a domain change or a change to this route, the stored result_url values are stale. They can be regenerated from the tokens — the URL is derived data, not a source.

Retention and purging 

The retention period comes from settings.db_save_result_lifetime_days, falling back to settings.db_save_result_lifetime_days. It is applied when a result is stored, by writing expires .

Expired rows are removed by a console command:

vendor/bin/typo3 pnquestionnaire:purgesavedresults
vendor/bin/typo3 pnquestionnaire:purgesavedresults --dry-run
Copied!

Register it as a scheduler task to keep the table clean. Unlike a backend deletion, the command removes rows for real rather than flagging them deleted — a stored result that has expired should not linger in the recycler.

The result mail 

The mail is sent as HTML and plain text, using the installation's own SystemEmail layout, so it inherits the styling already configured for TYPO3 mails.

Rich text inside the mail runs through lib.parseFunc_pnQuestionnaireMail , a copy of lib.parseFunc with forceAbsoluteUrl on. A relative href is a dead link in a mail client, and no Fluid ViewHelper can reach inside stored rich text — <f:format.html> hands the HTML straight to parseFunc. The nested parseFunc references for lists, preformatted text and table cells are repointed one by one; without that a link inside a list would still come out relative.

The sender falls back through three levels: the plugin's FlexForm, then TypoScript, then the installation's default sender. See settings.mail_from_address.

The send limit 

Configuration/Services.yaml defines a rate limiter for the mail form: three sends per hour, counted separately per recipient address and per client address. It uses the Symfony rate limiter that ships with the core, so there is no custom counting.

Two things to be aware of:

  • The per-client limit is only as reliable as the proxy configuration. Behind a reverse proxy, reverseProxyIP has to be configured or every visitor looks like the same client.
  • settings.mail_rate_limit_disabled switches the limit off. It is meant for testing an installation and should be off on a live site.

The form's protection against cross-site submission relies on FE.cookieSameSite being lax or stricter. That is a requirement, not a detail.

Usage counters 

With settings.statistics_enabled on, the questionnaire record keeps two tallies: starts , incremented when a visitor begins, and completions , incremented on a completed run. They are plain counters on the questionnaire record — no per-visitor data is involved.

Inverted conditions 

negate_condition on tx_pnquestionnaire_advice_block inverts a specific_answer condition. It is applied in ResultResolverService , at the point where the block's trigger answer is matched against the given answers. An absent answer counts as "not given", so an inverted block also appears for a question the visitor never answered — which is why Step 7 — Add advice blocks recommends making such questions required.

Templates, styling and JavaScript 

Overriding templates 

The extension uses standard TYPO3 Fluid template path merging. Copy any file from the extension's Resources/Private/ tree into your site package at the path configured in View paths, and TYPO3 uses your version instead. For purely visual changes no PHP is involved at all.

EXT:pn_questionnaire/Resources/Private/
Layouts/
└── Default.html
Templates/
└── Questionnaire/
    ├── Intro.html
    ├── Question.html
    └── Result.html
Partials/
├── AnswerTypes/
│   ├── SingleChoice.html
│   ├── MultipleChoice.html
│   ├── YesNo.html
│   ├── Scale.html
│   └── Informational.html
├── AdviceBlock.html
├── ButtonLabel.html
└── Progress.html
Copied!

ButtonLabel.html resolves one button label: it renders the FlexForm override when it is filled and the translated standard text otherwise. Progress.html renders the progress bar and is shared by the question steps and the result page.

Custom ViewHelper namespace 

The extension provides one custom ViewHelper. Declare its namespace in any template that uses it:

<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
      xmlns:pnq="http://typo3.org/ns/ProudNerds/PnQuestionnaire/ViewHelpers"
      data-namespace-typo3-fluid="true">
Copied!
ViewHelper Description
pnq:inArray(haystack: array, needle: value) Returns true when needle exists in haystack, comparing as strings. Used in the answer type partials to restore the checked state of previously given answers

Key template variables 

Question.html 

Variable Type Description
questionnaire Questionnaire The questionnaire record
question Question The current question
progress array{current, total} Step X of Y
progressPercentage int 0–100: the share of the questionnaire behind the visitor, see [progress_mode](#confval-typoscript-progress-mode)
prevQuestionUid `int null` UID of the previous question, null on the first
currentAnswer string[] Previously stored answer values for this question
hasAnswers bool true when at least one answer is in the session — shows the Start over button
answerTypePartial string Partial path, e.g. AnswerTypes/SingleChoice
scaleRange int[] Array from scale min to max (radio scale only)
scaleDisplay string radio or range, from the question's Scale display field
scaleMiddle int Midpoint of the scale range, used as the default slider position

Result.html 

Variable Type Description
questionnaire Questionnaire The questionnaire record
resultPage `ResultPage null` The matched result page; null means no catch-all is configured
adviceBlocks AdviceBlock[] The visible advice blocks, already filtered
totalScore float The calculated score, 0.0 when no scores are set
showScore bool From the plugin settings
showAnswerSummary bool From the plugin settings
answerSummary array[] Pre-built list of question and answers for the summary

Form field naming 

Answer inputs must use this exact name attribute for Extbase to map them to processAction(array $answers) :

name="tx_pnquestionnaire_questionnaire[answers][]"
Copied!

And the hidden question UID field:

name="tx_pnquestionnaire_questionnaire[questionUid]"
Copied!

Heading levels, and why the RTE needs Heading 4 and Heading 5 

The result page builds a heading hierarchy from three sources, only two of which the templates control:

Level Rendered by Content
h2 Result.html Result page headline
h3 AdviceBlock.html A group heading (condition_type = group_header), or a block title that is not in a group
h4 AdviceBlock.html The title of a block that points at a group heading
h5 The editor, inside the content field A sub-heading within a block

That last row is the catch: the deepest level comes out of a rich text field, so it is the RTE configuration — not the extension — that decides whether an editor can produce a valid document. An installation therefore needs both Heading 4 and Heading 5 available in the preset used for tx_pnquestionnaire_advice_block.body_text :

  • Heading 5 for a sub-heading inside a grouped block, whose title is already an h4.
  • Heading 4 for a sub-heading inside an ungrouped block, whose title is an h3.

Which one an editor needs depends on whether the block sits in a group, and that can change after the fact — moving a block into a group shifts its title from h3 to h4, so any sub-heading in its body has to move down a level too. Offer both and the editor can always pick the level one step below the block title.

If the preset stops at Heading 3, every sub-heading an editor creates lands at the same level as — or above — the title of the block it belongs to, which breaks the document outline (WCAG 1.3.1). The extension deliberately does not set richtextConfiguration on the field to force this: that would override whatever preset the site assigns through page TSconfig, for every installation.

Add the missing levels to the preset your site already uses
editor:
  config:
    heading:
      options:
        - { model: 'paragraph', title: 'Paragraph' }
        - { model: 'heading2', view: 'h2', title: 'Heading 2' }
        - { model: 'heading3', view: 'h3', title: 'Heading 3' }
        - { model: 'heading4', view: 'h4', title: 'Heading 4' }
        - { model: 'heading5', view: 'h5', title: 'Heading 5' }
Copied!

Styling 

The extension ships a minimal CSS file (Resources/Public/Css/Questionnaire.css) that only defines the progress bar. Everything else is unstyled — apply all visual design in your site package.

The progress bar fill uses currentColor, which inherits from the nearest element with an explicit color value:

In your site package
.pn-questionnaire__progress-fill {
    color: #your-brand-color;
}
Copied!

BEM class reference 

Class Element
.pn-questionnaire Root wrapper, also carries the data-questionnaire attribute
.pn-questionnaire__intro Intro screen container
.pn-questionnaire__intro-text Introduction text area
.pn-questionnaire__intro-actions Start button wrapper
.pn-questionnaire__intro-footer Text below the start button
.pn-questionnaire__step Question step container, carries data-active-step
.pn-questionnaire__progress Progress indicator wrapper, from the Progress partial
.pn-questionnaire__progress-bar Grey track of the progress bar
.pn-questionnaire__progress-fill Coloured fill; width set inline
.pn-questionnaire__progress-text "Step X of Y" on a question, "Completed" on the result page
.pn-questionnaire__progress-note Dynamic steps note below the bar
.pn-questionnaire__context-content Embedded tt_content element
.pn-questionnaire__question-text Question text area
.pn-questionnaire__help-text Help text below the question
.pn-questionnaire__form The answer form
.pn-questionnaire__answers Fieldset wrapping all answer inputs
.pn-questionnaire__answer-option Wrapper for one answer option
.pn-questionnaire__answer-option--radio Modifier for radio inputs
.pn-questionnaire__answer-option--checkbox Modifier for checkbox inputs
.pn-questionnaire__answer-option--yes-no Modifier for yes/no inputs
.pn-questionnaire__radio Radio input
.pn-questionnaire__checkbox Checkbox input
.pn-questionnaire__answer-label Label of an answer option
.pn-questionnaire__scale Scale question wrapper
.pn-questionnaire__scale--radio Modifier: radio button display
.pn-questionnaire__scale--range Modifier: range slider display
.pn-questionnaire__scale-options Row of scale radio buttons
.pn-questionnaire__scale-option One scale value
.pn-questionnaire__scale-label Label of one radio scale value
.pn-questionnaire__scale-range-track Wrapper for slider input and output
.pn-questionnaire__scale-range-label--min Min endpoint label
.pn-questionnaire__scale-range-label--max Max endpoint label
.pn-questionnaire__range The range input element
.pn-questionnaire__range-value The output element showing the live value
.pn-questionnaire__error Client-side validation message, hidden by default
.pn-questionnaire__required-mark Asterisk after the text of a required question
.pn-questionnaire__nav Previous / Next / Start over wrapper
.pn-questionnaire__btn Base button class
.pn-questionnaire__btn--primary Primary action button
.pn-questionnaire__btn--secondary Secondary action button
.pn-questionnaire__btn--reset Start over button
.pn-questionnaire__result Result container
.pn-questionnaire__result--error Modifier when no result page matched
.pn-questionnaire__result-headline Result headline
.pn-questionnaire__result-score Score display
.pn-questionnaire__result-body Result body text
.pn-questionnaire__result-cta Call-to-action button wrapper
.pn-questionnaire__result-reset Start over button on the result page
.pn-questionnaire__advice-blocks Advice blocks container
.pn-questionnaire__advice-block One advice block
.pn-questionnaire__advice-block--always Modifier for always-visible blocks
.pn-questionnaire__advice-block--score_range Modifier for score-range blocks
.pn-questionnaire__advice-block--specific_answer Modifier for specific-answer blocks
.pn-questionnaire__advice-block--scale_range Modifier for scale-range blocks
.pn-questionnaire__advice-block-headline Advice block headline
.pn-questionnaire__advice-block-body Advice block body text
.pn-questionnaire__answer-summary Answer summary container
.pn-questionnaire__summary-item One question and answer pair
.pn-questionnaire__summary-question Question text in the summary
.pn-questionnaire__summary-answers Answer text in the summary
.pn-questionnaire__saved-result-copylink Copy button next to the retrieval link
.pn-questionnaire__saved-result-status Live region reporting the copy result

JavaScript 

All behaviour is progressive enhancement: the questionnaire works without JavaScript through standard HTML form submissions.

Questionnaire.js 

Loaded via <f:asset.script> in Layouts/Default.html and initialised on DOMContentLoaded by looking for [data-questionnaire].

Feature Description
Scroll to container Scrolls the questionnaire into view when a step is active
Required validation Validates that at least one non-hidden input is filled before allowing submission
Error message Reads the error text from the data-required-error attribute on the form
Submit lock Disables the submit button after a valid submission, preventing double posts

A range input always has a value, so required validation passes automatically for scale slider questions.

ScaleRange.js 

Loaded conditionally, only when a scale question renders as a range slider. TYPO3 deduplicates the asset, so it is safe with multiple scale questions on one page.

Feature Description
Live value display Keeps the output element next to the slider in sync while the visitor drags
Initial sync Syncs on load in case the browser normalises the server-rendered value

The output element is server-rendered with the correct initial value, so the slider is usable without JavaScript.

To replace the JavaScript, point <f:asset.script> in your layout override at a different file, or load your own script that initialises a class targeting [data-questionnaire].

Adding a language 

  1. Copy Resources/Private/Language/locallang.xlf to a new file named {language-code}.locallang.xlf in the same directory.
  2. Add target-language="{language-code}" to the <file> element.
  3. Add a <target> element with the translation for each <trans-unit> .

TYPO3 picks the translation up automatically, based on the active frontend language. The Dutch translation (nl.locallang.xlf) can serve as a reference; German (de.locallang.xlf) is shipped as well.

The backend labels live in locallang_be.xlf and follow the same pattern.

Architecture 

How the parts fit together 

QuestionnaireController
    │
    ├── QuestionnaireRepository     → loads the configured questionnaire
    ├── SessionService              → stores / retrieves visitor answers
    ├── ConditionEvaluatorService   → filters questions to the visible subset
    ├── ProgressService             → calculates step X of Y, prev/next uids
    ├── ScoringService              → sums answer option scores
    ├── ResultResolverService       → picks the first matching result page
    │                                 and filters the advice blocks
    └── DomainRecordResolverService → builds the redirect URL for record outcomes
Copied!

Action flow 

GET  /page  →  introAction()      render intro screen
GET  /page  →  questionAction()   render current question
POST /page  →  processAction()    store answer, redirect to next or result
GET  /page  →  resultAction()     resolve result, render or redirect
POST /page  →  resetAction()      clear session, redirect to intro
Copied!

All five actions are registered as uncached in ext_localconf.php.

Service layer 

All business logic lives in Classes/Service/. The services are autowired through Configuration/Services.yaml.

Service Responsibility
SessionService Reads and writes visitor answers through FrontendUserAuthentication. Key tx_pnquestionnaire, namespaced per questionnaire uid
ConditionEvaluatorService Iterates all questions, evaluates each condition set, returns the visible questions in sort order
ProgressService calculate(), getNextQuestionUid(), getPreviousQuestionUid(), all operating on the visible questions
ScoringService Traverses visible questions and their answer options, sums the scores. Returns 0.0 when no scores are set
ResultResolverService Iterates result pages top to bottom, returns the first match. Also provides filterAdviceBlocks()
DomainRecordResolverService Builds the redirect URL for domain_record outcomes with UriBuilder, reading the TypoScript target and foreign_table from $GLOBALS['TCA']
ResultStorageService Writes and retrieves a stored result, and generates its token
ResultMailService Composes and sends the result mail, HTML and plain text
MailRateLimitService Wraps the Symfony rate limiter for the mail form
StatisticsService Increments the start and completion counters

Condition evaluation 

Each condition has a condition_type that determines how it is evaluated.

specific_answer (default) passes when the visitor selected a specific answer option for the reference question. Multiple conditions are folded together in list order:

result = evaluate(condition_1)
for each condition_2, condition_3, ...:
    if operator == AND → result = result && evaluate(condition)
    if operator == OR  → result = result || evaluate(condition)
Copied!

scale_range passes when the numeric answer for the reference question satisfies the configured operator and threshold:

stored_value [operator] scale_value
e.g. 7 >= 5  → true
Copied!

Supported operators are >=, <=, >, < and =. When the scale question has not been answered yet the condition returns false, so the dependent question stays hidden.

A question is shown when the final combined result is true.

Result page selection 

foreach resultPage in questionnaire.resultPages (ordered by sort_order):
    if matches(resultPage, sessionAnswers, totalScore):
        return resultPage  ← first match wins
return null  ← no catch-all configured
Copied!
Trigger Match condition
catch_all Always
score_range scoreMin <= totalScore <= scoreMax
specific_answer A specific answer option uid appears in the session answers
combination Both score_range and specific_answer match
scale_answer The numeric answer for the trigger question is within [triggerScaleMin, triggerScaleMax]

Session data format 

Answers are stored in the TYPO3 frontend session under the root key tx_pnquestionnaire.

tx_pnquestionnaire
└── q_{questionnaireUid}
    └── answers
        ├── "{questionUid}" → ["{answerOptionUid}"]   ← single choice
        ├── "{questionUid}" → ["{uid1}", "{uid2}"]    ← multiple choice
        └── "{questionUid}" → ["{scaleValue}"]        ← scale (raw number)
Copied!

All values are stored as strings; the SessionService methods handle the conversion.

Extending the extension 

Adding a question type 

  1. Add the new value to the type select field in Configuration/TCA/tx_pnquestionnaire_question.php — both the items array and the types array.
  2. Add the type constant to Classes/Domain/Model/Question.php.
  3. Create Resources/Private/Partials/AnswerTypes/YourType.html.
  4. Add the mapping to $answerTypePartialMap in QuestionnaireController::questionAction() .

No other changes are required.

Adding an outcome type 

  1. Add the new value to the outcome_type select field in the result page TCA.
  2. Add the outcome constant to Classes/Domain/Model/ResultPage.php.
  3. Handle it in QuestionnaireController::handleRedirectOutcome() or resultAction() .

Replacing a service 

To replace or extend the result matching, create a service that extends or wraps ResultResolverService and reconfigure it in Configuration/Services.yaml. The same applies to scoring: your service only needs the same method signature.

public function calculateTotal(array $visibleQuestions, array $sessionAnswers): float
Copied!

For purely visual changes no PHP is needed at all — copy the Fluid template into your site package, see Templates, styling and JavaScript.

Changelog 

The full changelog, including the upgrade notes per version, is maintained in the repository: CHANGELOG.md.

The versioning follows Semantic Versioning: a patch release never changes behaviour, a minor release adds features and may add a database field, and a major release may break compatibility.

Highlights per version 

Version What changed
1.2 The progress bar measures answered questions instead of the step number, with progress_mode to pick the convention; the bar also appears on the result page
1.1 Own button texts per plugin instance, and a text below the start button on the introduction screen. Adds the field introduction_footer_text
1.0 First public release

Credits and support 

Authors 

Created and maintained by ProudNerds:

  • Jacco van der Post
  • Emile Blume

Icons 

The backend icons in Resources/Public/Icons/, ext_icon.svg included, come from Tabler Icons and are used under the MIT licence. They are committed to the repository; download-icons.sh only exists to refetch them and is excluded from the distribution package.

Support 

Channel Use for
GitHub issues Bug reports and feature requests
extensions@proudnerds.com Everything that does not belong in a public issue
proudnerds.com Commercial support and custom development

When reporting a bug, please include the TYPO3 and PHP version, the extension version, the relevant plugin and TypoScript settings, and — for a problem in the frontend — the browser console output.

Contributing 

Pull requests are welcome. Keep to the conventions of the code that is there: PSR-12, strict types, constructor injection, PHPStan level 5 clean, and Fluid rather than hand-written HTML in the templates.

License 

The extension is licensed under GPL-2.0-or-later. This manual is published under the Open Publication License.