Skip to main content
Quizzes, Exams, Question Bank, Import / Export Guide
This file documents the data model and the API surface that powers Quizzes and Exams on the platform. It covers the Eloquent models (QuizQuestion, QuizQuestionOption, QuizQuestionOptionItem, QuizQuestionType, QuizStudentAttempt) and the CRUD/management controller (QuizzesController). Bulk import / export (export quiz/exam JSON, export-all ZIP archives, destructive question import) is documented separately in Quizzes & Exams export / import. The student attempt lifecycle (start / finish / save score / show answers) is documented in Quizzes & Exams progress tracking.
v4 — contract change of 01/11/2026: one category per quiz / exam. categories stays an array on the four write endpoints (§5.3–5.6), so older app builds keep working, but it now holds exactly one id: two or more answer 422 (§3.3). Read payloads keep the categories array, with one item (or none). The list filters (categories=[2,5] on §5.1 and §5.10) still take several ids.
v3 — contract change of 19/10/2026.
  • A chapter holds any number of quizzes. Creating a quiz on a chapter, or moving one into it, no longer turns the chapter’s existing quiz into an exam. Only the training’s final quiz is still one per training (§3, §5.9).
  • slug is optional and server-generated on all four write endpoints (§3.2). Every create / update response now returns it. Before, POST /quiz required it and POST /quiz_with_questions_and_options ignored it (the quiz was saved without a slug).
  • Every check runs before anything is written (§3.1): chapter / training ownership, instructor and revenue rules. The write itself is one database transaction. Validation errors on the create endpoints now answer 422 instead of 500.
  • Read payloads: every chapter now carries a quizzes array plus the deprecated quiz (= the first one). See Chapters and Retrieve a training.
  • quiz_questions is optional on 5.6: left out, the questions are kept.

Table of Contents

  1. Overview
  2. Base URL and Headers
  3. Concepts: Quiz vs. Exam
    • 3.1 Where a quiz is attached (checked before any write)
    • 3.2 Slug (server-generated)
    • 3.3 One category per quiz / exam
  4. Data Model
    • 4.1 Quiz (parent)
    • 4.2 QuizQuestion
    • 4.3 QuizQuestionType
    • 4.4 QuizQuestionOption
    • 4.5 QuizQuestionOptionItem
    • 4.6 QuizStudentAttempt
    • 4.7 Entity Relationship Diagram
  5. QuizzesController Endpoints
    • 5.1 List Quizzes / Exams
    • 5.2 Show Quiz
    • 5.3 Create Quiz (metadata only)
    • 5.4 Update Quiz (metadata only)
    • 5.5 Create Quiz With Questions & Options
    • 5.6 Update Quiz With Questions & Options
    • 5.7 Archive / Unarchive / Force Delete
    • 5.8 Students By Exam
    • 5.9 Helpers: turnQuizToExam, auto_change_instructors_for_quizzes
    • 5.10 Preview Exams (marketplace-safe)
    • 5.11 Preview an Exam (marketplace-safe)
  6. Export / Import Endpoints → moved to Quizzes & Exams export / import
  7. Business Rules & Edge Cases
  8. History Tracking Actions
  9. Related Files

1. Overview

A Quiz is a container of questions. There is one Eloquent model and one quizzes table behind everything here — what a row is depends entirely on whether its training_id and chapter_id foreign keys are set. There is no type column and no separate “exam” table. That single model backs three things, and “Quiz” actually covers two of them:
  • Chapter quiz — has a chapter_id. A checkpoint that lives at a specific chapter of a course. The chapter already belongs to a training, so a chapter quiz is stored with training_id = null and is always found through its chapter. A chapter can hold any number of quizzes (since 19/10/2026); the apps show them after the chapter’s lessons, in creation order.
  • Training quiz (a.k.a. the final quiz) — has a training_id and no chapter_id. The course-level quiz that covers the whole training rather than one chapter.
  • Exam — has neither training_id nor chapter_id (both NULL). Standalone, sold on its own with its own price and instructor revenue_percentage.
