Add interactive, client-side quizzes (QCM) to any page. A quiz works almost without a server: it is declared inline in the page content and runs in the browser. A conversion form (via Conversation) can be branched at the end.
composer require pushword/quiz
Then run bin/console doctrine:schema:update --force (a tiny quiz_result table stores anonymous scores for the percentile) and bin/console assets:install.
Use the quiz() Twig function in a page's content. The argument is a JSON string describing the quiz:
{{ 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!"}]}') }}
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).
| 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). |
title, difficulty — header.feedback — immediate (reveal each answer at once, default) or end.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).labels — author-defined UI words (no i18n), overriding the English defaults: question, questions, explanation, score, and better (use {p} as the percentile placeholder). Set these in the quiz's own language.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).
On completion the browser posts the score (in %) to POST /quiz/result, which returns the percentile ("better than X% of participants"). This store is anonymous (no PII). 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.
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."}, …]}