5W Overview (5W)

📦 What

Exposes Moodle's mod_quiz activity to the student portal. Students can list quizzes per course, view quiz settings and attempt history, start or resume an attempt, navigate questions page-by-page, auto-save answers, preview a pre-submit summary, finalize the attempt, and review completed attempts. Eight question types are supported: multichoice, truefalse, shortanswer, essay, numerical, match, calculated, and description. Review content (marks, correct answers, feedback) is gated by Moodle's per-quiz review-option bitmask per time context.

⏱ When

Triggered on every student HTTP request. Auto-save writes occur on each page navigation (POST /save). Grading runs synchronously at finish time inside a DB transaction. Overdue detection fires reactively on every save/show/finish request when a time limit is configured — no background jobs. Event forwarding to Moodle runs asynchronously on the Laravel queue after each attempt lifecycle transition (started, saved, finished, reviewed).

❓ Why

Quizzes are the primary summative assessment tool in Flexi's curriculum alongside assignments. The student portal needs its own quiz API — Moodle's web UI is not part of this portal. Teacher grading, quiz creation, and quiz reports remain in Moodle. The module writes directly to Moodle's question-engine tables (with formally approved write access) so Moodle's gradebook, reporting, and analytics remain fully consistent.

📍 Where

New top-level module at app/Modules/Quiz/ (mirrors Assignment module structure, not nested inside Course). Twenty dedicated Shared models in app/Shared/Models/ cover all quiz and question-engine Moodle tables. Routes registered under /api/v1/ via QuizServiceProvider. Question-engine write operations are isolated inside QuestionEngineService and QuestionEngineRepository.

⚙️ How

Attempt bootstrap: AttemptStartService creates a question_usages record, bootstraps question_attempts for each slot, and writes initial question_attempt_steps (state=todo) in one DB::transaction(). Page layout is parsed from the quiz_attempts.layout string (comma-separated slot numbers with 0 as page separator).

Saving: AttemptSaveService appends new question_attempt_steps + question_attempt_step_data rows per slot — append-only, immutable history mirroring Moodle's native question engine.

Grading: GradingService dispatches per QuestionType enum using a match expression. Essay always lands in needsgrading; all others are auto-graded. Grade aggregation respects quiz.grademethod (Highest/Average/First/Last). Override resolution merges user override → group override → quiz defaults via OverrideResolverService. All four attempt lifecycle events extend BaseEvent and are forwarded to Moodle asynchronously via the existing ForwardEventToMoodle listener.

→ Sequence Diagram

sequenceDiagram autonumber participant B as Browser participant MW as Middleware participant C as Controller participant FR as FormRequest participant SVC as Service participant QE as QuestionEngineService participant REPO as Repository participant DB as Moodle DB participant Q as Queue participant M as Moodle Plugin Note over B,M: POST /api/v1/quizzes/{quizId}/attempts B->>MW: POST /attempts + Bearer token MW->>MW: Sanctum auth + moodle.active MW->>FR: StartAttemptRequest authorize() + validate() FR->>REPO: check enrollment REPO->>DB: SELECT quiz_overrides, enrolments DB-->>FR: enrolled = true FR-->>C: validated C->>SVC: AttemptStartService::start(StartAttemptDTO) SVC->>REPO: OverrideRepository findUserOverride + findGroupOverrides REPO->>DB: SELECT quiz_overrides DB-->>SVC: effective settings SVC->>REPO: QuizAttemptRepository findInProgress REPO->>DB: SELECT quiz_attempts WHERE state=inprogress DB-->>SVC: null SVC->>DB: BEGIN TRANSACTION SVC->>REPO: QuizAttemptRepository create REPO->>DB: INSERT quiz_attempts DB-->>SVC: attempt_id SVC->>QE: bootstrapAttempt(quiz, attempt, slots, contextId) QE->>REPO: createUsage REPO->>DB: INSERT question_usages loop For each slot QE->>REPO: createAttempt REPO->>DB: INSERT question_attempts QE->>REPO: createStep(state=todo) REPO->>DB: INSERT question_attempt_steps end SVC->>DB: COMMIT SVC->>Q: dispatch QuizAttemptStarted async Q-->>M: ForwardEventToMoodle POST REST API SVC-->>C: attempt + page data C-->>B: 201 JSON attempt_id, page, navigation Note over B,M: POST /api/v1/quiz-attempts/{id}/save B->>C: POST /save page + responses C->>FR: SaveAnswersRequest validate C->>SVC: AttemptSaveService::save(SaveAnswersDTO) SVC->>SVC: overdue check timelimit + overduehandling SVC->>QE: savePageAnswers(attempt, page, responses) loop For each slot with response QE->>REPO: getLatestStep REPO->>DB: SELECT max sequencenumber QE->>REPO: createStep(seq+1, complete) REPO->>DB: INSERT question_attempt_steps QE->>REPO: createStepData(stepId, kv pairs) REPO->>DB: INSERT question_attempt_step_data end REPO->>DB: UPDATE quiz_attempts currentpage timemodified SVC->>Q: dispatch QuizAttemptAnswersSaved async C-->>B: 200 JSON navigation time_remaining Note over B,M: POST /api/v1/quiz-attempts/{id}/finish B->>C: POST /finish page + responses C->>SVC: AttemptFinishService::finish(FinishAttemptDTO) SVC->>QE: savePageAnswers final page SVC->>DB: BEGIN TRANSACTION loop For each question_attempt SVC->>REPO: QuestionRepository findById findAnswers REPO->>DB: SELECT question question_answers SVC->>SVC: GradingService grade per QuestionType SVC->>REPO: createStep graded state + fraction REPO->>DB: INSERT question_attempt_steps end SVC->>REPO: QuizAttemptRepository finalize sumgrades timefinish REPO->>DB: UPDATE quiz_attempts state=finished SVC->>REPO: QuizGradeRepository upsert grade REPO->>DB: INSERT ON DUPLICATE KEY UPDATE quiz_grades SVC->>DB: COMMIT SVC->>Q: dispatch QuizAttemptFinished async C-->>B: 200 JSON grade grade_feedback can_review_now