So the precise rule is:
Why the distinction matters (not just labelling):
  • A quiz (either kind) is learning content inside a course, never sold on its own. Its revenue_percentage is force-set to 0 (the instructor is paid through the training), and its instructor_id must match the parent training’s instructor. Every quiz and exam has a unique slug (generated by the backend when you don’t send one, §3.2), but only an exam’s slug is recorded in a user’s owned_exams — quizzes are never added to ownership because they cannot be purchased.
  • An exam is the only sellable variant: price becomes required (required_without_all:training_id,chapter_id), and it inherits the instructor’s default_revenue_percentage when none is given.
  • One final quiz per training, any number of chapter quizzes. Creating a final quiz for a training that already has one (or moving a quiz there) silently converts the existing final quiz into an exam via QuizzesController@turnQuizToExam() (it nulls training_id/chapter_id, unpublishes it, and assigns the instructor’s default revenue). See §5.9. Chapters have no such rule since 19/10/2026: before, a second quiz on a chapter turned the first one into an exam.
All three variants share the same question / option / option-item / attempt models documented below.

2. Base URL and Headers

Base API URL:
Suggested Postman variables:
Headers:
company_id must be passed explicitly on most endpoints and is validated against the companies table. App\Http\Middleware\CompanyPolicyMiddleware enforces that the value matches the authenticated user’s own company_id (super admins bypass this check). The only exception is the exam listing endpoint, which resolves company_id directly from auth()->user()->company_id and does not require it in the request.

3. Concepts: Quiz vs. Exam

When you create a final quiz for a training that already has one (or move a quiz onto that training as its final quiz), the existing final quiz is silently converted to an exam through QuizzesController@turnQuizToExam and unpublished. This is intentional — see §5.9. Chapter quizzes never trigger it: a chapter keeps every quiz you add to it (before 19/10/2026 the chapter’s previous quiz was converted the same way).

3.1 Where a quiz is attached (checked before any write)

The four write endpoints (§5.3–5.6) decide what the row becomes from chapter_id and training_id, and check everything before anything is written. Only then do they open one database transaction (convert the previous final quiz if needed, save the quiz, sync categories, write the questions, add the history row). If something fails inside that transaction, nothing is kept and the request answers 500. “The company” is the company_id you send on create (§5.3, §5.5) and the quiz’s own company on update (§5.4, §5.6). An archived chapter or training is accepted; a soft-deleted one gets the same errors.chapter_id / errors.training_id answer. Order of the checks:
  1. Field validation (422 with an errors object).
  2. instructor_id present → else 422 { "message": "Please provide an instructor." } (§5.6 falls back to the quiz’s current instructor).
  3. Attachment (table above) → 422 with errors.chapter_id or errors.training_id.
  4. Instructor and revenue → 422 { "message": "Instructor mismatch: ..." } (quiz) or 422 { "message": "Please contact your platform admin to set the default revenue percentage value." } (exam).
Before 19/10/2026 steps 3 and 4 ran after the quiz was saved: a request that answered 422 Instructor mismatch had already created the quiz and converted the old one. The 422 bodies of step 3:

3.2 Slug (server-generated)

slug is optional on all four write endpoints (POST /quiz, POST /quiz/{id}, POST /quiz_with_questions_and_options, POST /quiz/update_quiz_with_questions_and_options/{id}), for quizzes and exams alike — the same model as training slugs.
  • Create, slug omitted or empty → the backend builds one from the label (Quiz::generateUniqueSlug(), run by the model’s saving hook): kebab case ("PMP Mock Exam" → pmp-mock-exam, "PfMP — Portfolio Management" → pfmp-portfolio-management), cut to 120 characters, then suffixed -2, -3… until unique. Soft-deleted and archived quizzes count as taken (the database unique index covers them). A label with nothing usable (e.g. "???") falls back to exam for an exam and quiz for a chapter / final quiz.
  • Slug sent → trimmed and lowercased, then it must match ^[a-z0-9()\-,._=\[\]:]+$, be at most 255 characters and be unique in quizzes (on update, the quiz’s own slug does not count) → else 422 errors.slug.
  • Update, slug omitted or empty → the stored slug is kept. A quiz that still has no slug gets one generated on its next save.
  • Every create and update response returns the final slug, so the app never has to guess it.
  • Existing rows: the data-only migration 2026_10_19_000000_backfill_missing_quiz_slugs (php artisan migrate) gives a slug, with the same rule, to every quiz / exam whose slug is NULL or empty — mostly those created through POST /quiz_with_questions_and_options, which ignored slug before 19/10/2026. It leaves updated_at untouched and its down() does nothing.

