Interactive quizzes (QCM) and personality tests, declared inline in a page and run in the browser. A Conversation form can close the quiz to capture leads.
composer require pushword/quiz
Then run bin/console doctrine:schema:update --force (a tiny quiz_result table stores anonymous scores for the percentile — and chosen profiles for a personality test) and bin/console assets:install.
The payload is a JSON object, written in one of two equivalent forms.
Recommended — the {% quiz %} block. The JSON is the raw tag body, so apostrophes and quotes need no escaping and the JSON stays readable/diffable:
{% quiz %}{"title":"Mountains","feedback":"immediate","cta":"newsletter","questions":[{"q":"Highest summit?","answers":[{"a":"Mont Blanc"},{"a":"Everest","correct":true},{"a":"K2"}],"explanation":"Everest is 8,849 m."}],"results":[{"min":0,"msg":"Try again"},{"min":80,"msg":"Expert!"}]}{% endquiz %}
Legacy — the quiz() function. Here the JSON is a single-quoted Twig string, so every literal apostrophe must be escaped as \':
{{ quiz('{"title":"Mountains", … }') }}
You normally never write that JSON by hand — the EditorJS block generates it (add/remove questions and answers, flag the correct answer, pick or upload an image from the media library, add a video, write the explanation). When you do author a flat file by hand, prefer the {% quiz %} block and lint it with pw:quiz:validate (below).
The
{% quiz %}body is sub-parsed as Twig, so a{{ … }}inside it is still interpolated — but the result is then JSON-decoded, so only quote-free output is safe (a bare URL, a number). A helper like{{ link() }}emits HTML with double quotes and would break the JSON. Keep the JSON body free of blank lines (compact or pretty-printed without empty lines): the Markdown pipeline splits content on blank lines, which would cut the block in two.
A missing or unknown media file is skipped (admins see an inline warning); the rest of the quiz still renders.
Visitors select an answer, can change their selection, then choose Confirm answer to validate it and advance. Validation locks that question and reveals its feedback according to feedback; a completed attempt can be played again with Restart the quiz. The same confirmation step applies to personality tests and difficulty levels.
| Field | Where | Notes |
|---|---|---|
q | question | The question text. |
media | question / answer | An image filename (rendered with image()). On a video question it doubles as the poster and is then required. |
video | question | A video URL (rendered with video()), using media as its poster. |
alt | question / answer | Media alternative text. Required for a video. |
explanation | question | Shown once the question is answered. |
a | answer | The answer text. |
correct | answer | true for an expected answer (several allowed). Knowledge quiz only. |
weights | answer | Personality test (mode: profile): a {profileKey: points} map. |
profile | answer | Personality test shorthand: a profile key worth 1 point (== weights {key: 1}). |
mode — quiz (default, scored on correct answers) or profile (a personality test scored on answer weights; see below).title, difficulty — header.feedback — immediate (reveal the answer on confirmation, default) or end. Forced to end in mode: profile (no correct answer to reveal).profiles — personality-test outcomes {key, title, msg?, media?, alt?}; used only in mode: profile.results — score bands {min, msg}; the highest matched min wins.cta — a Conversation form type shown at the end (skipped if Conversation is not installed).ctaTitle — a call-to-action heading shown above that end form (for example “Receive the next quizzes in your mailbox”).numbering — prefix each answer so people can refer to one out loud: "A" (A, B, C…), "a" (a, b, c…), "1" (1, 2, 3…), or "" for none (default).pass — the score (in %) at or above which a difficulty level counts as passed and offers the next one (default 50). Only meaningful with levels.labels — overrides for the UI words, which otherwise default to the site locale: question, questions, explanation, confirm, restart, score, better (use {p} as the percentile placeholder), level, nextLevel, profile and share (personality mode; {p} = the share). Set these only to force a specific wording.levels — turn the quiz into several difficulty levels (see below).A single quiz can offer several difficulty levels behind an accessible tab selector. Add a levels array: each entry is a complete quiz of its own (same shape — difficulty, questions, results, feedback, cta, pass, labels), while the root keeps the shared metadata (title, labels, …).
{% quiz %}{"title":"Mountains","cta":"newsletter","pass":50,"levels":[
{"difficulty":"Easy","questions":[ … ],"results":[ … ]},
{"difficulty":"Intermediate","questions":[ … ]},
{"difficulty":"Hard","questions":[ … ]}
]}{% endquiz %}
label ?? difficulty.labels (merged), feedback, numbering, cta, ctaTitle, results and pass when it does not set its own.Home/End, roving focus). When a level is passed (score ≥ its pass), a “Next level →” button appears and jumps to the following tab.referring are discriminated per level), so an Easy score never dilutes a Hard one.In the EditorJS block, pick “Knowledge quiz with difficulty levels” in the Type selector to edit one full sub-quiz per level.
mode: profile)Set "mode": "profile" to turn the same block into a personality test (“Which X are you?”). There is no correct answer: each answer weighs one or more named profiles, and the highest-tallied profile is shown as a result card.
{% quiz %}{"mode":"profile","title":"Which explorer are you?","profiles":[{"key":"sommet","title":"The Summiteer","msg":"Higher, always.","media":"peak.jpg"},{"key":"calm","title":"The Contemplative","msg":"The mountain is your refuge."}],"questions":[{"q":"A free weekend, you…","answers":[{"a":"climb a peak","weights":{"sommet":2}},{"a":"walk by a lake","profile":"calm"}]}],"cta":"newsletter"}{% endquiz %}
weights map, or the profile: "key" shorthand (== { "key": 1 }). The highest tally wins; ties break by the order profiles are declared.key — a typo would otherwise vote for nothing.feedback is always end, levels are not used, and no schema.org/Quiz markup is emitted (there is no accepted answer to advertise). Every outcome is still server-rendered (hidden) for SEO/no-JS, and the runtime reveals the winner.{ quiz, result } to POST /quiz/result and gets back { share } — “X% got the same profile”. The knowledge-quiz percentile and the personality share stay separate even under one page slug.labels.profile (the “Your profile:” heading) and labels.share (use {p} for the share) override the wording.Pick “Personality test” in the Type selector. The profiles are edited above the questions — they are the vocabulary the answers then point at:
key follows through every weight on its own.The quiz is styled with Tailwind utilities on the markup, so it inherits your site’s design system — the accent follows --primary, and the spacing and colours come from your theme’s scale. Nothing to import: the utilities are in your stylesheet already, because @pushword/js-helper's app.css scans bundle templates (see managing assets if you build your CSS from an entry point of your own).
Every element’s utilities are a pwQuiz*Class default. Redefine one as a twig global and that element restyles, no template fork:
# config/packages/twig.yaml
twig:
globals:
pwQuizQClass: 'rounded-none border-l-4 border-brand-500 bg-brand-50 p-6'
pwQuizAClass: 'flex w-full items-center gap-3 rounded-md border px-4 py-3 text-left'
| variable | element |
|---|---|
pwQuizClass | the <section> root |
pwQuizHeadClass / pwQuizTitleClass | header, <h2> |
pwQuizMetaClass / pwQuizLevelMetaClass / pwQuizDiffClass | meta line, per-level meta line, difficulty badge |
pwQuizQuestionsClass / pwQuizQClass / pwQuizQNumClass / pwQuizQuestionClass | question list, card, “Question 1/5”, the question text |
pwQuizQLabelClass / pwQuizHintClass | question topic (label), hint |
pwQuizAnswersClass / pwQuizAnswerItemClass / pwQuizAClass | answer grid, its <li>, the answer button |
pwQuizAPrefixClass / pwQuizATextClass / pwQuizAMarkClass | A/B/C bullet, answer text, ✓/✗ slot |
pwQuizExplanationClass / pwQuizExplanationLabelClass | explanation box and its label |
pwQuizResultClass / pwQuizProfileTitleClass / pwQuizProfileMsgClass | result box, profile title, profile message |
pwQuizCtaClass / pwQuizCtaTitleClass | CTA block and its title |
pwQuizTabsClass / pwQuizTabClass | difficulty tablist and its tabs |
{% macro %}, and a macro sees no render context — only globals reach inside.[&>p]:mt-0: the browser decodes the attribute before matching classes.twig.yaml is not scanned — add @source "../config/packages/twig.yaml"; to your CSS, or keep the classes in a file already scanned.quiz.css supplies these shared styles:
quiz.js;quiz.js — the score donut and restart/next/share buttons;QuizRenderer.php.To use Tailwind utilities in generated markup, include the corresponding JS or PHP source files in Tailwind’s scan.
The file is unlayered, so its rules outrank the template’s utilities whatever the source order — which is what lets the answered/correct/wrong states override the base look. It keeps the --pw-quiz-* variables for its own colours:
.pw-quiz {
--pw-quiz-accent: var(--primary, #6d28d9);
--pw-quiz-correct: #16a34a;
--pw-quiz-wrong: #e11d48;
}
The pw-quiz-* class names themselves are load-bearing — quiz.js queries them and those state rules key off them. Restyle by adding to them, never by replacing them.
The whole quiz — questions, answers, the correct flag and the explanations — is rendered server-side as a readable, schema.org Quiz Q&A. That is what crawlers and no-JS visitors get. quiz.js then progressively enhances it into a game. Correctness is never signalled by colour alone (✓/✗ glyphs + aria-live). With difficulty levels, every level is server-rendered (panels stack without JS) and emits its own schema.org Quiz. A personality test (mode: profile) is server-rendered the same way but emits no schema.org Quiz — it has no accepted answer, so that markup would be misleading.
Use mode: "assessment" when answers describe a capacity rather than knowledge. Every answer needs a finite numeric value; the minimum selected value is the outcome. Zero and decimal values are supported. results uses the same min/msg bands, with numeric thresholds instead of percentages. There are no correct answers, difficulty tabs, score donut or schema.org accepted answers in this mode.
The native Quiz editor exposes this type, answer values, optional question label and hint, and the result messages. The player offers Back, Change my answers and Restart; Back and Change retain selections. labels.back, labels.editAnswers, labels.confirm, labels.finish (last step, “See my result” by default) and labels.restart override those controls.
Set recordResults and showComparison independently. Both default to false for assessments and true for existing quiz/profile content. Recording an assessment posts {mode: "assessment", result: "<numeric outcome>"} and stores the value in the existing result column; no database migration is needed. With recordResults: false the page carries no result signature, so the endpoint refuses (403) any attempt posted for the quiz. An assessment’s comparison line reads “{p}% got the same result” (labels.share).
These hooks work in every mode.
For a site-specific result (for example, catalogue recommendations), override @PushwordQuiz/component/_result.html.twig, extend @!PushwordQuiz/component/_result.html.twig, and fill quiz_result_extra. The partial receives quiz, slug and conversationAvailable; quiz_result wraps the complete result box. Keep the .pw-quiz-result and .pw-quiz-score hooks.
The browser dispatches pushword:quiz:complete from the [data-pw-quiz] root. It bubbles with detail: {quiz, mode, answers: [{q, a}], restored} plus score (quiz percentage), profile (profile key) or value (numeric assessment). It fires after the result is rendered, including on a saved-attempt replay. The same JSON is available as root.dataset.pwQuizResult for integrations that initialize later. Editing or restarting removes it. Listening needs no result POST. The PHP QuizCompletedEvent still fires only after a recorded attempt.
On completion the browser posts the attempt to POST /quiz/result and gets back the percentile (“better than X% of participants”), or { share } for a personality test. The body is { quiz, score } (a percentage) or { quiz, result } (a profile key), plus the required signature the renderer prints into the quiz config — an HMAC of the host and quiz slug on the kernel secret, so only quizzes the site rendered are recorded (403 otherwise) — and optional answers ([{q, a}]). The endpoint is rate-limited per IP (429) and stores no PII: answers are never persisted.
Listen to Pushword\Quiz\Event\QuizCompletedEvent to act on an attempt — host, quiz, mode (quiz, profile or assessment), profile key or numeric outcome (getResult()) or score, and the chosen answers — for instance to enrich a logged-in visitor’s account under your own consent rules.
If a cta is set, the Conversation form is shown at the end, pre-filled from a previously stored identity (localStorage); the lead is tagged with the quiz via the referring field.
The admin lists the attempts (Quiz → Results); the API answers the same question from outside, so participation is readable without an admin session. Both endpoints are token-authenticated and read-only — an attempt is written by the public endpoint above and round-tripped through quiz-result.csv.
GET /api/quiz/result?host=&quiz= # attempts, newest first, paginated
GET /api/quiz/result/stats?host=&quiz= # participation per quiz, unpaginated
A stats row reports all attempts, knowledgeAttempts and their averageScore. The profiles map counts profile keys or numeric assessment outcomes:
{"quiz":"Mountains","host":"example.tld","attempts":48,"knowledgeAttempts":40,
"averageScore":65.3,"profiles":{"sommet":5,"calm":3}}
POST /api/quiz/validate (token-authenticated, like the rest of the API) validates a quiz payload against the same rules as the renderer and the editor, returning precise violations:
curl -X POST https://example.tld/api/quiz/validate \
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '{"questions":[{"q":"","answers":[{"a":"x"}]}]}'
# 422 → {"error":"validation","violations":[{"path":"questions[0].q","message":"A question cannot be empty."}, …]}
pw:quiz:validate lints every quiz block in a flat file (both {% quiz %} and {{ quiz('…') }} forms) — or stdin (-) — against the same rules, printing {path, message} violations and exiting non-zero. Drop it into an edit→check loop:
bin/console pw:quiz:validate content/my-page.md
# ✓ Quiz #1 (line 12, {% quiz %}) — valid
cat draft.md | bin/console pw:quiz:validate -
It also warns (without failing) when a cta does not match a registered Conversation form type.
Fetch the machine-readable JSON Schema of a quiz payload (keys, aliases, enums) to generate a structurally-valid quiz in one shot:
bin/console pw:quiz:schema # prints the schema
curl https://example.tld/api/quiz/schema # same schema (token-authenticated)
The Symfony Validator on the Quiz model stays the source of truth; the schema mirrors its structure as an authoring aid.
When the Flat extension is enabled, every pw:flat:sync run (or --entity=quiz-result alone) round-trips quiz results through content/<host>/quiz-result.csv. Results are anonymous and immutable, so the merge is trivial: import only creates rows whose uuid is unknown — never updates, never deletes — and the merged union is written back on export.
The point is deployment safety: a workflow that rebuilds the database from flat files (or does not ship var/app.db at all) cannot erase attempts collected in production, and the percentile keeps its history. Results predating the uuid column get one on their first export. For the same reason the admin delete action on results is disabled: a deleted row would be recreated by the next import.