Student Attempt Lifecycle (start / finish / score / review)
This file documentsQuizzes_Exams_Progress_Tracking_Controller, which owns the student-facing attempt lifecycle for both Quizzes and Exams: starting an attempt, submitting answers, persisting the computed score, and retrieving a student’s prior answers.
The container model (Quiz), the question bank (QuizQuestion, QuizQuestionOption, QuizQuestionOptionItem, QuizQuestionType), and authoring / import-export are documented in Quizzes & Exams endpoints.
Table of Contents
- Overview
- Base URL and Headers
- Lifecycle Diagram
- Data Model:
QuizStudentAttempt&QuizStudentReset - Endpoints
- 5.1 Start Student Quiz
- 5.2 Finish Student Quiz (submit answers)
- 5.3 Save Student Quiz Result (score)
- 5.4 Show Quiz Student Answers
- 5.5 Reset Student Attempts (non-destructive)
- Answer Payload Shape (by question type)
- Business Rules & Edge Cases
- History Tracking Actions
- Related Files
1. Overview
A “quiz/exam attempt” for a student is one row inquiz_student_attempts (QuizStudentAttempt pivot model). The answers chosen during that attempt are rows in the quiz_student_answers join table — each row links the attempt to a question and to either the selected option or, for drag-and-drop, an option item.
The five endpoints in this controller drive the lifecycle:
start_student_quiz— creates a new attempt row (monotonicattemptnumber per student × quiz; the attempt ceiling is reset-aware — see §5.1).finish_student_quiz— stampsfinished_atand writes the answer rows intoquiz_student_answers.save_student_quiz_result— persists the scored result (correct_answers,uncorrect_answers,score_percentage,passed_at) onto the attempt row. The score itself is computed client-side (or by the caller) before this is invoked.show_quiz_student_answers— fetches the authenticated student’s attempts (with filters); each attempt is annotated with a derivedreset_cycle.reset_student_attempts— admin/instructor grants a student one more full cycle ofmax_attemptswithout deleting any history (logged inquiz_student_resets).
2. Base URL and Headers
Most endpoints takestudent_idexplicitly in the body (so an admin can drive a flow on behalf of a student).show_quiz_student_answersis the exception — it usesauth()->id()directly.
3. Lifecycle Diagram
4. Data Model: QuizStudentAttempt & QuizStudentReset
QuizStudentAttempt
File: app/Models/QuizStudentAttempt.php — extends Illuminate\Database\Eloquent\Relations\Pivot.
Relations:
quiz()—belongsTo(Quiz::class, 'quiz_id')student()—belongsTo(User::class, 'student_id')quiz_questions()—belongsToMany(QuizQuestion::class, 'quiz_student_answers', 'quiz_student_attempt_id', 'quiz_question_id')with pivotquiz_question_option_id, quiz_question_option_item_idquiz_question_options()— same join throughquiz_question_option_idquiz_question_option_items()— same join throughquiz_question_option_item_id
quiz_student_answers. The same join table is read three different ways via these three relations.
QuizStudentReset
File: app/Models/QuizStudentReset.php — table quiz_student_resets. A non-destructive reset log: each row grants one student one more full cycle of quiz.max_attempts on a given quiz/exam. Attempt rows are never touched.
Relations:
quiz(), student(), resetBy() — all belongsTo. Indexed on (quiz_id, student_id) for the fast ceiling lookup in start_student_quiz.
Ceiling model: the number of times a student may start a quiz ismax_attempts * (reset_count + 1), wherereset_count= number ofquiz_student_resetsrows for that student × quiz.max_attempts == 0(unlimited) is unaffected by resets. See Suggestions: user attempts fix for the full design rationale.
5. Endpoints
Routes (fromroutes/api.php):
5.1 Start Student Quiz
POST /api/quiz/start_student_quiz
Creates a new attempt row for the student on the given quiz.
Request Fields:
Request Body:
- Computes the next absolute attempt number —
(max(attempt) for this student × quiz) + 1, or1if none. This counter is monotonic and never resets. - Counts the student’s reset rows for this quiz (
reset_count) and computes the ceilingallowed_attempts = quiz.max_attempts * (reset_count + 1). - If
quiz.max_attempts > 0and the newattempt > allowed_attempts→401 Quiz/Exam cannot be started, too many attempts.(Whenmax_attempts == 0, there is no ceiling — unlimited.) - Creates a
quiz_student_attemptsrow withquiz_id,student_id,attempt. - History:
start_student_quiz(resource: student id, table:quiz_student_attempts).
Reset-aware ceiling: everySuccess Response (200):quiz_student_resetsrow (created via §5.5) grants one more full cycle ofmax_attempts. Withmax_attempts = 3and 1 reset, the student may start the quiz3 * (1 + 1) = 6times total. The historicalattemptnumbers keep climbing (4, 5, 6 …) — they are not rewound.
The response does not include the new attempt id. Retrieve it via §5.4 or by reading the attempts table directly.
5.2 Finish Student Quiz (submit answers)
POST /api/quiz/finish_student_quiz
Stamps finished_at on the latest open attempt and writes the student’s selected answers into quiz_student_answers.
Request Fields:
All four answer arrays are required by thepresentvalidation rule but may be empty arrays[].
Example A — Quiz with only True/False and Single Answer questions: The student answers 3 non-drag-and-drop questions by selecting option ids
60, 63, and 66. No drag-and-drop buckets used.
Example B — Quiz with Multiple Answer questions: The student picks options
65, 66, and 68 for a multiple-answer question (option ids can be from different questions — each id carries its own question reference).
Example C — Quiz with only Drag & Drop questions: Two drag-and-drop buckets: option
69 (bucket for “len()”) gets item 201; option 70 (bucket for “int()”) gets item 202.
Example D — Mixed quiz (Single Answer + Multiple Answer + Drag & Drop):
- Non-drag-and-drop question answers: option ids
61and65,66,68 - Drag-and-drop answers: bucket
69receives items201; bucket70receives items202
Example E — Student submits nothing (empty submission):
Behavior:
- Looks up the latest attempt for
student × quiz. - Only writes answers if the attempt exists and no answers have been recorded yet. Otherwise returns
500 Quiz has not started!. - Stamps
finished_at = now(). - For each id in
quiz_question_options_ids, writes a row toquiz_student_answerslinking the attempt, the option, and the derived question. - For each drag-and-drop pair
[i], writes a row linking the attempt, the option item, the derived question, and the bucket optionquiz_question_option_items_groups[i]. - History:
finish_student_quiz.
The score is not computed here. Compute it client-side from the saved answers and call §5.3 with the totals.Error Responses:
5.3 Save Student Quiz Result (score)
POST /api/quiz/save_student_quiz_result/{attempt_id}
Persists the computed score onto an existing attempt row. The score is calculated by the client — this endpoint just writes the values.
Path Parameter: attempt_id — the id of the quiz_student_attempts row to score (returned in §5.2 response or retrievable via §5.4).
Request Fields:
Request URL + Body:
QuizStudentAttempt::findOrFail($attempt_id)— 404 if not found.- Writes
correct_answers,uncorrect_answers,score_percentage,passed_atand saves. - History:
save_student_quiz_result.
The typo "resuts" (missing ‘l’) is in the actual controller response — keep it as-is in your test assertions.
Error Responses:
Additional Examples:
Student passed with a perfect score:
5.4 Show Quiz Student Answers
GET /api/quiz/show_quiz_student_answers
Returns all quiz_student_attempts for the authenticated student (auth()->id()), ordered latest first. Eager-loads quiz_questions.image on each attempt unless summary=1. Each attempt is annotated with a derived reset_cycle and, since 19/10/2026, with the ceiling of its quiz (reset_count, allowed_attempts, attempts_used, see the notes after the example).
Query Parameters (all optional):
Example Requests:
Thepivoton eachquiz_questionsitem holds the answer details:quiz_question_option_id= which option the student chose;quiz_question_option_item_id= which drag-and-drop item was placed (ornullfor non-drag-and-drop questions).
reset_cycleis a derived, non-stored field computed in-memory per attempt:1 + (number of resets for this student × quiz whose created_at ≤ the attempt's created_at). So attempts taken before any reset are cycle1; each reset bumps subsequent attempts to cycle2,3, … Use it to group attempts in the UI (“Cycle 1 — attempts 1–3”, “Cycle 2 — attempts 4–6 after reset”). It is not a column onquiz_student_attempts.
Empty Response (200) — no attempts yet:reset_count,allowed_attemptsandattempts_used(19/10/2026) are the same numbersstart_student_quizenforces, computed once per quiz for all rows:allowed_attempts = max_attempts * (reset_count + 1)(nullwhenmax_attemptsis 0 = unlimited) andattempts_used = MAX(attempt). The apps read them instead of guessing the ceiling fromreset_cycle. The same block is returned asattemptsbyGET /auth/user(my_exams[].attempts) andGET /quiz/{id}for a student caller.
5.5 Reset Student Attempts (non-destructive)
POST /api/quiz/{quiz_id}/reset_student_attempts
Grants a student one more full cycle of max_attempts on a quiz/exam without deleting any historical attempt. Each call appends a row to quiz_student_resets; start_student_quiz then allows max_attempts * (reset_count + 1) attempts. All previous attempts (scores + answers) remain visible via §5.4.
Works for anyAuthorization: gated by theQuiz— both quizzes (training/chapter-linked) and standalone exams. Thequiz_idis the route path param.
update Quiz policy — admins and instructors with quiz-update rights only. Students cannot self-reset (403 otherwise).
Path Parameter: quiz_id — the quiz/exam to grant attempts on (must exist in quizzes).
Request Fields:
Request URL + Body:
- Validates
student_id(exists inusers) andnote(nullable string). - Loads the quiz (
404ifquiz_idnot found) and authorizes the caller via theupdateQuiz policy (403if unauthorized). - Creates a
quiz_student_resetsrow:quiz_id,student_id,reset_by = auth()->id(),note. - History:
reset_student_attempts(resource: student id, table:quiz_student_resets). - Returns the new reset count and the recomputed ceiling.
Error Responses:
Resetting an unlimited quiz (max_attempts == 0) is a functional no-op — the ceiling check never blocks anyway — but the reset row is still recorded for the audit log, andallowed_attemptscomes back as0(still unlimited).
6. Answer Payload Shape (by question type)
The two answer arrays in §5.2 encode differently for each question type. Use this as a quick reference when building thefinish_student_quiz payload.
Full worked example — a quiz with 4 questions, one of each type:
- Q1 (True/False): option ids are
60(True) and61(False). Student selects61. - Q2 (Single Answer): option ids are
62,63,64. Student selects63. - Q3 (Multiple Answer): option ids are
65,66,67,68. Student selects65,66,68. - Q4 (Drag & Drop): buckets are options
69and70. Student drops item201into bucket69and item202into bucket70.
201 and 203 into bucket 69, and item 202 into bucket 70:
present) but may be empty.
7. Business Rules & Edge Cases
attemptis auto-derived server-side from(max(attempt) + 1) ?? 1, and is monotonic — it is never rewound, even after a reset. Never pass it in.max_attempts == 0(ornull) means unlimited. Only> 0enforces a ceiling.- The ceiling is reset-aware.
start_student_quizblocks whenattempt > max_attempts * (reset_count + 1). Eachquiz_student_resetsrow (§5.5) grants one more full cycle. Resets never delete attempts, so history and analytics stay intact. reset_cycleis derived, not stored. §5.4 computes it in-memory by bucketing each attempt against the reset timestamps; it never appears as a DB column.- Only admins/instructors can reset (the
updateQuiz policy). Students cannot grant themselves more attempts. finish_student_quizis single-shot per attempt. It checks!$quiz_student_attempt->quiz_questions()->exists()— once any answer row is present for that attempt, a second submit returns500 Quiz has not started!. To re-answer, start a new attempt.distinctvalidation on option ids —quiz_question_options_ids.*isdistinct, so the same option id can’t appear twice. This matters for multi-answer questions where you’d never want duplicates anyway.- The two drag-and-drop arrays must be the same length, otherwise
422. - Score is not computed here. This controller only persists what the caller passes in
save_student_quiz_result. The “correctness” comparison happens elsewhere (client / dedicated grader). - Column name typo:
uncorrect_answers. Keep it spelled this way in payloads — that is the actual column name. show_quiz_student_answersis implicitly per-user — it doesn’t acceptstudent_id. Admin / instructor views over another student’s attempts live elsewhere (seeCertificationsControllerand the Instructor dashboard page).
8. History Tracking Actions
Calls toinsert_in_history_table($action, $resource_id, $table):
§5.4 is read-only — no history is written.
9. Related Files
app/Http/Controllers/Quizzes_Exams_Progress_Tracking_Controller.phpapp/Models/QuizStudentAttempt.phpapp/Models/QuizStudentReset.php— non-destructive attempt-reset logdatabase/migrations/2026_06_05_000000_create_quiz_student_resets_table.phpapp/Models/QuizQuestion.php,QuizQuestionOption.php,QuizQuestionOptionItem.php,QuizQuestionType.phpapp/Models/Quiz.php—max_attempts,isExam()scoperoutes/api.php—Quizzes_Exams_Progress_Tracking_Controllergroup- DB table
quiz_student_answers— the answer join table (attempt × question × option × option-item) - Quizzes & Exams endpoints — authoring, listing, import / export
- Suggestions: user attempts fix — attempt-reset design rationale & shipped variant
- Instructor dashboard — instructor / dashboard views over student progress
- Certifications — certification flow once a student passes