> ## Documentation Index
> Fetch the complete documentation index at: https://theteam-13fc7f49.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Quizzes & Exams Endpoints (v4)

###### ***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](https://theteam-13fc7f49.mintlify.site/api-reference/quizzes-and-exams-export-and-import-endpoints.md). The student attempt lifecycle (start / finish / save score / show answers) is documented in [Quizzes & Exams progress tracking](https://theteam-13fc7f49.mintlify.site/api-reference/quizzes-and-exams-progress-tracking-api.md).

**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](https://theteam-13fc7f49.mintlify.site/api-reference/chapters.md) and [Retrieve a training](https://theteam-13fc7f49.mintlify.site/api-reference/trainings.md#retrieve-a-training).
* `quiz_questions` is optional on [5.6](#56-update-quiz-with-questions--options): 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](https://theteam-13fc7f49.mintlify.site/api-reference/quizzes-and-exams-export-and-import-endpoints.md)
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:

```text theme={null}
isQuiz()  ⇔  training_id IS NOT NULL  OR  chapter_id IS NOT NULL   → part of a course (chapter quiz OR training quiz)
isExam()  ⇔  training_id IS NULL     AND chapter_id IS NULL        → standalone, sellable
```

**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:

```text theme={null}
{{base_url}}/api
```

Suggested Postman variables:

```text theme={null}
{{base_url}} = http://127.0.0.1:8000
{{token}}    = your_sanctum_token
```

Headers:

```text theme={null}
Accept: application/json
Content-Type: application/json
Authorization: Bearer {{token}}
```

> `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**

| **Property** | **Quiz** | **Exam** |
| :- | :- | :- |
| `training_id` | set for a **training (final) quiz**; always stored as `null` for a **chapter quiz** (the chapter already implies the training) | `null` |
| `chapter_id` | set for a **chapter quiz**; `null` for a training (final) quiz | `null` |
| What identifies it | `training_id` **or** `chapter_id` is set (`isQuiz()`) | both are `null` (`isExam()`) |
| `price` | optional | **required** (`required_without_all:training_id,chapter_id`) |
| `revenue_percentage` | forced to `0` (instructor is paid via training) | required; defaults to instructor's `default_revenue_percentage` |
| `instructor_id` | required, must match the parent training's instructor | required |
| `slug` | optional, generated from the label when omitted (§3.2); unique across quizzes **and** exams | same; this is the value stored in `owned_exams` |
| `categories` | exactly **one** category id, sent as a one-item array (§3.3) | same |
| How many | **1 final quiz per training** (a new one converts the old one → `turnQuizToExam()`); **any number of quizzes per chapter** | n/a |

> 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`.

| You send | Stored as | Checks (`422`) and side effects |
| :- | :- | :- |
| `chapter_id` (with or without `training_id`) | **chapter quiz**: `chapter_id` set, `training_id = null` | The chapter's training must belong to the company → else `errors.chapter_id`. A `training_id` sent along must be the chapter's own training → else `errors.training_id`. The instructor must be the training's instructor. |
| `training_id` only | **final quiz**: `training_id` set, `chapter_id = null` | The training must belong to the company → else `errors.training_id`. The instructor must be the training's instructor. The training's previous final quiz becomes an exam (not on §5.6). |
| neither | **exam** | `price` is required. The instructor must have a `default_revenue_percentage`. |

"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:

```json theme={null}
{
  "message": "The selected chapter does not belong to a training of this company.",
  "errors": { "chapter_id": ["The selected chapter does not belong to a training of this company."] }
}
```

```json theme={null}
{
  "message": "For a chapter quiz, training_id must be left out or be the chapter's own training.",
  "errors": { "training_id": ["For a chapter quiz, training_id must be left out or be the chapter's own training."] }
}
```

```json theme={null}
{
  "message": "The selected training does not belong to this company.",
  "errors": { "training_id": ["The selected training does not belong to this company."] }
}
```

### **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.

```json theme={null}
{
  "message": "The slug has already been taken.",
  "errors": { "slug": ["The slug has already been taken."] }
}
```

```json theme={null}
{
  "message": "The slug field format is invalid.",
  "errors": { "slug": ["The slug field format is invalid."] }
}
```

### **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](https://theteam-13fc7f49.mintlify.site/api-reference/trainings.md#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](https://theteam-13fc7f49.mintlify.site/api-reference/auth.md)) 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](#51-list-quizzes--exams) and [5.10](#510-preview-exams-marketplace-safe) 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.

```json theme={null}
{
  "message": "A training, an exam or a quiz has one category: send exactly one id in categories.",
  "errors": {
    "categories": ["A training, an exam or a quiz has one category: send exactly one id in categories."]
  }
}
```

***

## **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:

```text theme={null}
id, company_id, training_id, chapter_id, instructor_id, image_id,
label, slug, description, max_attempts, passing_percentage,
duration, break_interval, break_duration, is_published,
price, discounted_price, revenue_percentage, created_at, updated_at, deleted_at
```

Scopes used here: `isQuiz()` (has training\_id or chapter\_id), `isExam()` (both null), `onlyArchived()`.

### **4.2 QuizQuestion**

File: `app/Models/QuizQuestion.php`

```text theme={null}
protected $table = 'quiz_questions';
protected $fillable = [
    'quiz_id',
    'quiz_question_type_id',
    'image_id',
    'label',
    'tip',
    'explanation',
];
```

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`

```text theme={null}
const TRUE_OR_FALSE_ID   = 1;  const TRUE_OR_FALSE   = 'True or False';
const SINGLE_ANSWER_ID   = 2;  const SINGLE_ANSWER   = 'Single Answer';
const MULTIPLE_ANSWER_ID = 3;  const MULTIPLE_ANSWER = 'Multiple Answer';
const DRAG_AND_DROP_ID   = 4;  const DRAG_AND_DROP   = 'Drag & Drop';
```

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`

```text theme={null}
protected $table = 'quiz_question_options';
protected $fillable = [
    'quiz_question_id',
    'label',
    'is_correct',
];
```

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`

```text theme={null}
protected $fillable = [
    'quiz_question_option_id',
    'label',
];
```

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`.

```text theme={null}
protected $table = 'quiz_student_attempts';
protected $casts = [
    'attempt'           => 'integer',
    'correct_answers'   => 'integer',
    'uncorrect_answers' => 'integer',
    'score_percentage'  => 'float',
];
```

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](https://theteam-13fc7f49.mintlify.site/api-reference/quizzes-and-exams-progress-tracking-api.md).

### **4.7 Entity Relationship Diagram**

```text theme={null}
companies                 trainings                chapters
    │                         │                       │
    └──> quizzes <─ training_id ──> chapter_id ───────┘
              │
              ├──> quiz_questions ──> quiz_question_types
              │         │
              │         ├──> image (files)
              │         │
              │         └──> quiz_question_options
              │                    │
              │                    └──> quiz_question_option_items   (drag & drop only)
              │
              └──> quiz_student_attempts ─────────────> users (student_id)
                          │
                          └──> quiz_student_answers (join: attempt × question × option × item)
```

***

## **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:**

| **Param** | **Type** | **Required** | **Notes** |
| :- | :- | :- | :- |
| `company_id` | integer | **yes** | must exist in `companies` |
| `chapter_id` | integer | no | filter by chapter |
| `is_quiz` | boolean | no | `true` → only quizzes linked to a training/chapter |
| `is_exam` | boolean | no | `true` → only standalone exams |
| `min_price` | integer | no | price > value |
| `max_price` | integer | no | price \< value |
| `categories` | string (JSON array) | no | e.g. `[1,2,3]` — pass as a URL-encoded JSON string. Several ids are still accepted (a row matches when its one category is in the list, §3.3) |
| `reviews` | string (JSON array) | no | e.g. `[3,5]` → avg rating between 3 and 5 |
| `is_published` | boolean | no | filter by publish status |
| `only_archived` | boolean | no | `true` → return archived rows only |
| `instructor_id` | integer | no | filter by instructor |
| `search` | string | no | label search |
| `page` | integer | no | pagination page (default 1) |
| `per_page` | integer | no | items per page (max 100) |

**Example Requests:**

```text theme={null}
GET {{base_url}}/api/quizzes?company_id=1
GET {{base_url}}/api/quizzes?company_id=1&is_exam=true&is_published=true
GET {{base_url}}/api/quizzes?company_id=1&is_quiz=true
GET {{base_url}}/api/quizzes?company_id=1&categories=[2,5]&min_price=10&max_price=100&page=1&per_page=10
GET {{base_url}}/api/quizzes?company_id=1&search=python&reviews=[4,5]
GET {{base_url}}/api/quizzes?company_id=1&only_archived=true
```

**Success Response (200):**

```text theme={null}
{
  "current_page": 1,
  "data": [
    {
      "id": 5,
      "company_id": 1,
      "training_id": null,
      "chapter_id": null,
      "instructor_id": 12,
      "image_id": null,
      "label": "Advanced Python Exam",
      "slug": "advanced-python-exam",
      "description": "Comprehensive Python assessment.",
      "max_attempts": 3,
      "passing_percentage": "75.00",
      "duration": 60,
      "break_interval": 10,
      "break_duration": 5,
      "is_published": true,
      "price": "29.99",
      "discounted_price": "19.99",
      "revenue_percentage": "30.00",
      "created_at": "2026-01-15T10:00:00.000000Z",
      "updated_at": "2026-01-15T10:00:00.000000Z",
      "deleted_at": null,
      "image": null,
      "categories": [
        { "id": 5, "label": "Python" }
      ],
      "reviews": [],
      "chapter": null,
      "training": null,
      "reviews_avg_rating": null,
      "quiz_questions_count": 10,
      "training_label": null,
      "training_slug": null
    }
  ],
  "first_page_url": "http://127.0.0.1:8000/api/quizzes?page=1",
  "from": 1,
  "last_page": 3,
  "last_page_url": "http://127.0.0.1:8000/api/quizzes?page=3",
  "next_page_url": "http://127.0.0.1:8000/api/quizzes?page=2",
  "path": "http://127.0.0.1:8000/api/quizzes",
  "per_page": 10,
  "prev_page_url": null,
  "to": 10,
  "total": 25
}
```

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:

```text theme={null}
{
  "id": 41,
  "training_id": null,
  "chapter_id": 7,
  "label": "Chapter 1 quiz",
  "chapter": { "id": 7, "training_id": 12, "label": "Getting started", ... },
  "training": null,
  "training_label": "PMP Bootcamp",
  "training_slug": "pmp-bootcamp",
  ...
}
```

**Error Responses:**

```text theme={null}
403 { "message": "This action is unauthorized." }
422 { "message": "The per page field must not be greater than 100.", "errors": { "per_page": ["The per page field must not be greater than 100."] } }    ← any invalid query parameter (a 500 before 19/10/2026)
500 { "message": "<exception message>" }                                                                                                                   ← logged
```

***

### **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](#511-preview-an-exam-marketplace-safe), which never returns questions. The instructor listing `GET /api/instructors/{id}/quizzes` already strips answers (see Instructor API doc §7).

**Query Parameters:**

| **Param** | **Type** | **Default** | **Notes** |
| :- | :- | :- | :- |
| `WithQuizQuestions` | boolean | `true` | when `true`, loads all questions with their type, options, and option items |

> **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:**

```text theme={null}
GET {{base_url}}/api/quiz/5
GET {{base_url}}/api/quiz/5?WithQuizQuestions=true
GET {{base_url}}/api/quiz/5?WithQuizQuestions=false
```

**Success Response (200) — with** `WithQuizQuestions=true `**(default):**

```text theme={null}
{
  "id": 5,
  "company_id": 1,
  "training_id": null,
  "chapter_id": null,
  "instructor_id": 12,
  "image_id": null,
  "label": "Advanced Python Exam",
  "slug": "advanced-python-exam",
  "description": "Comprehensive Python assessment.",
  "max_attempts": 3,
  "passing_percentage": "75.00",
  "duration": 60,
  "break_interval": 10,
  "break_duration": 5,
  "is_published": true,
  "price": "29.99",
  "discounted_price": "19.99",
  "revenue_percentage": "30.00",
  "created_at": "2026-01-15T10:00:00.000000Z",
  "updated_at": "2026-01-15T10:00:00.000000Z",
  "deleted_at": null,
  "image": null,
  "categories": [
    { "id": 2, "label": "Programming" }
  ],
  "reviews": [],
  "chapter": null,
  "instructor": {
    "id": 12,
    "name": "John Smith",
    "email": "john@example.com"
  },
  "questions_count": 4,
  "quiz_questions": [
    {
      "id": 21,
      "quiz_id": 5,
      "quiz_question_type_id": 1,
      "image_id": null,
      "label": "Python is a statically typed language.",
      "tip": null,
      "explanation": "Python is dynamically typed.",
      "image": null,
      "quiz_question_type": { "id": 1, "label": "True or False" },
      "quiz_question_options": [
        { "id": 60, "quiz_question_id": 21, "label": "True",  "is_correct": false, "quiz_question_option_items": [] },
        { "id": 61, "quiz_question_id": 21, "label": "False", "is_correct": true,  "quiz_question_option_items": [] }
      ]
    },
    {
      "id": 22,
      "quiz_id": 5,
      "quiz_question_type_id": 2,
      "image_id": null,
      "label": "Which keyword is used to define a function in Python?",
      "tip": "It is a 3-letter keyword.",
      "explanation": "`def` is used to define functions in Python.",
      "image": null,
      "quiz_question_type": { "id": 2, "label": "Single Answer" },
      "quiz_question_options": [
        { "id": 62, "quiz_question_id": 22, "label": "function", "is_correct": false, "quiz_question_option_items": [] },
        { "id": 63, "quiz_question_id": 22, "label": "def",      "is_correct": true,  "quiz_question_option_items": [] },
        { "id": 64, "quiz_question_id": 22, "label": "func",     "is_correct": false, "quiz_question_option_items": [] }
      ]
    },
    {
      "id": 23,
      "quiz_id": 5,
      "quiz_question_type_id": 3,
      "image_id": null,
      "label": "Which of the following are valid Python data types? (Select all that apply)",
      "tip": null,
      "explanation": "Python built-in types include int, str, list, dict, tuple, set.",
      "image": null,
      "quiz_question_type": { "id": 3, "label": "Multiple Answer" },
      "quiz_question_options": [
        { "id": 65, "quiz_question_id": 23, "label": "int",   "is_correct": true,  "quiz_question_option_items": [] },
        { "id": 66, "quiz_question_id": 23, "label": "str",   "is_correct": true,  "quiz_question_option_items": [] },
        { "id": 67, "quiz_question_id": 23, "label": "array", "is_correct": false, "quiz_question_option_items": [] },
        { "id": 68, "quiz_question_id": 23, "label": "list",  "is_correct": true,  "quiz_question_option_items": [] }
      ]
    },
    {
      "id": 24,
      "quiz_id": 5,
      "quiz_question_type_id": 4,
      "image_id": null,
      "label": "Match each Python built-in function to its purpose.",
      "tip": null,
      "explanation": null,
      "image": null,
      "quiz_question_type": { "id": 4, "label": "Drag & Drop" },
      "quiz_question_options": [
        {
          "id": 69,
          "quiz_question_id": 24,
          "label": "Returns the number of items in an object",
          "is_correct": true,
          "quiz_question_option_items": [
            { "id": 201, "quiz_question_option_id": 69, "label": "len()" }
          ]
        },
        {
          "id": 70,
          "quiz_question_id": 24,
          "label": "Converts a value to an integer",
          "is_correct": true,
          "quiz_question_option_items": [
            { "id": 202, "quiz_question_option_id": 70, "label": "int()" }
          ]
        }
      ]
    }
  ]
}
```

***

### **5.3 Create Quiz (metadata only)**

`POST /api/quiz`

Authorize: `create` on `Quiz`.

**Request Fields:**

| **Field** | **Type** | **Required** | **Notes** |
| :- | :- | :- | :- |
| `company_id` | integer | **yes** | must exist in `companies` |
| `training_id` | integer | no | **final quiz**: the training (must belong to `company_id`). **Chapter quiz**: leave it out, or send the chapter's own training — it is stored as `null` either way (any other training → `422 errors.training_id`). **Exam**: `null`. See §3.1. |
| `chapter_id` | integer | no | **chapter quiz**: the chapter (its training must belong to `company_id`, else `422 errors.chapter_id`). The chapter's other quizzes are left untouched — a chapter holds any number of quizzes. `null` otherwise. |
| `image_id` | integer | no | cover image file id |
| `label` | string | **yes** | 1–255 chars |
| `slug` | string | no | **optional since 19/10/2026** (was required). Omitted → generated from `label` (`pmp-mock-exam`, then `pmp-mock-exam-2`…). Sent → lowercased; `^[a-z0-9()\-,._=\[\]:]+$`, max 255, unique across quizzes and exams. See §3.2. |
| `description` | string | no | |
| `max_attempts` | integer | no | `0` or `null` = unlimited. This is the per-student attempt ceiling, but admins/instructors can grant a single student extra cycles via attempt-reset — the effective ceiling becomes `max_attempts * (reset_count + 1)`. See [Quizzes & Exams progress tracking](https://theteam-13fc7f49.mintlify.site/api-reference/quizzes-and-exams-progress-tracking-api.md) §5.5. |
| `passing_percentage` | numeric | **yes** | 0–100 |
| `duration` | integer | **yes** | minutes |
| `break_interval` | integer | no | minutes between breaks |
| `break_duration` | integer | no | minutes per break |
| `is_published` | boolean | **yes** | `true` or `false` |
| `price` | numeric | **required when no** `training_id `**and no** `chapter_id` | standalone exam price |
| `discounted_price` | numeric | no | |
| `categories` | array of integers | **yes** | **exactly one id** since 01/11/2026 (`min:1`, `max:1`); it must exist in `categories`. Two or more → `422` (§3.3). Still an array, for older app builds |
| `instructor_id` | integer | **yes** (enforced at runtime) | must exist in `users` |
| `revenue_percentage` | numeric | no | 0–100; for exams falls back to instructor's default if omitted |

**Example A — Final quiz linked to a training (no price required, slug generated):**

```text theme={null}
POST {{base_url}}/api/quiz
Accept: application/json
Authorization: Bearer {{token}}
Content-Type: application/json
```

```json theme={null}
{
  "company_id": 1,
  "training_id": 3,
  "chapter_id": null,
  "image_id": null,
  "label": "Web Basics Final Quiz",
  "description": "Test your knowledge of the whole training.",
  "max_attempts": 2,
  "passing_percentage": 60,
  "duration": 20,
  "break_interval": null,
  "break_duration": null,
  "is_published": false,
  "price": null,
  "discounted_price": null,
  "categories": [1],
  "instructor_id": 12,
  "revenue_percentage": null
}
```

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:**

```json theme={null}
{
  "company_id": 1,
  "training_id": null,
  "chapter_id": 7,
  "image_id": null,
  "label": "Chapter 3 Quiz",
  "description": null,
  "max_attempts": 3,
  "passing_percentage": 50,
  "duration": 15,
  "break_interval": null,
  "break_duration": null,
  "is_published": true,
  "price": null,
  "discounted_price": null,
  "categories": [1],
  "instructor_id": 12,
  "revenue_percentage": null
}
```

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):**

```json theme={null}
{
  "company_id": 1,
  "chapter_id": 7,
  "label": "Chapter 3 Quiz",
  "description": "Practice round on the same chapter.",
  "max_attempts": 0,
  "passing_percentage": 60,
  "duration": 10,
  "is_published": false,
  "categories": [1],
  "instructor_id": 12
}
```

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):**

```json theme={null}
{
  "company_id": 1,
  "training_id": null,
  "chapter_id": null,
  "image_id": null,
  "label": "Python Data Structures Exam",
  "slug": "Python-Data-Structures-2026",
  "description": "Lists, dictionaries, sets and tuples.",
  "max_attempts": 3,
  "passing_percentage": 75,
  "duration": 60,
  "break_interval": 10,
  "break_duration": 5,
  "is_published": true,
  "price": 29.99,
  "discounted_price": 19.99,
  "categories": [5],
  "instructor_id": 12,
  "revenue_percentage": 30
}
```

The sent slug is stored in lowercase: `python-data-structures-2026`.

**Example D — two categories (refused since 01/11/2026):**

```json theme={null}
{
  "company_id": 1,
  "training_id": null,
  "chapter_id": null,
  "label": "PMP Mock Exam",
  "passing_percentage": 70,
  "duration": 60,
  "max_attempts": 3,
  "is_published": false,
  "price": 49,
  "categories": [3, 4],
  "instructor_id": 12
}
```

```json theme={null}
{
  "message": "A training, an exam or a quiz has one category: send exactly one id in categories.",
  "errors": {
    "categories": ["A training, an exam or a quiz has one category: send exactly one id in categories."]
  }
}
```

`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):**

```json theme={null}
{
  "quiz_id": 24,
  "slug": "python-data-structures-2026",
  "message": "Quiz created successfully."
}
```

`slug` is the stored slug: the one you sent (lowercased) or the generated one.

**Error Responses:**

| **Status** | **Message** | **Reason** |
| :- | :- | :- |
| `422` | Validation error object | Any field fails its validation rule (since 19/10/2026 a `422`; before, these surfaced as `500`) |
| `422` | `"The slug has already been taken."` / `"The slug field format is invalid."` (`errors.slug`) | Sent slug already used by a quiz or exam (archived and soft-deleted ones included), or has characters outside the allowed set |
| `422` | `"A training, an exam or a quiz has one category: send exactly one id in categories."` (`errors.categories`) | Two or more category ids (since 01/11/2026, §3.3) |
| `422` | `"Please provide an instructor."` | `instructor_id` is null or missing |
| `422` | `"The selected chapter does not belong to a training of this company."` (`errors.chapter_id`) | `chapter_id` belongs to another company's training (or was soft-deleted) |
| `422` | `"For a chapter quiz, training_id must be left out or be the chapter's own training."` (`errors.training_id`) | `chapter_id` sent with a different training's `training_id` |
| `422` | `"The selected training does not belong to this company."` (`errors.training_id`) | Final quiz on another company's training |
| `422` | `"Instructor mismatch: The instructor_id in your request does not match the training's instructor."` | `instructor_id` doesn't match the training's instructor (for a chapter quiz: the chapter's training) |
| `422` | `"Please contact your platform admin to set the default revenue percentage value."` | Exam instructor has no default revenue % set |
| `500` | `{ "message": "<exception message>" }` | Unexpected error inside the transaction — nothing is saved. For example, the training's previous final quiz cannot be converted because its instructor has no default revenue percentage. |

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:**

```text theme={null}
POST {{base_url}}/api/quiz/5
Accept: application/json
Authorization: Bearer {{token}}
Content-Type: application/json
```

```json theme={null}
{
  "training_id": null,
  "chapter_id": null,
  "image_id": null,
  "label": "Advanced Python Exam (Revised)",
  "slug": "Advanced-Python-Exam-2026",
  "description": "Revised comprehensive Python assessment — updated May 2026.",
  "max_attempts": 5,
  "passing_percentage": 70,
  "duration": 90,
  "break_interval": 15,
  "break_duration": 5,
  "is_published": true,
  "price": 34.99,
  "discounted_price": 24.99,
  "categories": [5],
  "instructor_id": 12,
  "revenue_percentage": 30
}
```

**Success Response (200):**

```json theme={null}
{
  "message": "Quiz updated successfully.",
  "slug": "advanced-python-exam-2026"
}
```

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:

```text theme={null}
404 { "message": "No query results for model [App\\Models\\Quiz] 99" }   ← unknown quiz id
```

***

### **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:**

| **Field** | **Type** | **Required** | **Notes** |
| :- | :- | :- | :- |
| `quiz_questions` | array | **yes** | min 1 question |
| `quiz_questions[*].quiz_question_type_id` | integer | **yes** | `1`=True/False, `2`=Single Answer, `3`=Multiple Answer, `4`=Drag & Drop |
| `quiz_questions[*].image_id` | integer | no | file id for question image |
| `quiz_questions[*].label` | string | **yes** | question text |
| `quiz_questions[*].tip` | string | no | hint shown to student |
| `quiz_questions[*].explanation` | string | no | shown after answering |
| `quiz_questions[*].quiz_question_options` | array | **yes** | min 1 option |
| `quiz_questions[*].quiz_question_options[*].label` | string | **yes** | option text |
| `quiz_questions[*].quiz_question_options[*].is_correct` | boolean | no | marks the correct answer(s); stored as `false` when omitted |
| `quiz_questions[*].quiz_question_options[*].quiz_question_option_items` | array | for type `4` only | draggable items for this bucket (ignored for other types; none created when omitted) |
| `quiz_questions[*].quiz_question_options[*].quiz_question_option_items[*].label` | string | **yes** (when items present) | item label |

**Full Example — all 4 question types (Standalone Exam):**

```text theme={null}
{
  "company_id": 1,
  "training_id": null,
  "chapter_id": null,
  "image_id": null,
  "label": "JavaScript Fundamentals — Full Exam",
  "description": "Covers JS basics, types, and syntax.",
  "max_attempts": 3,
  "passing_percentage": 70,
  "duration": 45,
  "break_interval": null,
  "break_duration": null,
  "is_published": true,
  "price": 49,
  "discounted_price": null,
  "categories": [3],
  "instructor_id": 12,
  "revenue_percentage": 30,
  "quiz_questions": [
    {
      "quiz_question_type_id": 1,
      "image_id": null,
      "label": "JavaScript is a compiled language.",
      "tip": "Think about how browsers execute JS.",
      "explanation": "JavaScript is interpreted at runtime, not precompiled.",
      "quiz_question_options": [
        { "label": "True",  "is_correct": false },
        { "label": "False", "is_correct": true  }
      ]
    },
    {
      "quiz_question_type_id": 2,
      "image_id": null,
      "label": "Which keyword declares a constant in JavaScript?",
      "tip": "Introduced in ES6.",
      "explanation": "`const` creates an immutable binding.",
      "quiz_question_options": [
        { "label": "var",   "is_correct": false },
        { "label": "let",   "is_correct": false },
        { "label": "const", "is_correct": true  },
        { "label": "def",   "is_correct": false }
      ]
    },
    {
      "quiz_question_type_id": 3,
      "image_id": null,
      "label": "Which of the following are falsy values in JavaScript? (Select all that apply)",
      "tip": "There are 6 falsy values in JS.",
      "explanation": "Falsy values: false, 0, empty string, null, undefined, NaN.",
      "quiz_question_options": [
        { "label": "0",         "is_correct": true  },
        { "label": "\"\"",      "is_correct": true  },
        { "label": "null",      "is_correct": true  },
        { "label": "[]",        "is_correct": false },
        { "label": "undefined", "is_correct": true  }
      ]
    },
    {
      "quiz_question_type_id": 4,
      "image_id": null,
      "label": "Match each value to its JavaScript data type.",
      "tip": null,
      "explanation": null,
      "quiz_question_options": [
        {
          "label": "string",
          "is_correct": true,
          "quiz_question_option_items": [
            { "label": "\"hello\"" },
            { "label": "\"world\"" }
          ]
        },
        {
          "label": "number",
          "is_correct": true,
          "quiz_question_option_items": [
            { "label": "42" },
            { "label": "3.14" }
          ]
        },
        {
          "label": "boolean",
          "is_correct": true,
          "quiz_question_option_items": [
            { "label": "true" },
            { "label": "false" }
          ]
        }
      ]
    }
  ]
}
```

Response: `{ "quiz_id": 8, "slug": "javascript-fundamentals-full-exam", "message": "Quiz created successfully." }`.

**Minimal Example — Final quiz of a training, 2 questions only:**

```text theme={null}
{
  "company_id": 1,
  "training_id": 3,
  "chapter_id": null,
  "image_id": null,
  "label": "Module 2 Quiz",
  "description": null,
  "max_attempts": 2,
  "passing_percentage": 60,
  "duration": 15,
  "break_interval": null,
  "break_duration": null,
  "is_published": false,
  "price": null,
  "discounted_price": null,
  "categories": [1],
  "instructor_id": 12,
  "revenue_percentage": null,
  "quiz_questions": [
    {
      "quiz_question_type_id": 1,
      "image_id": null,
      "label": "CSS stands for Cascading Style Sheets.",
      "tip": null,
      "explanation": "Correct. CSS = Cascading Style Sheets.",
      "quiz_question_options": [
        { "label": "True",  "is_correct": true  },
        { "label": "False", "is_correct": false }
      ]
    },
    {
      "quiz_question_type_id": 2,
      "image_id": null,
      "label": "Which HTML tag creates a hyperlink?",
      "tip": "It is a single letter.",
      "explanation": "The `<a>` (anchor) tag creates hyperlinks.",
      "quiz_question_options": [
        { "label": "<link>", "is_correct": false },
        { "label": "<a>",    "is_correct": true  },
        { "label": "<href>", "is_correct": false }
      ]
    }
  ]
}
```

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):**

```json theme={null}
{
  "quiz_id": 12,
  "slug": "module-2-quiz",
  "message": "Quiz created successfully."
}
```

**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:**

```text theme={null}
POST {{base_url}}/api/quiz/update_quiz_with_questions_and_options/8
Accept: application/json
Authorization: Bearer {{token}}
Content-Type: application/json
```

```json theme={null}
{
  "training_id": null,
  "chapter_id": null,
  "image_id": null,
  "label": "JavaScript Fundamentals — Full Exam (v2)",
  "description": "Updated version covering ES6+ features.",
  "max_attempts": 5,
  "passing_percentage": 65,
  "duration": 60,
  "break_interval": 15,
  "break_duration": 5,
  "is_published": true,
  "price": 59,
  "discounted_price": 39,
  "categories": [3],
  "instructor_id": 12,
  "revenue_percentage": 30,
  "quiz_questions": [
    {
      "quiz_question_type_id": 2,
      "image_id": null,
      "label": "What does the `===` operator check in JavaScript?",
      "tip": "Think about value AND type.",
      "explanation": "`===` checks both value and type (strict equality).",
      "quiz_question_options": [
        { "label": "Value only",          "is_correct": false },
        { "label": "Value and type",      "is_correct": true  },
        { "label": "Type only",           "is_correct": false },
        { "label": "Reference in memory", "is_correct": false }
      ]
    },
    {
      "quiz_question_type_id": 3,
      "image_id": null,
      "label": "Which ES6 features allow block-scoped variable declarations? (Select all that apply)",
      "tip": null,
      "explanation": "`let` and `const` are block-scoped; `var` is function-scoped.",
      "quiz_question_options": [
        { "label": "var",   "is_correct": false },
        { "label": "let",   "is_correct": true  },
        { "label": "const", "is_correct": true  }
      ]
    },
    {
      "quiz_question_type_id": 4,
      "image_id": null,
      "label": "Match the JavaScript array method to what it does.",
      "tip": null,
      "explanation": null,
      "quiz_question_options": [
        {
          "label": "Adds one or more elements to the end of an array",
          "is_correct": true,
          "quiz_question_option_items": [
            { "label": "push()" }
          ]
        },
        {
          "label": "Removes the last element from an array",
          "is_correct": true,
          "quiz_question_option_items": [
            { "label": "pop()" }
          ]
        },
        {
          "label": "Returns a new array with a function applied to each element",
          "is_correct": true,
          "quiz_question_option_items": [
            { "label": "map()" }
          ]
        }
      ]
    }
  ]
}
```

**Success Response (200):**

```json theme={null}
{
  "quiz_id": 8,
  "slug": "javascript-fundamentals-full-exam",
  "message": "Quiz updated successfully."
}
```

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):**

```json theme={null}
{
  "chapter_id": 9,
  "label": "Chapter 4 Practice Quiz",
  "description": "Practice round.",
  "max_attempts": 0,
  "passing_percentage": 60,
  "duration": 10,
  "is_published": true,
  "categories": [1]
}
```

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`.

```text theme={null}
POST {{base_url}}/api/quiz/5/archive
Body: (empty)
```

Success Response (200):

```text theme={null}
{
  "message": "Quiz archived successfully."
}
```

***

**Unarchive** — `POST /api/quiz/{id}/unarchive`

Authorize: `restore` on a quiz fetched with `onlyArchived()`. History: `unarchived`.

```text theme={null}
POST {{base_url}}/api/quiz/5/unarchive
Body: (empty)
```

Success Response (200):

```text theme={null}
{
  "message": "Quiz restored successfully.",
  "quiz": {
    "id": 5,
    "label": "Advanced Python Exam",
    "slug": "advanced-python-exam",
    "is_published": true,
    "deleted_at": null
  }
}
```

***

**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.

```text theme={null}
DELETE {{base_url}}/api/quiz/5/delete
Body: (empty)
```

Success Response (200):

```text theme={null}
{
  "message": "Quiz force deleted successfully."
}
```

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)](https://theteam-13fc7f49.mintlify.site/api-reference/users.md#21-owner-emails-json).

**Query Parameters:**

| **Param** | **Type** | **Default** | **Notes** |
| :- | :- | :- | :- |
| `no_exam` | boolean | `false` | `true` → return students who do NOT yet have this exam |
| `withQuestions` | boolean | `false` | `true` → eager-load student's questions in the response |

**Example A — Students who purchased this exam:**

```text theme={null}
GET {{base_url}}/api/students_by_exam/5
```

**Example B — Students who do NOT have this exam yet:**

```text theme={null}
GET {{base_url}}/api/students_by_exam/5?no_exam=true
```

**Example C — Students with their questions loaded:**

```text theme={null}
GET {{base_url}}/api/students_by_exam/5?withQuestions=true
```

**Success Response (200) — default (purchased students):**

```text theme={null}
{
  "current_page": 1,
  "data": [
    {
      "id": 7,
      "name": "Alice Johnson",
      "email": "alice@example.com",
      "role_id": 4,
      "company_id": 1
    },
    {
      "id": 9,
      "name": "Bob Martin",
      "email": "bob@example.com",
      "role_id": 4,
      "company_id": 1
    }
  ],
  "per_page": 15,
  "total": 2
}
```

***

### **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](#51-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**:

| **Included** | **Never included** |
| :- | :- |
| `id`, `label`, `slug`, `description`, `duration`, `break_interval`, `break_duration`, `max_attempts`, `passing_percentage`, `is_published`, `views_count`, `price`, `discounted_price`, `final_price`, `image`, `categories`, `average_rating`, `total_ratings`, `total_active_sales`, `quiz_questions_count`, a minimal `instructor` block, `is_owned` | `quiz_questions` (labels, `tip`, `explanation`, options, `is_correct`, option items), `revenue_percentage`, and the instructor's `email` / `default_revenue_percentage` that `GET /api/quizzes` leaks through `Quiz::$with = ['instructor']` |

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](#511-preview-an-exam-marketplace-safe) only; listing exams never counts as a view).

**Query Parameters:**

| **Param** | **Type** | **Required** | **Notes** |
| :- | :- | :- | :- |
| `company_id` | integer | **yes** | must exist in `companies`; non super-admins may only pass their own company (`403` otherwise) |
| `min_price` | integer | no | price > value |
| `max_price` | integer | no | price \< value |
| `instructor_id` | integer | no | filter by instructor |
| `categories` | string (JSON array) | no | e.g. `[2,5]`. Several ids are still accepted (an exam matches when its one category is in the list, §3.3) |
| `reviews` | string (JSON array) | no | e.g. `[3,5]` → average rating between 3 and 5 (exams without reviews never match; bounds clamped to 0–5; anything that is not two finite numbers is ignored) |
| `search` | string | no | label search |
| `page` | integer | no | pagination page (default 1) |
| `per_page` | integer | no | items per page (default 20, max 100) |

**Example Requests:**

```text theme={null}
GET {{base_url}}/api/preview-exams?company_id=1&page=1&per_page=10
GET {{base_url}}/api/preview-exams?company_id=1&categories=[2,5]&search=python
```

**Success Response (200):**

```json theme={null}
{
  "current_page": 1,
  "data": [
    {
      "id": 5,
      "company_id": 1,
      "training_id": null,
      "chapter_id": null,
      "instructor_id": 12,
      "image_id": 41,
      "label": "Advanced Python Exam",
      "slug": "advanced-python-exam",
      "description": "Comprehensive Python assessment.",
      "duration": 60,
      "break_interval": 10,
      "break_duration": 5,
      "max_attempts": 3,
      "passing_percentage": 75,
      "is_published": 1,
      "views_count": 42,
      "price": 29.99,
      "discounted_price": 19.99,
      "final_price": 19.99,
      "created_at": "2026-01-15 10:00:00",
      "updated_at": "2026-01-15 10:00:00",
      "average_rating": 5,
      "reviews_avg_rating": 5,
      "total_ratings": 1,
      "total_active_sales": 7,
      "quiz_questions_count": 4,
      "image": { "id": 41, "type": 2, "url": "/storage/files/python.png" },
      "instructor": {
        "id": 12,
        "first_name": "John",
        "last_name": "Smith",
        "fullname": "John Smith",
        "image_id": 9,
        "image": { "id": 9, "type": 2, "url": "/storage/files/john.png" },
        "bio": "Python trainer",
        "hide_public_profile": false,
        "public_profile_about_me": "Teaching Python since 2012.",
        "public_profile_skills": "Python, Django",
        "public_profile_verification": 1
      },
      "categories": [{ "id": 5, "label": "Python" }],
      "training": null,
      "training_label": null,
      "training_slug": null,
      "is_owned": false
    }
  ],
  "first_page_url": "{{base_url}}/api/preview-exams?page=1",
  "from": 1,
  "last_page": 1,
  "last_page_url": "{{base_url}}/api/preview-exams?page=1",
  "links": [ ... ],
  "next_page_url": null,
  "path": "{{base_url}}/api/preview-exams",
  "per_page": 10,
  "prev_page_url": null,
  "to": 1,
  "total": 1,
  "catalog_counts": { "trainings": 27, "exams": 1, "instructors": 4 }
}
```

**Error Responses:**

```text theme={null}
401 { "message": "Unauthorized" }
403 { "message": "Unauthorized to fetch data of other companies." }      ← company_id of another company
403 { "message": "This action is unauthorized." }                        ← role without QUIZZES_VIEWANY (81)
422 { "message": "The company ID is required.", "errors": { "company_id": ["The company ID is required."] } }
422 { "message": "The per page field must not be greater than 100.", "errors": { "per_page": ["The per page field must not be greater than 100."] } }    ← per_page outside 1–100
500 { "message": "Unable to load the preview. Please try again later." }                              ← logged; the raw message is only returned when APP_DEBUG=true
```

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](#52-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`.

| **Param** | **Type** | **Notes** |
| :- | :- | :- |
| `idOrSlug` | integer or string | exam ID, or its slug (case-insensitive). A digit-only value is tried as an ID first, then as a slug. |

**Example Requests:**

```text theme={null}
GET {{base_url}}/api/preview-exam/5
GET {{base_url}}/api/preview-exam/advanced-python-exam
```

**Success Response (200):**

```json theme={null}
{
  "id": 5,
  "company_id": 1,
  "training_id": null,
  "chapter_id": null,
  "instructor_id": 12,
  "image_id": 41,
  "label": "Advanced Python Exam",
  "slug": "advanced-python-exam",
  "description": "Comprehensive Python assessment.",
  "duration": 60,
  "break_interval": 10,
  "break_duration": 5,
  "max_attempts": 3,
  "passing_percentage": 75,
  "is_published": 1,
  "views_count": 43,
  "price": 29.99,
  "discounted_price": 19.99,
  "final_price": 19.99,
  "created_at": "2026-01-15 10:00:00",
  "updated_at": "2026-01-15 10:00:00",
  "average_rating": 5,
  "reviews_avg_rating": 5,
  "total_ratings": 1,
  "total_active_sales": 7,
  "quiz_questions_count": 4,
  "image": { "id": 41, "type": 2, "url": "/storage/files/python.png" },
  "instructor": { "id": 12, "first_name": "John", "last_name": "Smith", "fullname": "John Smith", "image_id": 9, "image": { "id": 9, "type": 2, "url": "/storage/files/john.png" }, "bio": "Python trainer", "hide_public_profile": false, "public_profile_about_me": "Teaching Python since 2012.", "public_profile_skills": "Python, Django", "public_profile_verification": 1 },
  "categories": [{ "id": 5, "label": "Python" }],
  "training": null,
  "training_label": null,
  "training_slug": null,
  "is_owned": true,
  "reviews": [
    {
      "id": 2,
      "user_id": 88,
      "rating": 5,
      "comment": "Hard but fair.",
      "created_at": "2026-02-01 12:00:00",
      "updated_at": "2026-02-01 12:00:00",
      "user": { "id": 88, "first_name": "Sarra", "last_name": "Trabelsi", "image_id": null, "image": null }
    }
  ]
}
```

**Error Responses:**

```text theme={null}
401 { "message": "Unauthorized" }
403 { "message": "This action is unauthorized." }   ← role without QUIZZES_VIEWANY (81)
403 { "message": "Unauthorized to fetch data of other companies." }   ← ?company_id= of another company (non super-admin)
404 { "message": "Exam not found." }                ← unknown id / slug, unpublished, archived, other company, or a quiz attached to a training / chapter
500 { "message": "Unable to load the preview. Please try again later." }   ← logged; the raw message is only returned when APP_DEBUG=true
```

***

## **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](https://theteam-13fc7f49.mintlify.site/api-reference/quizzes-and-exams-export-and-import-endpoints.md).

***

## **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](https://theteam-13fc7f49.mintlify.site/api-reference/chapters.md) and [Trainings](https://theteam-13fc7f49.mintlify.site/api-reference/trainings.md#retrieve-a-training).
* `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:

| **Action** | **Trigger** |
| :- | :- |
| `created` | `store`, `create_quiz_with_questions_and_options` |
| `updated` | `update`, `update_quiz_with_questions_and_options` |
| `converted_to_exam` | `turnQuizToExam` (a new or moved final quiz retires the training's previous final quiz; chapter quizzes never trigger it since 19/10/2026) |
| `changed_instructor` | `auto_change_instructors_for_quizzes` |
| `archive` | `archive` |
| `unarchived` | `unarchive` |
| `deleted` | force `delete` |
| `imported_questions_with_images` | bulk import via JSON+images |

***

## **9. Related Files**

* `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](https://theteam-13fc7f49.mintlify.site/api-reference/quizzes-and-exams-export-and-import-endpoints.md)
* `app/Http/Controllers/Quizzes_Exams_Progress_Tracking_Controller.php` — see [Quizzes & Exams progress tracking](https://theteam-13fc7f49.mintlify.site/api-reference/quizzes-and-exams-progress-tracking-api.md)
* `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](https://theteam-13fc7f49.mintlify.site/api-reference/archive-delete-and-restore-flow.md) — archive / restore / hard-delete semantics
* [Instructor dashboard](https://theteam-13fc7f49.mintlify.site/api-reference/instructor-dashboard.md) — `default_revenue_percentage` setter


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.