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)