3.3 One category per quiz / exam (since 01/11/2026)

A quiz or an exam has one category, like a training (Trainings → Assign categories). The four write endpoints (POST /quiz, POST /quiz/{id}, POST /quiz_with_questions_and_options, POST /quiz/update_quiz_with_questions_and_options/{id}) keep the categories array, so older app builds still work, but validate it with required|array|min:1|max:1 (Category::REQUIRED_SINGLE_RULE):
  • One id ("categories": [5]) → saved with sync: on update, the id you send replaces the quiz’s current category.
  • Two or more ids → 422 on errors.categories (body below), before anything is written. The message is translated from the Accept-Language header (en, fr, ar; anything else falls back to en).
  • Empty array or no categories → 422 on errors.categories, as before.
  • Read payloads keep the array: GET /quizzes, GET /quiz/{id}, GET /preview-exams, GET /preview-exam/{idOrSlug} and the student’s my_exams[] cards (Auth §9) return categories as a list of { "id", "label" } with one item, or none for a row that never had a category.
  • Filters are unchanged: categories=[2,5] on 5.1 and 5.10 still takes several ids and returns the rows whose category is one of them.
  • Existing rows: the migration 2026_11_01_000000_keep_one_category_per_item (php artisan migrate) kept the oldest category of every quiz / exam that had several (lowest pivot id: the one picked first) and deleted the others, after writing them to the local disk under storage/app/backups/category-pivots/{Ymd-His}.json (no file when nothing had to go). It then added a unique index on category_quiz.quiz_id, so the database itself refuses a second category. Its down() drops the index only: put a removed category back by hand from the backup file.

4. Data Model

4.1 Quiz (parent)

Not redefined in this file — see app/Models/Quiz.php. The fields most relevant to this doc are:
Scopes used here: isQuiz() (has training_id or chapter_id), isExam() (both null), onlyArchived().

4.2 QuizQuestion

File: app/Models/QuizQuestion.php
Traits: HasFactory, Archivable, SoftDeletes. Relations:
  • quiz() — belongsTo(Quiz::class)
  • quiz_question_type() — belongsTo(QuizQuestionType::class)
  • quiz_question_options() — hasMany(QuizQuestionOption::class)
  • image() — belongsTo(File::class, 'image_id')
  • students() — belongsToMany(User::class, 'student_quizzes', ...) with pivot attempt, quiz_id, quiz_question_option_id
  • quiz_student_attempts() — belongsToMany(QuizStudentAttempt::class, 'quiz_student_answers', ...) with pivot attempt, quiz_question_option_id

4.3 QuizQuestionType

File: app/Models/QuizQuestionType.php
Fillable: label. Relation: quiz_questions() — hasMany(QuizQuestion::class). Behavioral rules tied to type:
  • For DRAG_AND_DROP_ID (4), each QuizQuestionOption must carry quiz_question_option_items[] — the items the student drags onto that option. For all other types, items are ignored.
  • For TRUE_OR_FALSE, options are typically two: one is_correct = true, one is_correct = false.
  • For SINGLE_ANSWER exactly one option has is_correct = true; for MULTIPLE_ANSWER one or more do.

4.4 QuizQuestionOption

File: app/Models/QuizQuestionOption.php
Traits: HasFactory, Archivable, SoftDeletes. Relations:
  • quiz_question() — belongsTo(QuizQuestion::class)
  • quiz_question_option_items() — hasMany(QuizQuestionOptionItem::class)
  • quiz_student_attempts() — belongsToMany(QuizStudentAttempt::class, 'quiz_student_answers', ...)

4.5 QuizQuestionOptionItem

File: app/Models/QuizQuestionOptionItem.php
Traits: HasFactory, Archivable, SoftDeletes. Used only by DRAG_AND_DROP questions. Each item represents a draggable element that belongs to a specific option (the “bucket”). Relation: quiz_question_option() — belongsTo(QuizQuestionOption::class).

4.6 QuizStudentAttempt

