2026-09-24

Modelling quizzes: question types, manual grading and the attempt state you didn't know you needed

Before writing a schema for a learning platform’s quiz module, I read the source of a mature open-source LMS plugin, both its data models and its enums, purely as a reference for functionality. Nothing was ported. Every feature was reimplemented and each got a scope decision. Reading real code beats reading screenshots because code shows you the states that no screenshot captures.

Two levels, and a grant

The product shape I was building had two renamed primitives:

Access is an enrolment: one row per user and series, moving from pending to approved or rejected by an admin. That row is already “conditional enablement for specific students, on request”, so no separate feature was needed. The gap list was more interesting: no pricing, no per-user overrides, only single-answer multiple choice, and no timer or pass mark.

Question types are a grading decision

The plugin’s question types split cleanly by how they are graded:

Type Grading How correctness works
true/false auto exactly one correct option among two
single choice auto exactly one correct option among N
multiple choice auto the chosen set must exactly equal the correct set
matching auto prompt-to-match pairs; text or image variants
sortable auto correct only if the order matches index for index
fill in the blanks auto string match, case-insensitive
text answer manual the auto-check always returns false and defers to a human
audio, video manual play media, type a free-text answer

The detail I liked: multiple choice requires an exact set match, so partial credit is a separate, deliberate feature rather than a default. And fill in the blanks is authored inline. You write the sentence and wrap each answer in double curly braces, with pipes for alternatives:

The capital of France is {{ Paris }}
The sky is {{ blue | azure }}

Each token then renders as an inline input inside the sentence. Authoring in the same string that gets displayed avoids a separate answers table that can fall out of sync with the prompt.

The third attempt state

The most transferable finding was the attempt lifecycle. A naive schema has two states, in_progress and submitted. The plugin has three:

attempt_started  ->  attempt_pending  ->  attempt_ended
                     (submitted, waiting     (fully graded)
                      for a human grader)

pending only exists because of the manually graded question types. The moment a quiz can contain a text answer, “submitted” and “graded” are different facts, and the score can’t be final. If you only ever ship auto-graded types, you can defer the extra state. But you should know it is coming, because adding a state to an attempt table that already has data is harder than reserving the name.

Questions belong to many quizzes

Another quiet decision: questions and quizzes are related many-to-many through a pivot with a per-quiz order:

quiz_question_rel (quiz_id, question_id, menu_order,  UNIQUE (quiz_id, question_id))

That is what makes a shared question bank possible: the same question row attached to several quizzes and reordered independently in each. Retrofitting it later means migrating a foreign key into a pivot, which is exactly the sort of change to avoid.

Settings, and what was gated

The quiz settings are the usual suspects: pass mark, full mark, duration, attempts allowed (zero meaning unlimited), whether to reveal the correct answer immediately, and questions per page. A few (percentage-based pass marks, randomised question order, and “must answer every question”) were premium-only in the reference. That was useful as a scoping signal: features a mature product treats as advanced can wait for a fast follow.

How I used it

Every discovered feature went into one catalogue with a decision: build now (V1), fast-follow, defer, or cut. The V1 rows became a dependency-ordered task plan, and the domain foundation got a short ADR. The schema reserved room for some deferred items, for example a nullable per-enrolment attempt override, without building the feature. Design for the future, build for the roadmap.

Takeaways

← All posts