◆ Flowchart — Attempt Lifecycle

flowchart TD A([POST /attempts]) --> B{Authenticated?} B -- No --> B1[401 Unauthenticated] B -- Yes --> C{Enrolled in course?} C -- No --> C1[403 Forbidden] C -- Yes --> D{Quiz open?} D -- No --> D1[409 QUIZ_NOT_OPEN 5002] D -- Yes --> E{Password required?} E -- wrong --> E1[422 QUIZ_PASSWORD_REQUIRED 5004] E -- OK --> F{Attempts exhausted?} F -- Yes --> F1[409 ATTEMPTS_EXHAUSTED 5003] F -- No --> G{Existing inprogress\nattempt?} G -- Yes --> H[Resume 200 OK] G -- No --> I[INSERT quiz_attempts\nbootstrap question engine\nDB transaction] I --> I1[INSERT question_usages] I1 --> I2[INSERT question_attempts x slots] I2 --> I3[INSERT question_attempt_steps state=todo] I3 --> J[dispatch QuizAttemptStarted async] J --> K[201 Created — page 0 + navigation] K --> L([POST /save]) L --> M{Attempt inprogress?} M -- No --> M1[409 ATTEMPT_ALREADY_FINISHED 5006] M -- Yes --> N{Time limit exceeded?} N -- autosubmit --> N1[auto-finish 200 finished state] N -- graceperiod --> N2[409 ATTEMPT_OVERDUE 5009] N -- autoabandon --> N3[409 ATTEMPT_ABANDONED 5010] N -- No limit --> O{Valid page?} O -- No --> O1[422 INVALID_PAGE 5011] O -- Yes --> P{Sequential nav\npage jump?} P -- Violation --> P1[422 SEQUENTIAL_NAV_VIOLATION 5012] P -- OK --> Q[INSERT question_attempt_steps\n+ step_data per slot] Q --> R[UPDATE quiz_attempts.currentpage] R --> S[dispatch QuizAttemptAnswersSaved async] S --> T[200 OK navigation + time_remaining] T --> U([POST /finish]) U --> V[Save final page answers] V --> W{Grade each slot} W --> W1[multichoice truefalse\nfraction from answers] W --> W2[shortanswer pattern match\nnumerical tolerance] W --> W3[essay — needsgrading\nfraction=null] W --> W4[match correct/total] W1 & W2 & W3 & W4 --> X[INSERT graded steps] X --> Y[sumgrades = sum of maxmark x fraction] Y --> Z[UPDATE quiz_attempts state=finished] Z --> AA[UPSERT quiz_grades\nhighest/average/first/last] AA --> AB[dispatch QuizAttemptFinished async] AB --> AC[200 OK grade + grade_feedback] AC --> AD([GET /review]) AD --> AE{Attempt finished?} AE -- No --> AE1[403 ATTEMPT_NOT_FINISHED 5007] AE -- Yes --> AF{Review bitmask\npermits current context?} AF -- No --> AF1[403 REVIEW_NOT_PERMITTED 5015] AF -- Yes --> AG[Build per-question review\ngated by reviewmarks\nreviewrightanswer etc] AG --> AH[dispatch QuizAttemptReviewed async] AH --> AI[200 OK full review payload] style B1 fill:#3f1111,stroke:#ef4444,color:#fca5a5 style C1 fill:#3f1111,stroke:#ef4444,color:#fca5a5 style D1 fill:#3f1111,stroke:#ef4444,color:#fca5a5 style E1 fill:#3f1111,stroke:#ef4444,color:#fca5a5 style F1 fill:#3f1111,stroke:#ef4444,color:#fca5a5 style M1 fill:#3f1111,stroke:#ef4444,color:#fca5a5 style N1 fill:#1a2a1a,stroke:#34d399,color:#6ee7b7 style N2 fill:#3f2a00,stroke:#f59e0b,color:#fcd34d style N3 fill:#3f1111,stroke:#ef4444,color:#fca5a5 style O1 fill:#3f1111,stroke:#ef4444,color:#fca5a5 style P1 fill:#3f1111,stroke:#ef4444,color:#fca5a5 style AE1 fill:#3f1111,stroke:#ef4444,color:#fca5a5 style AF1 fill:#3f1111,stroke:#ef4444,color:#fca5a5