File: app/Models/QuizStudentAttempt.php — extends Illuminate\Database\Eloquent\Relations\Pivot.
Columns of interest: quiz_id, student_id, attempt, correct_answers, uncorrect_answers, score_percentage, finished_at, passed_at. 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
The join table quiz_student_answers is what stores the student’s actual answers per attempt. Each row links: attempt × question × option (and, for drag & drop, × option item). This model is consumed by the progress-tracking controller, documented in Quizzes & Exams progress tracking.

4.7 Entity Relationship Diagram


5. QuizzesController Endpoints

Controller: app/Http/Controllers/QuizzesController.php. All routes are under the QuizzesController group in routes/api.php.

5.1 List Quizzes / Exams

GET /api/quizzes Authorize: viewAny on Quiz. Query Parameters: Example Requests:
Success Response (200):
Eager-loaded on every item: image, categories, reviews, chapter, training:id,label,slug, withAvg('reviews','rating'), withCount('quiz_questions'), and the computed training_label / training_slug fields. training_label / training_slug name the training a quiz belongs to: the final quiz’s own training, or, for a chapter quiz (stored with training_id = null), the chapter’s training (since 19/10/2026; it used to be null, and the admin app fetched GET /trainings?per_page=1000 to fill the gap). Both are null for a standalone exam. The chapter’s training is only read for these two fields: chapter carries no training object. A chapter quiz item:
Error Responses:

5.2 Show Quiz

GET /api/quiz/{id} (numeric id only). Authorize: view on the quiz.
⚠️ Answer data is fully exposed here. With WithQuizQuestions=true (the default), the response includes each option’s is_correct flag, every explanation / tip, and the intact drag-&-drop grouping (quiz_question_options[].quiz_question_option_items). QuizPolicy::view lets through super-admins, admins, holders of QUIZZES_VIEW (83) and students who own the exam — and every company seeds 83 to the student role by default (PermissionRole::assign_permissions_to_student), so today any logged-in student can read the answer key of any exam in their company by id, bought or not. Use this endpoint for authoring / admin screens only. The student exam preview page must call 5.11 Preview an Exam, which never returns questions. The instructor listing GET /api/instructors/{id}/quizzes already strips answers (see Instructor API doc §7).
Query Parameters:
Student callers also get an attempts block (19/10/2026): { "used", "allowed", "remaining", "reset_count", "best_score_percentage", "passed", "last_attempt_at" } for the calling student on this quiz — the ceiling start_student_quiz enforces (allowed = max_attempts * (reset_count + 1), null = unlimited). Staff callers do not get it. questions_count is always present (counted in SQL when WithQuizQuestions=false).
Example Requests:
Success Response (200) — with WithQuizQuestions=true (default):

5.3 Create Quiz (metadata only)

POST /api/quiz Authorize: create on Quiz. Request Fields: Example A — Final quiz linked to a training (no price required, slug generated):
If training 3 already had a final quiz, that quiz is converted into an unpublished exam (history converted_to_exam). Response: { "quiz_id": 21, "slug": "web-basics-final-quiz", "message": "Quiz created successfully." }. Example B — Chapter quiz:
Response: { "quiz_id": 22, "slug": "chapter-3-quiz", "message": "Quiz created successfully." }. The quiz is stored with chapter_id = 7 and training_id = null; sending "training_id": 3 (chapter 7’s own training) gives the same result. Example B2 — A second quiz on the same chapter (since 19/10/2026):
Response: { "quiz_id": 23, "slug": "chapter-3-quiz-2", "message": "Quiz created successfully." }. Chapter 7 now holds quizzes 22 and 23, in that order; quiz 22 stays on the chapter and stays published (before 19/10/2026 it would have been converted into an unpublished exam). The new quiz is a draft: students only see it once it is published. Example C — Standalone Exam (price required, slug sent):
The sent slug is stored in lowercase: python-data-structures-2026. Example D — two categories (refused since 01/11/2026):
422, and nothing is written. Send one id ("categories": [3]); the same answer comes from §5.4–5.6. Behavior (every check runs before anything is written, see §3.1):
  1. Validates the fields (422 with errors), categories included: exactly one id (§3.3). The slug, when sent, is lowercased first.
  2. Returns 422 Please provide an instructor. if instructor_id is missing.
  3. Resolves the attachment: chapter quiz (training_id stored as null), final quiz or exam, and checks that the chapter / training belongs to company_id (422 errors.chapter_id / errors.training_id).
  4. Quiz (chapter or final): instructor_id must be the training’s instructor (422 Instructor mismatch), revenue_percentage is forced to 0. Exam: the instructor must have a default_revenue_percentage (422 asking the admin to set it), used when the request sends no positive revenue_percentage.
  5. In one transaction: for a final quiz, converts the training’s previous final quiz into an unpublished exam (turnQuizToExam, §5.9); creates the quiz (slug generated when omitted, §3.2); syncs its category; inserts the created history row. A chapter’s existing quizzes are never touched.
