Quiz

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.

Install

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.

Declare a quiz

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).

Question & answer fields

FieldWhereNotes
qquestionThe question text.
mediaquestion / answerAn image filename (rendered with image()). On a video question it doubles as the poster and is then required.
videoquestionA video URL (rendered with video()), using media as its poster.
altquestion / answerMedia alternative text. Required for a video.
explanationquestionShown once the question is answered.
aanswerThe answer text.
correctanswertrue for an expected answer (several allowed).

Quiz-level fields

  • title, difficulty — header.
  • feedbackimmediate (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.

SEO & accessibility

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).

Percentile & leads

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.

Validate from the API (AI agents)

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."}, …]}