📁 Files Changed

File Path Layer Description
app/Modules/Quiz/Controllers/QuizController.phpControllerindex (quiz list) and show (full quiz detail) — thin HTTP delegates to QuizListService / QuizShowService
app/Modules/Quiz/Controllers/QuizAttemptController.phpController6 actions: store (start), show (page), save, summary, finish, review — delegates to respective services
app/Modules/Quiz/Requests/ListQuizzesRequest.phpRequestValidates courseId; authorizes that student is enrolled
app/Modules/Quiz/Requests/ShowQuizRequest.phpRequestValidates quizId; authorizes enrollment in quiz's course
app/Modules/Quiz/Requests/StartAttemptRequest.phpRequestOptional password (max:64) and force_new (boolean); enrollment authorization
app/Modules/Quiz/Requests/SaveAnswersRequest.phpRequestpage (int ≥ 0) + responses array; custom rule ensures keys are valid integer slot numbers — prevents slot injection
app/Modules/Quiz/Requests/FinishAttemptRequest.phpRequestSame rules as SaveAnswersRequest; used for finalizing the attempt
app/Modules/Quiz/Requests/ShowAttemptPageRequest.phpRequestValidates optional page query parameter for GET /quiz-attempts/{id}
app/Modules/Quiz/Requests/AttemptSummaryRequest.phpRequestAuthorization guard for the pre-submit summary endpoint
app/Modules/Quiz/Requests/ReviewAttemptRequest.phpRequestNo body; authorizes that attempt belongs to the authenticated student
app/Modules/Quiz/Services/QuizListService.phpServiceLists paginated quizzes with override-resolved effective dates, best grade, and attempt counts
app/Modules/Quiz/Services/QuizShowService.phpServiceFull quiz detail including attempt history, grade bands, current overall grade, and override resolution
app/Modules/Quiz/Services/AttemptStartService.phpServiceGuards quiz open/password/attempt count; resumes or creates attempt; bootstraps question engine in DB::transaction
app/Modules/Quiz/Services/AttemptSaveService.phpServiceSaves page answers; detects overdue via overduehandling; sequential nav enforcement; dispatches QuizAttemptAnswersSaved
app/Modules/Quiz/Services/AttemptSummaryService.phpServiceCompiles pre-submit summary: all slots with current state, answered/unanswered counts, time remaining
app/Modules/Quiz/Services/AttemptFinishService.phpServiceSaves final answers, grades all slots, computes sumgrades, finalizes attempt, upserts quiz_grades — full DB::transaction
app/Modules/Quiz/Services/AttemptReviewService.phpServiceGuards finished state; builds per-question review payload gated by ReviewPermissionResolver bitmask
app/Modules/Quiz/Services/QuestionEngineService.phpServiceOrchestrates question_usages / question_attempts / steps / step_data writes; overdue action dispatch
app/Modules/Quiz/Services/GradingService.phpServicePer-type auto-grading via match expression: multichoice, truefalse, shortanswer, essay (needsgrading), numerical (tolerance), match, calculated, description
app/Modules/Quiz/Services/OverrideResolverService.phpServiceResolves effective timeopen/timeclose/timelimit/attempts/password: user override → group override → quiz defaults
app/Modules/Quiz/Services/ReviewPermissionResolver.phpServicePure logic: determines QuizReviewContext from attempt state + time; decodes review bitmask per field
app/Modules/Quiz/Repositories/QuizRepository.phpRepositoryfindForStudent, listForCourse (paginated), findCmId, findCmContextId
app/Modules/Quiz/Repositories/QuizAttemptRepository.phpRepositoryfindById, findInProgress, countFinished, getAllFinished, create, updateState, updateCurrentPage, finalize
app/Modules/Quiz/Repositories/QuizGradeRepository.phpRepositoryupsert and find quiz_grades — grade aggregation after every finish
app/Modules/Quiz/Repositories/QuestionRepository.phpRepositoryfindById, findAnswers, findTypeOptions (dispatches per QuestionType), findMatchSubquestions, findQuestionsForAttempt
app/Modules/Quiz/Repositories/QuestionEngineRepository.phpRepositorycreateUsage, createAttempt, getAttemptBySlot, getLatestStep, createStep, createStepData, updateAttemptSummary
app/Modules/Quiz/Repositories/OverrideRepository.phpRepositoryfindUserOverride, findGroupOverrides — reads quiz_overrides for effective settings resolution
app/Modules/Quiz/Interfaces/QuizRepositoryInterface.phpInterfaceContract for QuizRepository — enables constructor DI through abstraction
app/Modules/Quiz/Interfaces/QuizAttemptRepositoryInterface.phpInterfaceContract for QuizAttemptRepository
app/Modules/Quiz/Interfaces/QuizGradeRepositoryInterface.phpInterfaceContract for QuizGradeRepository
app/Modules/Quiz/Interfaces/QuestionRepositoryInterface.phpInterfaceContract for QuestionRepository
app/Modules/Quiz/Interfaces/QuestionEngineRepositoryInterface.phpInterfaceContract for QuestionEngineRepository
app/Modules/Quiz/Interfaces/OverrideRepositoryInterface.phpInterfaceContract for OverrideRepository
app/Modules/Quiz/Resources/QuizListResource.phpResourceLightweight list shape: id, name, intro_excerpt, effective dates, best_grade, has_unfinished_attempt
app/Modules/Quiz/Resources/QuizResource.phpResourceFull quiz detail: all list fields + intro (filtered HTML), attempts[], grade_bands[], effective settings
app/Modules/Quiz/Resources/AttemptResource.phpResourceAttempt metadata + navigation array + embedded AttemptPageResource for current page
app/Modules/Quiz/Resources/QuestionResource.phpResourceAll 8 question types via QuestionType match expression; type_config varies per type
app/Modules/Quiz/Resources/AttemptSummaryResource.phpResourcePre-submit summary: total/answered/not_answered counts + full navigation slot states
app/Modules/Quiz/Resources/AttemptFinishResource.phpResourceFinish result: state, time_finish, sum_grades, grade, grade_feedback (if permitted), can_review_now
app/Modules/Quiz/Resources/AttemptReviewResource.phpResourceFull review: attempt metadata + questions (ReviewQuestionResource collection) + overall_feedback (gated)
app/Modules/Quiz/Resources/ReviewQuestionResource.phpResourcePer-question review: awarded_mark, show_marks/correctness/right_answer (bitmask gated), specific/general feedback
app/Modules/Quiz/Events/QuizAttemptStarted.phpEventExtends BaseEvent; component=mod_quiz, target=attempt, objectTable=quiz_attempts; forwarded to Moodle
app/Modules/Quiz/Events/QuizAttemptAnswersSaved.phpEventExtends BaseEvent; dispatched on every page save (pending PM decision on Moodle forwarding)
app/Modules/Quiz/Events/QuizAttemptFinished.phpEventExtends BaseEvent; dispatched after attempt finalized and quiz_grades updated
app/Modules/Quiz/Events/QuizAttemptReviewed.phpEventExtends BaseEvent; dispatched when student accesses the review endpoint
app/Modules/Quiz/Enums/QuizGradeMethod.phpEnumint enum: Highest(1), Average(2), First(3), Last(4) — grade aggregation strategy
app/Modules/Quiz/Enums/QuizNavMethod.phpEnumstring enum: Free('free'), Sequential('seq') — controls page navigation enforcement
app/Modules/Quiz/Enums/QuizOverdueHandling.phpEnumstring enum: Autosubmit, Graceperiod, Autoabandon — time expiry behaviour
app/Modules/Quiz/Enums/QuizAttemptState.phpEnumstring enum with isEditable() and isReviewable() helpers per state
app/Modules/Quiz/Enums/QuestionState.phpEnumstring enum with isAnswered(), isGraded(), isCorrect() helpers for all 8 states
app/Modules/Quiz/Enums/QuestionType.phpEnumstring enum for 8 question types; supportsAutoGrading() and fromMoodleType() named constructor
app/Modules/Quiz/Enums/QuizReviewContext.phpEnumint enum bitmask: DuringAttempt(65536), ImmediatelyAfter(4096), LaterOpen(256), AfterClose(16)
app/Modules/Quiz/Enums/QuizErrorCode.phpEnumint enum: error codes 5001–5015 for all domain errors
app/Modules/Quiz/Exceptions/QuizNotFoundException.phpExceptionQuiz not found or student not enrolled — code 5001
app/Modules/Quiz/Exceptions/QuizNotOpenException.phpExceptionQuiz outside availability window — code 5002
app/Modules/Quiz/Exceptions/AttemptsExhaustedException.phpExceptionAll allowed attempts used — code 5003
app/Modules/Quiz/Exceptions/QuizPasswordRequiredException.phpExceptionPassword required or incorrect — code 5004
app/Modules/Quiz/Exceptions/AttemptNotFoundException.phpExceptionAttempt ID does not exist or belongs to another student — code 5005
app/Modules/Quiz/Exceptions/AttemptAlreadyFinishedException.phpExceptionAttempt already finished or abandoned — code 5006
app/Modules/Quiz/Exceptions/AttemptNotFinishedException.phpExceptionReview requested on unfinished attempt — code 5007
app/Modules/Quiz/Exceptions/AttemptNoQuestionsException.phpExceptionQuiz has no questions configured — code 5008
app/Modules/Quiz/Exceptions/AttemptOverdueException.phpExceptionAttempt is overdue — code 5009
app/Modules/Quiz/Exceptions/AttemptAbandonedException.phpExceptionAttempt was abandoned — code 5010
app/Modules/Quiz/Exceptions/InvalidPageException.phpExceptionPage number out of range — code 5011
app/Modules/Quiz/Exceptions/SequentialNavViolationException.phpExceptionSequential quiz non-sequential page jump — code 5012
app/Modules/Quiz/Exceptions/ResponseValidationException.phpExceptionResponse data failed type-specific validation — code 5013
app/Modules/Quiz/Exceptions/QuestionEngineWriteException.phpExceptionDB write to question engine tables failed — code 5014
app/Modules/Quiz/Exceptions/ReviewNotPermittedException.phpExceptionCurrent time context does not allow reviewing — code 5015
app/Modules/Quiz/DTOs/ListQuizzesDTO.phpDTOcourseId, studentId, perPage=15 — input to QuizListService
app/Modules/Quiz/DTOs/ShowQuizDTO.phpDTOquizId, studentId — input to QuizShowService
app/Modules/Quiz/DTOs/StartAttemptDTO.phpDTOquizId, studentId, password?, forceNew — input to AttemptStartService
app/Modules/Quiz/DTOs/SaveAnswersDTO.phpDTOattemptId, studentId, page, responses[] — input to AttemptSaveService
app/Modules/Quiz/DTOs/FinishAttemptDTO.phpDTOSame shape as SaveAnswersDTO — input to AttemptFinishService
app/Modules/Quiz/DTOs/ReviewAttemptDTO.phpDTOattemptId, studentId — input to AttemptReviewService
app/Modules/Quiz/DTOs/AttemptContext.phpDTOInternal value object: MoodleQuizAttempt + MoodleQuiz + resolved effective settings
app/Modules/Quiz/DTOs/GradingResult.phpDTOQuestionState, fraction, rightAnswer, responseSummary — output of GradingService per slot
app/Modules/Quiz/DTOs/LayoutPage.phpDTOInternal: pageNumber + slots[] — parsed from quiz_attempts.layout comma-separated string
app/Modules/Quiz/routes.phpRoute8 routes under /api/v1/; throttle:60,1 on reads, throttle:20,1 on writes; auth:sanctum + moodle.active
app/Shared/Models/MoodleQuiz.phpModelExtended with slots(), sections(), overrides(), feedback(), attempts() relationships + enum casts for grademethod/navmethod/overduehandling
app/Shared/Models/MoodleQuizSlot.phpModelRead-only; table quiz_slots; relationships to MoodleQuiz + MoodleQuestion; slot/page/maxmark casts
app/Shared/Models/MoodleQuizSection.phpModelRead-only; table quiz_sections; firstslot, shufflequestions casts
app/Shared/Models/MoodleQuizFeedback.phpModelRead-only; table quiz_feedback; grade-band mingrade/maxgrade used in show endpoint
app/Shared/Models/MoodleQuizOverride.phpModelRead-only; table quiz_overrides; user/group overrides for effective settings resolution
app/Shared/Models/MoodleQuizAttempt.phpModelWRITE; table quiz_attempts; state cast to QuizAttemptState enum; fillable columns defined
app/Shared/Models/MoodleQuizGrade.phpModelWRITE; table quiz_grades; upserted after every finish per grademethod
app/Shared/Models/MoodleQuestion.phpModelRead-only; table question; has relationships to answers + all qtype option models
app/Shared/Models/MoodleQuestionCategory.phpModelRead-only; table question_categories; parent/contextid casts
app/Shared/Models/MoodleQuestionAnswer.phpModelRead-only; table question_answers; fraction used for multichoice/truefalse/shortanswer grading
app/Shared/Models/MoodleQuestionUsage.phpModelWRITE; table question_usages; one per attempt; component=mod_quiz; contextid cast
app/Shared/Models/MoodleQuestionAttempt.phpModelWRITE; table question_attempts; one per slot per attempt; hasMany steps(); flagged cast to bool
app/Shared/Models/MoodleQuestionAttemptStep.phpModelWRITE; table question_attempt_steps; state cast to QuestionState; append-only immutable history
app/Shared/Models/MoodleQuestionAttemptStepData.phpModelWRITE; table question_attempt_step_data; key-value pairs per step (response data per question type)
app/Shared/Models/MoodleQuestionNumerical.phpModelRead-only; table question_numerical; tolerance used in numerical grading (parsed as float at service layer)
app/Shared/Models/MoodleQtypeMultichoiceOptions.phpModelRead-only; table qtype_multichoice_options; single/shuffleanswers bool casts
app/Shared/Models/MoodleQtypeShortanswerOptions.phpModelRead-only; table qtype_shortanswer_options; usecase (case sensitivity) bool cast
app/Shared/Models/MoodleQtypeEssayOptions.phpModelRead-only; table qtype_essay_options; responseformat, attachments for type_config in QuestionResource
app/Shared/Models/MoodleQtypeMatchOptions.phpModelRead-only; table qtype_match_options; shuffleanswers flag
app/Shared/Models/MoodleQtypeMatchSubquestion.phpModelRead-only; table qtype_match_subquestions; sub-items for match question display and grading
tests/Feature/Quiz/QuizListTest.phpTestFeature: paginated list, enrollment guard, override effective dates, best grade, unfinished attempt flag
tests/Feature/Quiz/QuizAttemptStartTest.phpTestFeature: new attempt creation + DB row verification, resume, exhausted attempts, quiz not open, password, event firing, layout building
tests/Feature/Quiz/QuizAttemptFinishTest.phpTestFeature: grading per question type, state transitions, quiz_grades upsert, already-finished guard, event firing
tests/Unit/Modules/Quiz/Services/ReviewPermissionResolverTest.phpTestUnit: all 4 review contexts × 7 review flags — 28+ assertions, no DB
tests/Unit/Modules/Quiz/Services/GradingServiceTest.phpTestUnit: per-type graders — correct, wrong, partial, edge cases (case sensitivity, tolerance bounds, multi-correct multichoice)
tests/Unit/Modules/Quiz/Services/QuestionEngineServiceTest.phpTestUnit: layout building for questionsperpage=0/1/N, sequential/free nav page parsing, shuffled sections
tests/Unit/Modules/Quiz/Enums/QuizAttemptStateTest.phpTestUnit: isEditable() and isReviewable() per state
tests/Unit/Modules/Quiz/Enums/QuestionStateTest.phpTestUnit: isAnswered(), isGraded(), isCorrect() per state