Success Response (200):
slug is the stored slug: the one you sent (lowercased) or the generated one. Error Responses: All the bodies are spelled out in §3.1, §3.2 and §3.3.

5.4 Update Quiz (metadata only)

POST /api/quiz/{id} (numeric id). Same validation rules as store, minus company_id, and the same checks in the same order (§3.1), using the quiz’s own company for the chapter / training ownership check. Every field is overwritten from the request, so send the full form.
  • Moving a quiz into a chapter (chapter_id) keeps the chapter’s other quizzes untouched (since 19/10/2026; before, the chapter’s existing quiz was converted into an exam). The quiz is stored with training_id = null.
  • Moving a quiz onto a training as its final quiz (training_id without chapter_id) still converts the training’s current final quiz into an unpublished exam (turnQuizToExam, §5.9).
  • slug is optional (re-enabled on 19/10/2026): omitted or empty keeps the stored slug; a sent slug is lowercased, must match the allowed characters and be unique (the quiz’s own slug does not count as taken). See §3.2.
  • categories takes exactly one id (since 01/11/2026, §3.3): the id you send replaces the quiz’s current category; two or more → 422, and the quiz keeps its category.
Request Fields: same as §5.3, except company_id is not accepted. Example — update exam 5 and rename its slug:
Success Response (200):
Without slug in the body, the response carries the unchanged slug ("advanced-python-exam").
Renaming an exam’s slug does not rewrite the owned_exams arrays of the users who already bought it (ownership is slug-based). Change the slug of a sold exam with care.
Error Responses: same as §5.3 (the ownership checks use the quiz’s company), plus:

5.5 Create Quiz With Questions & Options

POST /api/quiz_with_questions_and_options Authorize: create on Quiz. Combines quiz metadata + all questions and options in one request. Same rules as §5.3: optional slug (generated from the label when omitted, §3.2), any number of quizzes per chapter, the same checks before any write (§3.1). The quiz, its category, questions, options, drag-and-drop items and the history row are written in one transaction: if a question fails to save, no quiz is left behind.
Before 19/10/2026 this endpoint ignored slug and saved the quiz with slug = NULL. Those rows are filled by the backfill migration (§3.2).
Request Fields (quiz-level): same as §5.3, including the optional slug and categories with exactly one id (§3.3). Additional question-level fields: Full Example — all 4 question types (Standalone Exam):
Response: { "quiz_id": 8, "slug": "javascript-fundamentals-full-exam", "message": "Quiz created successfully." }. Minimal Example — Final quiz of a training, 2 questions only:
Behavior — same checks and final-quiz rule as §5.3 (here training 3’s previous final quiz, if any, becomes an unpublished exam), then, inside the same transaction, creates QuizQuestion → QuizQuestionOption → QuizQuestionOptionItem (items only for type 4). To add a quiz to a chapter instead, send chapter_id — the chapter’s existing quizzes stay. Success Response (200):
Error Responses: same as §5.3, plus 422 when quiz_questions is missing or empty, or a question has no options.

5.6 Update Quiz With Questions & Options

POST /api/quiz/update_quiz_with_questions_and_options/{id} Authorize: update on the quiz. Same validation as §5.5, without company_id, and the same checks before any write (§3.1, using the quiz’s own company). Everything is written in one transaction.
  • quiz_questions is optional (since 19/10/2026). Sent → a full destructive replace: all existing questions and options are deleted, then recreated from the payload (an empty array [] deletes them all). Left out → the questions are kept and only the metadata is updated.
  • slug is optional: omitted or empty keeps the stored slug; sent → lowercased, checked and unique (§3.2).
  • instructor_id is optional here: when left out, the quiz’s current instructor is kept (and checked against the training).
  • categories takes exactly one id (since 01/11/2026, §3.3): it replaces the quiz’s current category; two or more → 422, nothing is written.
  • Moving into a chapter keeps the chapter’s other quizzes. Unlike §5.3–5.5, this endpoint does not apply the final-quiz rule: making the quiz a training’s final quiz does not convert the training’s current one. If a training ends up with two final quizzes, its quiz relation returns the newest (highest id).
  • is_published is validated (required) but not saved by this endpoint — change the publish state with §5.4.
  • The other metadata fields are overwritten from the request: an omitted image_id, description, max_attempts, break_interval, break_duration, price or discounted_price is saved as null.
