Skip to main content
Student Attempt Lifecycle (start / finish / score / review)
This file documents Quizzes_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

  1. Overview
  2. Base URL and Headers
  3. Lifecycle Diagram
  4. Data Model: QuizStudentAttempt & QuizStudentReset
  5. 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)
  6. Answer Payload Shape (by question type)
  7. Business Rules & Edge Cases
  8. History Tracking Actions
  9. Related Files

1. Overview

A “quiz/exam attempt” for a student is one row in quiz_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:
  1. start_student_quiz — creates a new attempt row (monotonic attempt number per student × quiz; the attempt ceiling is reset-aware — see §5.1).
  2. finish_student_quiz — stamps finished_at and writes the answer rows into quiz_student_answers.
  3. 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.
  4. show_quiz_student_answers — fetches the authenticated student’s attempts (with filters); each attempt is annotated with a derived reset_cycle.
  5. reset_student_attempts — admin/instructor grants a student one more full cycle of max_attempts without deleting any history (logged in quiz_student_resets).

2. Base URL and Headers

Most endpoints take student_id explicitly in the body (so an admin can drive a flow on behalf of a student). show_quiz_student_answers is the exception — it uses auth()->id() directly.

3. Lifecycle Diagram


4. Data Model: QuizStudentAttempt & QuizStudentReset

QuizStudentAttempt

File: app/Models/QuizStudentAttempt.php — extends Illuminate\Database\Eloquent\Relations\Pivot.
Significant columns: 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 pivot quiz_question_option_id, quiz_question_option_item_id
  • quiz_question_options() — same join through quiz_question_option_id
  • quiz_question_option_items() — same join through quiz_question_option_item_id
Each answer the student gives ends up as one row in 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 is max_attempts * (reset_count + 1), where reset_count = number of quiz_student_resets rows 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 (from routes/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:
Behavior:
  1. Computes the next absolute attempt number — (max(attempt) for this student × quiz) + 1, or 1 if none. This counter is monotonic and never resets.
  2. Counts the student’s reset rows for this quiz (reset_count) and computes the ceiling allowed_attempts = quiz.max_attempts * (reset_count + 1).
  3. If quiz.max_attempts > 0 and the new attempt > allowed_attempts → 401 Quiz/Exam cannot be started, too many attempts. (When max_attempts == 0, there is no ceiling — unlimited.)
  4. Creates a quiz_student_attempts row with quiz_id, student_id, attempt.
  5. History: start_student_quiz (resource: student id, table: quiz_student_attempts).
Reset-aware ceiling: every quiz_student_resets row (created via §5.5) grants one more full cycle of max_attempts. With max_attempts = 3 and 1 reset, the student may start the quiz 3 * (1 + 1) = 6 times total. The historical attempt numbers keep climbing (4, 5, 6 …) — they are not rewound.
Success Response (200):
Error Responses:
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 the present validation 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 61 and 65, 66, 68
  • Drag-and-drop answers: bucket 69 receives items 201; bucket 70 receives items 202

Example E — Student submits nothing (empty submission):

Behavior:
  1. Looks up the latest attempt for student × quiz.
  2. Only writes answers if the attempt exists and no answers have been recorded yet. Otherwise returns 500 Quiz has not started!.
  3. Stamps finished_at = now().
  4. For each id in quiz_question_options_ids, writes a row to quiz_student_answers linking the attempt, the option, and the derived question.
  5. For each drag-and-drop pair [i], writes a row linking the attempt, the option item, the derived question, and the bucket option quiz_question_option_items_groups[i].
  6. History: finish_student_quiz.
Success Response (200):
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:
Behavior:
  1. QuizStudentAttempt::findOrFail($attempt_id) — 404 if not found.
  2. Writes correct_answers, uncorrect_answers, score_percentage, passed_at and saves.
  3. History: save_student_quiz_result.
Success Response (200):
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:
Student failed (below passing threshold):

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:
Success Response (200):
The pivot on each quiz_questions item 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 (or null for non-drag-and-drop questions).
reset_cycle is 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 cycle 1; each reset bumps subsequent attempts to cycle 2, 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 on quiz_student_attempts.
reset_count, allowed_attempts and attempts_used (19/10/2026) are the same numbers start_student_quiz enforces, computed once per quiz for all rows: allowed_attempts = max_attempts * (reset_count + 1) (null when max_attempts is 0 = unlimited) and attempts_used = MAX(attempt). The apps read them instead of guessing the ceiling from reset_cycle. The same block is returned as attempts by GET /auth/user (my_exams[].attempts) and GET /quiz/{id} for a student caller.
Empty Response (200) — no attempts yet:

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 any Quiz — both quizzes (training/chapter-linked) and standalone exams. The quiz_id is the route path param.
Authorization: gated by the 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:
Behavior:
  1. Validates student_id (exists in users) and note (nullable string).
  2. Loads the quiz (404 if quiz_id not found) and authorizes the caller via the update Quiz policy (403 if unauthorized).
  3. Creates a quiz_student_resets row: quiz_id, student_id, reset_by = auth()->id(), note.
  4. History: reset_student_attempts (resource: student id, table: quiz_student_resets).
  5. Returns the new reset count and the recomputed ceiling.
Success Response (200):
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, and allowed_attempts comes back as 0 (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 the finish_student_quiz payload.
Reference table — what goes where: Full worked example — a quiz with 4 questions, one of each type:
  • Q1 (True/False): option ids are 60 (True) and 61 (False). Student selects 61.
  • Q2 (Single Answer): option ids are 62, 63, 64. Student selects 63.
  • Q3 (Multiple Answer): option ids are 65, 66, 67, 68. Student selects 65, 66, 68.
  • Q4 (Drag & Drop): buckets are options 69 and 70. Student drops item 201 into bucket 69 and item 202 into bucket 70.
A bucket with multiple items — student drops items 201 and 203 into bucket 69, and item 202 into bucket 70:
Note that all four arrays are required by validation (present) but may be empty.

7. Business Rules & Edge Cases

  • attempt is 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 (or null) means unlimited. Only > 0 enforces a ceiling.
  • The ceiling is reset-aware. start_student_quiz blocks when attempt > max_attempts * (reset_count + 1). Each quiz_student_resets row (§5.5) grants one more full cycle. Resets never delete attempts, so history and analytics stay intact.
  • reset_cycle is 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 update Quiz policy). Students cannot grant themselves more attempts.
  • finish_student_quiz is single-shot per attempt. It checks !$quiz_student_attempt->quiz_questions()->exists() — once any answer row is present for that attempt, a second submit returns 500 Quiz has not started!. To re-answer, start a new attempt.
  • distinct validation on option ids — quiz_question_options_ids.* is distinct, 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_answers is implicitly per-user — it doesn’t accept student_id. Admin / instructor views over another student’s attempts live elsewhere (see CertificationsController and the Instructor dashboard page).

8. History Tracking Actions

Calls to insert_in_history_table($action, $resource_id, $table): §5.4 is read-only — no history is written.
  • app/Http/Controllers/Quizzes_Exams_Progress_Tracking_Controller.php
  • app/Models/QuizStudentAttempt.php
  • app/Models/QuizStudentReset.php — non-destructive attempt-reset log
  • database/migrations/2026_06_05_000000_create_quiz_student_resets_table.php
  • app/Models/QuizQuestion.php, QuizQuestionOption.php, QuizQuestionOptionItem.php, QuizQuestionType.php
  • app/Models/Quiz.php — max_attempts, isExam() scope
  • routes/api.php — Quizzes_Exams_Progress_Tracking_Controller group
  • 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