📏 Rules Applied

Architecture — Layer Separation

  • All 8 controller methods are 5–15 lines; zero business logic in controllers
  • Full stack enforced: Route → Middleware → Controller → FormRequest → Service → Repository → Model
  • No layer skipping — services never touch Request objects; controllers never touch DB
  • QuestionEngineService isolates all question-engine write orchestration from domain services
  • GradingService encapsulates all grading logic; services call only the single grade() entry point

Architecture — Shared Database Rules

  • All Moodle table access via dedicated Shared models in app/Shared/Models/
  • Read-only models override save/delete to throw exceptions — write guard enforced at model level
  • 6 write-enabled models (quiz_attempts, quiz_grades, question_usages, question_attempts, question_attempt_steps, question_attempt_step_data) documented in write-access-approval.md
  • No migrations created — quiz and question tables are Moodle-owned
  • DB_TABLE_PREFIX applied uniformly — no manual prefix in model $table properties

Architecture — Events System

  • All 4 quiz events extend App\Shared\Events\BaseEvent
  • BaseEvent fields populated: component=mod_quiz, target=attempt, objectTable=quiz_attempts
  • ForwardEventToMoodle listener handles all BaseEvent subclasses — no changes required to listener
  • All event dispatching is queued async — never blocks HTTP response
  • Event::fake() used in feature tests to assert dispatch without queue execution