Request Fields: identical to §5.5 body, excluding company_id; quiz_questions and instructor_id are optional. Example — replace all questions on quiz 8:
Success Response (200):
No slug was sent, so the stored one is kept and returned. Example — metadata only, questions kept (move chapter quiz 23 to chapter 9 of the same training):
Response: { "quiz_id": 23, "slug": "chapter-3-quiz-2", "message": "Quiz updated successfully." }. instructor_id falls back to the quiz’s current instructor, the questions stay as they were, and chapter 9’s existing quizzes are not touched. Error Responses: same as §5.3 (the ownership checks use the quiz’s company; Please provide an instructor. only when neither the request nor the quiz has one), plus 404 for an unknown quiz id and 403 when the caller may not update the quiz.
Heads up — when quiz_questions is sent, this is a full replace, not a diff: every question you leave out is deleted. To update metadata only, leave quiz_questions out (or use §5.4).

5.7 Archive / Unarchive / Force Delete

Archive — POST /api/quiz/{id}/archive Authorize: archive. Calls Archivable@archive() on the quiz. History: archive.
Success Response (200):

Unarchive — POST /api/quiz/{id}/unarchive Authorize: restore on a quiz fetched with onlyArchived(). History: unarchived.
Success Response (200):

Force Delete — DELETE /api/quiz/{id}/delete Authorize: delete on a quiz fetched with onlyArchived(). Hard-deletes the row. History: deleted.
Per the project’s archive flow, force-delete is only allowed on already-archived rows. Archive first, then delete.
Success Response (200):
Error Response (404): returned when the quiz is not in the archived state.

5.8 Students By Exam

GET /api/students_by_exam/{id_exam} Resolves company_id from auth()->user()->company_id. Exams only — quizzes with training_id = null and chapter_id = null. Passing a quiz (non-exam) id will 404.
Only need the owners’ emails (array + comma-separated string), by exam id or slug? Use GET /api/users/owners-emails?exam_id=5 / ?exam_slug=advanced-python-exam — see Owner Emails (JSON).
Query Parameters: Example A — Students who purchased this exam:
Example B — Students who do NOT have this exam yet:
Example C — Students with their questions loaded:
Success Response (200) — default (purchased students):

5.9 Helpers: turnQuizToExam, auto_change_instructors_for_quizzes

  • turnQuizToExam(Quiz $quiz) — clears training_id and chapter_id, sets is_published = false, requires an instructor_id (throws otherwise), requires the instructor’s default_revenue_percentage > 0 (throws otherwise), defaults revenue_percentage to the instructor’s default if it’s empty, saves. History: converted_to_exam. Used internally by store / update / create_quiz_with_questions_and_options to retire a training’s previous final quiz when a new final quiz is created or moved onto that training (one final quiz per training). Since 19/10/2026 it is never called for chapter quizzes — a chapter keeps all its quizzes. It runs inside the request’s transaction: if it throws, nothing is written and the request answers 500 with the exception message.
  • auto_change_instructors_for_quizzes(array $quiz_ids) — for each quiz id, copies the training’s instructor onto the quiz: a final quiz reaches its training through training_id, a chapter quiz through its chapter (since 19/10/2026). Returns 400 if a quiz is attached to no training. Used by TrainingsController@update (POST /training/{id}), which passes every quiz of the training — its final quiz and all its chapter quizzes (Training::allQuizzesQuery()). Before 19/10/2026 only quizzes carrying the training’s training_id followed, so chapter quizzes kept the old instructor. History: changed_instructor.

5.10 Preview Exams (marketplace-safe)

GET /api/preview-exams The marketplace version of 5.1 List Quizzes / Exams, built for the student exam catalog (/student-exam-catalog). Same authorization (viewAny on Quiz, held by students by default), same paginator envelope, but every row is built from a whitelist: Rules enforced server-side, whatever the query string says: standalone exams only (training_id and chapter_id both null), published only (is_published = 1), never archived / soft-deleted. is_exam, is_quiz and is_published parameters are accepted for compatibility and ignored. is_owned is true when the caller has a paid, non-revoked order for the exam. training, training_label and training_slug are always null (kept for parity with 5.1). views_count is the number of times the exam’s marketplace page was opened (incremented by 5.11 only; listing exams never counts as a view). Query Parameters: Example Requests:
Success Response (200):
Error Responses:
The instructor public-profile fields are null while the instructor keeps hide_public_profile = true. Response shapes are produced by app/Services/MarketplacePreviewService.php — extend the whitelist there, never eager-load quiz_questions into a preview. catalog_counts (page 1 only). The size of each student Marketplace catalog of the company: trainings (public trainings), exams (published standalone exams) and instructors (users with role 2). They are counted without the request’s own filters (search, categories, price, …), leave archived and deleted rows out, and are cached per company for 5 minutes; the cache is cleared as soon as a training, exam or instructor is created, published / unpublished, archived, deleted or restored (app/Services/MarketplaceCountsService.php, MarketplaceCountsObserver). The same object comes on page 1 of GET /preview-trainings, GET /preview-exams and GET /instructors, so the app fills the three Marketplace tab counts from whichever catalog loads first, with no extra request. Pages 2 and later do not carry it.

5.11 Preview an Exam (marketplace-safe)

GET /api/preview-exam/{idOrSlug} The marketplace version of 5.2 Show Quiz, built for the exam preview page (/student-exam-catalog/quiz-preview/:quizId). Returns the same card as 5.10 plus reviews[] (id, rating, comment, dates, minimal user block). The questions are never included — only quiz_questions_count (the key the Flutter Quiz model already maps to questionsCount). Each successful call counts one view: views_count is incremented before the response is built, so the returned value already includes this visit (like GET /api/preview-training/{idOrSlug} does for trainings). The counter is updated without touching the exam’s updated_at. Authorization: viewAny on Quiz. The company is taken from the caller’s token (super-admins may pass company_id). Standalone, published exams only: a quiz attached to a training or chapter, an unpublished exam, an archived exam or another company’s exam all answer 404. Example Requests:
Success Response (200):
Error Responses:

6. Export / Import Endpoints

Moved to its own file. The export / import endpoints — export quiz JSON, export exam JSON, export-all-quizzes ZIP, export-all-exams ZIP, and the destructive question/option import (with images) — are now documented separately in Quizzes & Exams export / import.