Architecture — API Design

  • All routes under /api/v1/ — version in URL path, not headers
  • Resource naming: plural nouns (/quizzes, /quiz-attempts), kebab-case URIs
  • Correct HTTP verbs: GET for reads, POST for start/save/finish
  • Standard envelope on every response: success, message, data, errors, code
  • Rate limiting: throttle:60,1 on reads; throttle:20,1 on write endpoints

Coding Style — PHP 8.3 Features

  • 8 enums used for all fixed value sets — no string constants or magic numbers
  • All DTOs are final readonly classes with constructor-promoted typed properties
  • match expressions used throughout GradingService for per-type dispatch
  • Named arguments used for clarity on 3+ parameter calls
  • All properties and parameters have explicit types — no mixed anywhere
  • Strict comparison (===) enforced throughout — no type juggling

Coding Style — Laravel First

  • Collection::map / ::filter over array_map / array_filter throughout
  • Carbon for all timestamp arithmetic (timestart, timefinish, time_remaining)
  • DB::transaction() for both attempt bootstrap and finish/grading atomic writes
  • Http::fake() for Moodle REST API mocking in feature tests
  • Constructor injection everywhere — no app() helper in services/repositories
  • All 6 repository interfaces bound in QuizServiceProvider — services depend on contracts

Security

  • Every attempt access verifies quiz_attempts.userid === auth()->id() before any read or write
  • All authorization in FormRequest::authorize() — not in services or controllers
  • SaveAnswersRequest validates that response keys are valid slot numbers — prevents slot injection
  • Password check in StartAttemptRequest (FormRequest layer) before service invocation
  • Review endpoint guards QuizAttemptState::Finished before decoding review permissions
  • No raw SQL — all DB access via Eloquent or Query Builder with parameter binding
  • No Moodle table names or raw DB errors leaked in 5xx responses

Testing — TDD Workflow

  • Red → Green → Refactor enforced per task (per tasks.md TDD Rule callout)
  • No Moodle* factory files — tests seed via DB::table()->insert() in setUp()
  • RefreshDatabase trait on all feature tests
  • Event::fake() for all attempt lifecycle event dispatch assertions
  • Unit tests have zero DB calls (ReviewPermissionResolverTest, GradingServiceTest, enum tests)
  • All test methods prefixed test_it_ in snake_case with descriptive outcome names
  • AAA pattern (Arrange, Act, Assert) in every test method — no test logic (if/else)