7. Business Rules & Edge Cases

  • Instructor is always required. store, update and create_quiz_with_questions_and_options return 422 Please provide an instructor. if it is missing — even though the validator marks it nullable. update_quiz_with_questions_and_options falls back to the quiz’s current instructor. turnQuizToExam throws (→ 500) when the quiz it converts has none.
  • Instructor must match parent training. When training_id or chapter_id is set, the quiz’s instructor_id must equal the training’s instructor_id (for a chapter quiz, the chapter’s training). Otherwise 422 Instructor mismatch.
  • Every check runs before anything is written (§3.1), and the write is one transaction. Since 19/10/2026 a 422 never leaves a half-done change behind (before, the quiz could already be created and the old one converted).
  • The chapter / training must belong to the company (create: company_id; update: the quiz’s company) → 422 errors.chapter_id / errors.training_id.
  • Chapter quizzes are stored with training_id = null. A training_id sent with chapter_id must be the chapter’s own training (422 otherwise) and is dropped. A training’s quiz relation (its final quiz) never returns a chapter quiz, even an older row that carries both ids.
  • revenue_percentage is forced to 0 for quizzes linked to training/chapter (the instructor is already paid via the training’s split).
  • For standalone exams, if revenue_percentage is 0 or empty the instructor’s default_revenue_percentage is used. If the instructor doesn’t have one set, the request fails with 422 Please contact your platform admin to set the default revenue percentage value.
  • One final quiz per training; any number of quizzes per chapter. Creating or moving a final quiz onto a training that has one auto-converts the existing final quiz to an exam and unpublishes it — the new quiz wins (not on update_quiz_with_questions_and_options). Chapters have no limit since 19/10/2026: their quizzes come in creation (id) order, after the chapter’s lessons.
  • Update-with-questions is destructive when quiz_questions is sent — it deletes and re-creates all questions & options. Left out, the questions are kept.
  • One category per quiz / exam (since 01/11/2026): the four write endpoints take categories as a one-item array, two or more ids → 422 (§3.3). The pivot category_quiz has a unique index on quiz_id.
  • Slug is optional everywhere and server-generated when missing (§3.2); it is unique across quizzes and exams (archived and soft-deleted ones included) and can be changed on both update endpoints.
  • Reading a chapter’s quizzes: every chapter payload (GET /chapters, GET /chapter/{id}, GET /training/{id}, the GET /auth/user login payload, the chapter exports) carries quizzes (all of them, creation order) and the deprecated quiz (= the first one, or null) for older app builds. On GET /chapters, GET /chapter/{id} and GET /training/{id} students only get the published chapter quizzes while staff also get the drafts; the login payload and GET /preview-training/{idOrSlug} only ever list published ones; the exports carry every quiz. See Chapters and Trainings.
  • students_by_exam is for exams only — it filters with whereNull('training_id')->whereNull('chapter_id').
  • The student marketplace must use the preview-* endpoints (5.10 / 5.11 for exams, and GET /api/preview-trainings / GET /api/preview-training/{idOrSlug} for trainings). GET /api/quizzes exposes the instructor’s email and revenue values, and GET /api/quiz/{id} exposes the full answer key to any student holding QUIZZES_VIEW (seeded by default). The preview endpoints return counts and outlines only.
  • Preview endpoints are whitelist-based. They force isExam() + is_published = 1 (exams) / is_public = 1 (trainings), never load quiz_questions or lesson content, and select only safe instructor columns. Extend them through app/Services/MarketplacePreviewService.php.

8. History Tracking Actions

Calls to insert_in_history_table($action, $resource_id, $table) made by these controllers:
  • app/Models/Quiz.php — parent model (label, slug, price, scopes isQuiz, isExam, Archivable, SoftDeletes; SLUG_PATTERN, generateUniqueSlug() and the saving hook that fills a missing slug).
  • app/Models/Chapter.php — quizzes() (every quiz of the chapter, id order) and the deprecated quiz(); relationsToArray() adds quiz = the first of quizzes to the JSON.
  • app/Models/Training.php — quiz() (the final quiz only: chapter_id null, newest first) and allQuizzesQuery() (final + chapter quizzes).
  • database/migrations/2026_10_19_000000_backfill_missing_quiz_slugs.php — fills the missing slugs of existing rows (data only).
  • tests/Feature/ChapterMultipleQuizzesTest.php, tests/Feature/QuizSlugTest.php — guard the multiple-quizzes-per-chapter and slug rules.
  • app/Models/Category.php — MAX_PER_ITEM (1), REQUIRED_SINGLE_RULE and singleCategoryMessages() (the messages.one_category_only text in resources/lang/{en,fr,ar}/messages.php).
  • database/migrations/2026_11_01_000000_keep_one_category_per_item.php — keeps the oldest category of each quiz / training, backs up the others, adds the unique indexes; tests/Feature/SingleCategoryTest.php guards the one-category rule (§3.3).
  • app/Models/QuizQuestion.php
  • app/Models/QuizQuestionType.php
  • app/Models/QuizQuestionOption.php
  • app/Models/QuizQuestionOptionItem.php
  • app/Models/QuizStudentAttempt.php
  • app/Http/Controllers/QuizzesController.php
  • app/Http/Controllers/Export_Import_Quizzes_Exams_Controller.php — see Quizzes & Exams export / import
  • app/Http/Controllers/Quizzes_Exams_Progress_Tracking_Controller.php — see Quizzes & Exams progress tracking
  • routes/api.php — QuizzesController, Export_Import_Quizzes_Exams_Controller groups
  • app/Traits/HandlesStudentListing.php, app/Traits/QuizzesAndTrainingsListing.php — pagination/search helpers
  • Archive, delete & restore flow — archive / restore / hard-delete semantics
  • Instructor dashboard — default_revenue_percentage setter