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

# Trainings

Manage trainings end-to-end: create, list, update, archive, restore, delete, and assign a category (one per training), tags, or students.

All endpoints are authenticated — they run behind `auth:sanctum`, the "user is not blocked" check, and the per-company policy middleware. Always send your Sanctum token as a `Bearer` header.

## Table of contents

* **Base URL & Headers**
* **List trainings**
* **Create a training**
* **Retrieve a training**
* **Preview trainings (marketplace-safe)**
* **Preview a training (marketplace-safe)**
* **Update a training**
* **Archive a training**
* **Restore an archived training**
* **Permanently delete a training**
* **Assign categories**
* **Assign tags**
* **List students of a training**
* **Postman setup**

***

## Base URL & Headers

```text theme={null}
Base URL: {{base_url}}/api
```

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

Dates are returned in `Y-m-d H:i:s` format. Money fields are numeric (doubles). Boolean query params accept `true`, `false`, `1`, `0`.

***

## List trainings

`GET /trainings`

Returns a paginated list of trainings for a given company, with filters for level, price, categories, tags, rating, and visibility.

> [!WARNING]
>
> **Not for the student marketplace.** Every row embeds the training's final quiz with its questions, options, `is_correct` flags and `explanation` texts (eager load `quiz.quiz_questions.quiz_question_options.quiz_question_option_items`), the full instructor user (email, phone, `default_revenue_percentage`, ...) and `revenue_percentage`. Anyone who opens the browser's network tab sees all of it. For the catalog page use [Preview trainings](#preview-trainings-marketplace-safe) instead; keep this endpoint for the admin tables that need the full payload.

### Query parameters

| Name | Type | Required | Description |
| :- | :- | :- | :- |
| `company_id` | integer | Yes | Must exist in the `companies` table. |
| `level` | string | No | One of `Beginner`, `Intermediate`, `Expert`. |
| `min_price` | integer | No | Returns trainings with price strictly greater than this value. |
| `max_price` | integer | No | Returns trainings with price strictly less than this value. |
| `only_archived` | boolean | No | If `true`, returns only archived records. |
| `page` | integer | No | Page number. Default `1`. |
| `per_page` | integer | No | Between `1` and `100`. |
| `search` | string | No | Free-text search across training fields. |
| `instructor_id` | integer | No | Filter by instructor. |
| `categories` | string | No | JSON-encoded array of category IDs, e.g. `[1,2,3]`. Several ids are still accepted: a training matches when its category is one of them (one category per training since 01/11/2026, see [Assign categories](#assign-categories)). |
| `tags` | string | No | JSON-encoded array of tag IDs, e.g. `[4,5]`. |
| `reviews` | string | No | JSON-encoded `[min,max]` range for the average rating, e.g. `[3,5]`. |
| `is_public` | boolean | No | Filter by visibility. |
| `withChapters` | boolean | No | Eager-loads the `chapters` relation when `true`. |

### Example request

```text theme={null}
GET {{base_url}}/api/trainings?company_id=1&level=Beginner&per_page=10&withChapters=true
Accept: application/json
Authorization: Bearer {{token}}
```

### Example response — `200 OK`

```text theme={null}
{
  "current_page": 1,
  "data": [
    {
      "id": 12,
      "company_id": 1,
      "instructor_id": 5,
      "revenue_percentage": 40,
      "label": "Intro to PMP",
      "slug": "intro-to-pmp",
      "description": "<p>Course description...</p>",
      "level": "Beginner",
      "duration": 120,
      "price": 199.99,
      "discounted_price": 149.99,
      "image_id": 22,
      "video_id": null,
      "badge_logo_id": null,
      "is_public": true,
      "views_count": 42,
      "published": true,
      "created_at": "2026-04-01 09:22:10",
      "updated_at": "2026-04-10 14:03:55",
      "deleted_at": null,
      "archived_at": null,
      "average_rating": 4.5,
      "total_ratings": 12,
      "total_active_sales": 9,
      "total_sales": 11,
      "final_price": 149.99,
      "total_lessons_count": 18,
      "total_chapters_count": 4,
      "chapters_count": 4,
      "categories": [{ "id": 3, "label": "Project Management" }],
      "tags": [{ "id": 7, "label": "Certification" }],
      "instructor": {
        "id": 5,
        "first_name": "Ali",
        "last_name": "Ben",
        "role": { "id": 4, "label": "Instructor" }
      },
      "reviews": [],
      "image": { "id": 22, "url": "/storage/files/xxx.png" },
      "video": null,
      "badge_logo": null,
      "quiz": null
    }
  ],
  "per_page": 10,
  "total": 27,
  "last_page": 3
}
```

### 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
500 { "message": "<exception message>" }                                                                                                                   ← logged
```

> [!NOTE]
>
> Before 19/10/2026 an invalid query parameter came back as a `500` whose `message` was the bare translation key (e.g. `{"message":"validation.max.numeric"}` for `per_page=1000`). There is no "all rows" mode: page through the list with `per_page` ≤ `100`.

***

## Create a training

`POST /training`

Creates a new training. Requires the instructor to have a `default_revenue_percentage` configured; otherwise the request is rejected.

### Body parameters

| Name | Type | Required | Description |
| :- | :- | :- | :- |
| `company_id` | integer | Yes | Must exist in `companies`. |
| `instructor_id` | integer | Yes | Instructor must have `default_revenue_percentage` set. |
| `revenue_percentage` | number | No | Between `0` and `100`. Falls back to the instructor's default when omitted. |
| `label` | string | Yes | 1–255 characters. |
| `description` | string | Yes | Minimum 1 character. |
| `level` | string | Yes | Recommended: `Beginner`, `Intermediate`, `Expert`. |
| `duration` | integer | Yes | Duration in minutes. |
| `price` | number | Yes | Between `0` and `9999999.999`. |
| `discounted_price` | number | No | Between `0` and `9999999.999`. |
| `image_id` | integer | No | Must exist in `files`. |
| `video_id` | integer | No | Must exist in `files`. |
| `badge_logo_id` | integer | No | Must exist in `files`. Falls back to a default PMP badge when not supplied. |
| `is_public` | boolean | No | Visibility flag. |
| `slug` | string | No | **Generated by the backend when omitted or empty** (since 16/10/2026): the label is slugified (`"Intro to PMP"` → `intro-to-pmp`) and suffixed `-2`, `-3`… until unique (soft-deleted and archived trainings count). When sent, it is forced to lowercase, must match `^[a-z0-9()\-,._=\[\]:]+$` and be unique. |
| `file` | file | No | Upload: `pptx`, `jpeg`, `png`, `pdf`, or `zip`, max 10 MB. |

### Example request

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

```text theme={null}
{
  "company_id": 1,
  "instructor_id": 5,
  "revenue_percentage": 40,
  "label": "Intro to PMP",
  "description": "Full PMP primer.",
  "level": "Beginner",
  "duration": 120,
  "price": 199.99,
  "discounted_price": 149.99,
  "image_id": 22,
  "video_id": null,
  "badge_logo_id": null,
  "is_public": true
}
```

### Responses

`200 OK` — `slug` is the generated (or supplied) slug

```text theme={null}
{
  "training_id": 12,
  "slug": "intro-to-pmp",
  "message": "Training created successfully."
}
```

`422 Unprocessable Entity` — missing instructor default

```text theme={null}
{ "message": "Please contact your platform admin to set the default revenue percentage value." }
```

`422 Unprocessable Entity` — duplicate or malformed slug (only when the client sends one)

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

***

## Retrieve a training

`GET /training/{id}`

Returns a single training with its instructor, chapters (each with its lessons and quizzes), reviews, media, badge logo, and its published final quiz.

> Every call to this endpoint increments the `views_count` counter on the training.

**Quizzes in this payload (since 19/10/2026):**

* `chapters[].quizzes` — **every** quiz of the chapter, in creation order, each with its full question tree. It is there whether or not the chapter has lessons, and is `[]` when the chapter has none.
* `chapters[].quiz` — ⚠️ **deprecated**: the first element of `quizzes` (or `null`), kept for older app builds. Read `quizzes` in new code.
* **Students** only get **published** chapter quizzes. **Staff** (instructor, content manager, admin, super admin) also get the drafts, so the training editor can list them; check each quiz's `is_published`.
* `quiz` (top level) — the training's **final** quiz (attached to the training, not to a chapter), published only, or `null`. It is never a chapter quiz, even for an older row that carries both a `training_id` and a `chapter_id`.

Before 19/10/2026 each chapter carried a single `quiz` (published only); the "quiz missing on a chapter without lessons" symptom the app worked around was most likely an unpublished (draft) quiz. See [Chapters → Chapter quizzes in the payload](https://theteam-13fc7f49.mintlify.site/api-reference/chapters.md#chapter-quizzes-in-the-payload).

> [!WARNING]
>
> **Not for the student marketplace.** The response contains every lesson's full `content`, plus the final quiz and every chapter quiz with their questions, options, `is_correct` flags and `explanation` texts. `TrainingPolicy::view` returns `true` for every authenticated user, so a student who has not bought the training can read the whole course from this JSON. The training preview page must call [Preview a training](#preview-a-training-marketplace-safe) instead.

### Example request

```text theme={null}
GET {{base_url}}/api/training/12
Accept: application/json
Authorization: Bearer {{token}}
```

### Responses

`200 OK`

```text theme={null}
{
  "id": 12,
  "company_id": 1,
  "instructor_id": 5,
  "revenue_percentage": 40,
  "label": "Intro to PMP",
  "slug": "intro-to-pmp",
  "description": "<p>...</p>",
  "level": "Beginner",
  "duration": 120,
  "price": 199.99,
  "discounted_price": 149.99,
  "image_id": 22,
  "video_id": null,
  "badge_logo_id": null,
  "is_public": true,
  "views_count": 43,
  "published": true,
  "average_rating": 4.5,
  "total_ratings": 12,
  "total_active_sales": 9,
  "total_sales": 11,
  "final_price": 149.99,
  "total_lessons_count": 18,
  "total_chapters_count": 4,
  "chapters": [
    {
      "id": 40,
      "training_id": 12,
      "label": "Chapter 1",
      "position": 1,
      "lessons_count": 1,
      "lessons": [
        { "id": 101, "chapter_id": 40, "label": "Lesson 1", "type": "document", "position": 1, "content": "..." }
      ],
      "quizzes": [
        {
          "id": 77,
          "training_id": null,
          "chapter_id": 40,
          "label": "Chapter 1 Quiz",
          "slug": "chapter-1-quiz",
          "passing_percentage": 70,
          "duration": 15,
          "max_attempts": 3,
          "is_published": 1,
          "...": "other quiz columns, rating / sales fields, instructor",
          "quiz_questions": [ "... questions → options (with is_correct) → option items ..." ]
        },
        {
          "id": 80,
          "training_id": null,
          "chapter_id": 40,
          "label": "Chapter 1 Recap Quiz",
          "slug": "chapter-1-recap-quiz",
          "is_published": 1,
          "...": "...",
          "quiz_questions": [ "..." ]
        }
      ],
      "quiz": { "id": 77, "chapter_id": 40, "label": "Chapter 1 Quiz", "...": "same object as quizzes[0] (deprecated)" }
    },
    {
      "id": 41,
      "training_id": 12,
      "label": "Chapter 2 — Checkpoint",
      "position": 2,
      "lessons_count": 0,
      "lessons": [],
      "quizzes": [
        { "id": 79, "training_id": null, "chapter_id": 41, "label": "Checkpoint Quiz", "slug": "checkpoint-quiz", "is_published": 1, "...": "...", "quiz_questions": [ "..." ] }
      ],
      "quiz": { "id": 79, "chapter_id": 41, "label": "Checkpoint Quiz", "...": "same object as quizzes[0] (deprecated)" }
    }
  ],
  "categories": [{ "id": 3, "label": "Project Management" }],
  "tags": [{ "id": 7, "label": "Certification" }],
  "instructor": { "id": 5, "first_name": "Ali", "last_name": "Ben" },
  "image": { "id": 22, "url": "/storage/files/xxx.png" },
  "video": null,
  "badge_logo": null,
  "quiz": {
    "id": 31,
    "training_id": 12,
    "chapter_id": null,
    "label": "Final quiz",
    "slug": "final-quiz",
    "is_published": 1,
    "...": "...",
    "quiz_questions": [ "..." ]
  },
  "reviews": []
}
```

Called by a student. A staff caller would also see chapter 40's draft quizzes (with `"is_published": 0`) in `quizzes`.

`404 Not Found`

```text theme={null}
{ "message": "No query results for model [App\\Models\\Training] 12" }
```

***

## Preview trainings (marketplace-safe)

`GET /preview-trainings`

The marketplace version of [List trainings](#list-trainings), built for the student catalog page (`/student-training-catalog`). It keeps the same authorization (`viewAny` on `Training`, which students hold by default), the same paginator envelope and the same catalog filters (there is no `only_archived` / `withChapters`), but every row is **built from a whitelist**, so a student who has not bought a training cannot read its content from the JSON.

| Included | Never included |
| :- | :- |
| `id`, `label`, `slug`, `description` (marketing HTML), `level`, `duration`, `price`, `discounted_price`, `final_price`, `views_count`, `image` / `video` / `badge_logo` (`id`, `type`, `url`), `categories`, `tags`, `average_rating`, `total_ratings`, `total_active_sales`, `total_chapters_count`, `total_lessons_count`, the final quiz **outline** (`quiz.quiz_questions_count`, no questions; `quiz` is `null` while the final quiz is unpublished), `is_owned` | lesson `content`, `quiz_questions`, options, `is_correct`, `explanation`, `tip`, drag-and-drop items, `revenue_percentage`, and the instructor's `email`, `phone_number`, `birthday`, `default_revenue_percentage`, `owned_trainings` / `owned_exams` |

Rules enforced server-side, whatever the query string says:

* Only **public** trainings (`is_public = 1`). An `is_public` parameter is accepted for compatibility but ignored.
* Archived and soft-deleted trainings are never returned.
* `instructor` only carries `id`, names, avatar, `bio` and the `public_profile_*` fields, and those are `null` while the instructor keeps `hide_public_profile = true`.
* `is_owned` is `true` when the caller has a paid order (status Paid Online / Paid Offline, item not revoked) for the training, so the UI can show "Open" instead of "Buy" without a second call.

### Query parameters

| Name | Type | Required | Description |
| :- | :- | :- | :- |
| `company_id` | integer | Yes | Must exist in `companies`. Non super-admins may only pass their own company (`403` otherwise). |
| `level` | string | No | One of `Beginner`, `Intermediate`, `Expert`. |
| `min_price` | integer | No | Trainings with a price strictly greater than this value. |
| `max_price` | integer | No | Trainings with a price strictly lower than this value. |
| `instructor_id` | integer | No | Filter by instructor. |
| `categories` | string | No | JSON-encoded array of category IDs, e.g. `[1,2,3]`. Several ids are still accepted: a training matches when its category is one of them (one category per training since 01/11/2026, see [Assign categories](#assign-categories)). |
| `tags` | string | No | JSON-encoded array of tag IDs, e.g. `[4,5]`. |
| `reviews` | string | No | JSON-encoded `[min,max]` range on the average rating, e.g. `[3,5]`. Trainings without reviews never match. Bounds are clamped to `0`–`5`; anything that is not two finite numbers is ignored. |
| `search` | string | No | Case-insensitive match on `label`. |
| `page` | integer | No | Page number. Default `1`. |
| `per_page` | integer | No | Between `1` and `100`. Default `20`. |

### Example request

```text theme={null}
GET {{base_url}}/api/preview-trainings?company_id=1&page=1&per_page=10
Accept: application/json
Authorization: Bearer {{token}}
```

### Responses

> [tab] 200
>
> ✅ 200 OK — standard Laravel paginator (`data`, `current_page`, `per_page`, `total`, `last_page`, `next_page_url`, ...)
>
> ```json theme={null}
> {
>   "current_page": 1,
>   "data": [
>     {
>       "id": 12,
>       "company_id": 1,
>       "instructor_id": 5,
>       "label": "Intro to PMP",
>       "slug": "intro-to-pmp",
>       "description": "<p>Everything you need to pass the PMP exam.</p>",
>       "level": "Beginner",
>       "duration": 120,
>       "price": 199.99,
>       "discounted_price": 149.99,
>       "final_price": 149.99,
>       "is_public": 1,
>       "published": true,
>       "views_count": 42,
>       "image_id": 22,
>       "video_id": null,
>       "badge_logo_id": null,
>       "created_at": "2026-04-01 09:22:10",
>       "updated_at": "2026-04-10 14:03:55",
>       "average_rating": 4.5,
>       "reviews_avg_rating": 4.5,
>       "total_ratings": 12,
>       "total_active_sales": 9,
>       "chapters_count": 4,
>       "total_chapters_count": 4,
>       "total_lessons_count": 18,
>       "image": { "id": 22, "type": 2, "url": "/storage/files/pmp.png" },
>       "video": null,
>       "badge_logo": null,
>       "instructor": {
>         "id": 5,
>         "first_name": "Ali",
>         "last_name": "Ben",
>         "fullname": "Ali Ben",
>         "image_id": 9,
>         "image": { "id": 9, "type": 2, "url": "/storage/files/ali.png" },
>         "bio": "PMP trainer",
>         "hide_public_profile": false,
>         "public_profile_about_me": "Certified PMP trainer since 2015.",
>         "public_profile_skills": "PMP, Agile",
>         "public_profile_verification": 1
>       },
>       "categories": [{ "id": 3, "label": "Project Management" }],
>       "tags": [{ "id": 7, "label": "Certification" }],
>       "quiz": {
>         "id": 31,
>         "training_id": 12,
>         "chapter_id": null,
>         "label": "Final quiz",
>         "slug": "final-quiz",
>         "duration": 30,
>         "break_interval": null,
>         "break_duration": null,
>         "max_attempts": 3,
>         "passing_percentage": 75,
>         "is_published": 1,
>         "quiz_questions_count": 10
>       },
>       "is_owned": false
>     }
>   ],
>   "first_page_url": "{{base_url}}/api/preview-trainings?page=1",
>   "from": 1,
>   "last_page": 3,
>   "last_page_url": "{{base_url}}/api/preview-trainings?page=3",
>   "links": [ ... ],
>   "next_page_url": "{{base_url}}/api/preview-trainings?page=2",
>   "path": "{{base_url}}/api/preview-trainings",
>   "per_page": 10,
>   "prev_page_url": null,
>   "to": 10,
>   "total": 27,
>   "catalog_counts": { "trainings": 27, "exams": 6, "instructors": 4 }
> }
> ```

> [tab] 401
>
> ⚠️ 401 Unauthorized — missing or invalid token
>
> ```json theme={null}
> { "message": "Unauthorized" }
> ```

> [tab] 403
>
> ⚠️ 403 Forbidden — `company_id` of another company (non super-admin)
>
> ```json theme={null}
> { "message": "Unauthorized to fetch data of other companies." }
> ```
>
> ⚠️ 403 Forbidden — the role lost the `trainings-viewAny` permission (`61`)
>
> ```json theme={null}
> { "message": "This action is unauthorized." }
> ```

> [tab] 422
>
> ⚠️ 422 Validation Error
>
> ```json theme={null}
> {
>   "message": "The company ID is required.",
>   "errors": { "company_id": ["The company ID is required."] }
> }
> ```
>
> ⚠️ 422 Validation Error — `per_page` outside `1`–`100` or `page` below `1` (translated with `Accept-Language`: `en`, `fr`, `ar`):
>
> ```json theme={null}
> {
>   "message": "The per page field must not be greater than 100.",
>   "errors": { "per_page": ["The per page field must not be greater than 100."] }
> }
> ```

> [tab] 500
>
> ❌ 500 Internal Server Error — the exception is logged; the raw message is only returned when `APP_DEBUG=true`
>
> ```json theme={null}
> { "message": "Unable to load the preview. Please try again later." }
> ```

> Response shapes are produced by `app/Services/MarketplacePreviewService.php`. When you extend them, add fields to the whitelist there — never eager-load `quiz_questions` or `chapters.lessons` with `content` 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.

***

## Preview a training (marketplace-safe)

`GET /preview-training/{idOrSlug}`

The marketplace version of [Retrieve a training](#retrieve-a-training), built for the training preview page (`/student-training-catalog/student-training-preview/:id`). Returns the same card as [Preview trainings](#preview-trainings-marketplace-safe) plus the **curriculum outline** and the **reviews**:

* `chapters[]` — chapter labels and positions, `lessons_count`, each chapter's `lessons[]` (`id`, `label`, `position`, `is_free_preview`, `content`), `quizzes[]` — the outline of **every published** quiz of the chapter, in creation order (label, slug, duration, attempts, `passing_percentage`, `quiz_questions_count`; `[]` when none) — and the deprecated `quiz` (= the first of `quizzes`, or `null`, kept for older app builds). `quizzes` was added on 19/10/2026, when chapters started holding several quizzes.
* **Exactly one lesson carries its `content`**: the first lesson of the first chapter (or, if that chapter has no lessons, of the first chapter that has some). It is flagged `is_free_preview: true`; every other lesson has `content: null`. This mirrors the free-lesson rule of the preview page.
* `reviews[]` — `id`, `rating`, `comment`, dates, and a minimal `user` block (`id`, names, avatar).
* No quiz ever includes its questions.

Authorization: `viewAny` on `Training`. The company is taken from the caller's token (super-admins may pass `company_id` to target another company). Every call increments `views_count`, like `GET /training/{id}`.

### Path parameters

| Name | Type | Required | Description |
| :- | :- | :- | :- |
| `idOrSlug` | integer or string | Yes | Training 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-training/12
GET {{base_url}}/api/preview-training/intro-to-pmp
Accept: application/json
Authorization: Bearer {{token}}
```

### Responses

> [tab] 200
>
> ✅ 200 OK — the card fields of the list endpoint, then `chapters` and `reviews`
>
> ```json theme={null}
> {
>   "id": 12,
>   "label": "Intro to PMP",
>   "slug": "intro-to-pmp",
>   "description": "<p>Everything you need to pass the PMP exam.</p>",
>   "level": "Beginner",
>   "duration": 120,
>   "price": 199.99,
>   "discounted_price": 149.99,
>   "final_price": 149.99,
>   "is_public": 1,
>   "views_count": 43,
>   "average_rating": 4.5,
>   "total_ratings": 12,
>   "total_active_sales": 9,
>   "total_chapters_count": 2,
>   "total_lessons_count": 3,
>   "image": { "id": 22, "type": 2, "url": "/storage/files/pmp.png" },
>   "video": null,
>   "badge_logo": null,
>   "instructor": { "id": 5, "first_name": "Ali", "last_name": "Ben", "fullname": "Ali Ben", "image_id": 9, "image": { "id": 9, "type": 2, "url": "/storage/files/ali.png" }, "bio": "PMP trainer", "hide_public_profile": false, "public_profile_about_me": "Certified PMP trainer since 2015.", "public_profile_skills": "PMP, Agile", "public_profile_verification": 1 },
>   "categories": [{ "id": 3, "label": "Project Management" }],
>   "tags": [{ "id": 7, "label": "Certification" }],
>   "quiz": { "id": 31, "training_id": 12, "chapter_id": null, "label": "Final quiz", "slug": "final-quiz", "duration": 30, "break_interval": null, "break_duration": null, "max_attempts": 3, "passing_percentage": 75, "is_published": 1, "quiz_questions_count": 10 },
>   "is_owned": false,
>   "chapters": [
>     {
>       "id": 40,
>       "training_id": 12,
>       "label": "Chapter 1 — Introduction",
>       "position": 1,
>       "lessons_count": 2,
>       "created_at": "2026-04-01 09:30:00",
>       "updated_at": "2026-04-01 09:30:00",
>       "lessons": [
>         { "id": 101, "chapter_id": 40, "label": "Welcome", "type": "video", "position": 1, "is_free_preview": true, "content": "<p>Welcome to the course! This lesson is free.</p>" },
>         { "id": 102, "chapter_id": 40, "label": "The PMP framework", "type": "document", "position": 2, "is_free_preview": false, "content": null }
>       ],
>       "quizzes": [
>         { "id": 30, "training_id": null, "chapter_id": 40, "label": "Chapter 1 quiz", "slug": "chapter-1-quiz", "duration": 15, "break_interval": null, "break_duration": null, "max_attempts": 3, "passing_percentage": 75, "is_published": 1, "quiz_questions_count": 2 },
>         { "id": 33, "training_id": null, "chapter_id": 40, "label": "Chapter 1 recap", "slug": "chapter-1-recap", "duration": 10, "break_interval": null, "break_duration": null, "max_attempts": 0, "passing_percentage": 60, "is_published": 1, "quiz_questions_count": 5 }
>       ],
>       "quiz": { "id": 30, "training_id": null, "chapter_id": 40, "label": "Chapter 1 quiz", "slug": "chapter-1-quiz", "duration": 15, "break_interval": null, "break_duration": null, "max_attempts": 3, "passing_percentage": 75, "is_published": 1, "quiz_questions_count": 2 }
>     },
>     {
>       "id": 41,
>       "training_id": 12,
>       "label": "Chapter 2 — Scope management",
>       "position": 2,
>       "lessons_count": 1,
>       "created_at": "2026-04-01 09:31:00",
>       "updated_at": "2026-04-01 09:31:00",
>       "lessons": [
>         { "id": 103, "chapter_id": 41, "label": "Scope basics", "type": "document", "position": 1, "is_free_preview": false, "content": null }
>       ],
>       "quizzes": [],
>       "quiz": null
>     }
>   ],
>   "reviews": [
>     {
>       "id": 8,
>       "user_id": 88,
>       "rating": 4.5,
>       "comment": "Clear and well paced.",
>       "created_at": "2026-04-12 18:02:11",
>       "updated_at": "2026-04-12 18:02:11",
>       "user": { "id": 88, "first_name": "Sarra", "last_name": "Trabelsi", "image_id": null, "image": null }
>     }
>   ]
> }
> ```

> [tab] 401
>
> ⚠️ 401 Unauthorized
>
> ```json theme={null}
> { "message": "Unauthorized" }
> ```

> [tab] 403
>
> ⚠️ 403 Forbidden — the role lost the `trainings-viewAny` permission (`61`)
>
> ```json theme={null}
> { "message": "This action is unauthorized." }
> ```
>
> ⚠️ 403 Forbidden — `?company_id=` of another company (non super-admin)
>
> ```json theme={null}
> { "message": "Unauthorized to fetch data of other companies." }
> ```

> [tab] 404
>
> ⚠️ 404 Not Found — unknown id / slug, non-public, archived, soft-deleted, or belongs to another company
>
> ```json theme={null}
> { "message": "Training not found." }
> ```

> [tab] 500
>
> ❌ 500 Internal Server Error — the exception is logged; the raw message is only returned when `APP_DEBUG=true`
>
> ```json theme={null}
> { "message": "Unable to load the preview. Please try again later." }
> ```

***

## Update a training

`POST /training/{id}`

Updates an existing training. The training's instructor is then copied onto **every quiz of the training**: its final quiz and, since 19/10/2026, all its chapter quizzes (chapter quizzes are stored with `training_id = null`, so before that date they kept their previous instructor). A `changed_instructor` history row is written for each of those quizzes.

### Body parameters

Same as **Create**, with the following differences:

* `revenue_percentage` is **required** here (between `0` and `100`).
* `slug` is **optional**. If provided, it is forced to lowercase and must remain unique. This is where the slug is edited after creation (the Flutter editor unlocks it on the Update training screen).
* `company_id` is **not** accepted — the training already owns its company.

### Example request

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

```text theme={null}
{
  "instructor_id": 5,
  "revenue_percentage": 45,
  "label": "Intro to PMP — Updated",
  "description": "Updated description",
  "level": "Intermediate",
  "duration": 150,
  "price": 229.99,
  "discounted_price": 179.99,
  "image_id": 22,
  "video_id": 33,
  "badge_logo_id": 44,
  "is_public": true,
  "slug": "intro-to-pmp-v2"
}
```

`200 OK`

```text theme={null}
{ "message": "Training updated successfully." }
```

***

## Archive a training

`POST /training/{id}/archive`

Soft-archives the training. The record is hidden from the default catalog but not deleted.

### Example request

```text theme={null}
POST {{base_url}}/api/training/12/archive
Accept: application/json
Authorization: Bearer {{token}}
```

`200 OK`

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

***

## Restore an archived training

`POST /training/{id}/unarchive`

Restores a previously archived training. Fails with `404` if the target isn't archived.

### Example request

```text theme={null}
POST {{base_url}}/api/training/12/unarchive
Accept: application/json
Authorization: Bearer {{token}}
```

`200 OK`

```text theme={null}
{
  "message": "Training restored successfully.",
  "training": {
    "id": 12,
    "label": "Intro to PMP",
    "slug": "intro-to-pmp",
    "archived_at": null
  }
}
```

***

## Permanently delete a training

`DELETE /training/{id}/delete`

Force-deletes an **already archived** training. The training must be in the archived state, otherwise `404` is returned.

> ⚠️ This is irreversible. Always archive first, then confirm before calling this endpoint.

### Example request

```text theme={null}
DELETE {{base_url}}/api/training/12/delete
Accept: application/json
Authorization: Bearer {{token}}
```

`200 OK`

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

***

## Assign categories

`POST /training/{id}/assign_catgories_to_training`

Sets the training's category. **A training has one category since 01/11/2026**: `categories` stays an array, so older app builds keep working, but it must hold exactly one id. That id replaces the training's current category (this is a `sync`, not an append).

> ⚠️ The URL contains a typo (`catgories`) on purpose — it matches the registered route and must be used exactly as shown.

### Body parameters

| Name | Type | Required | Description |
| :- | :- | :- | :- |
| `categories` | integer\[] | Yes | Exactly **one** category id (rules `present`, `array`, `min:1`, `max:1`); it must exist in `categories`. Two or more ids → `422`. |

### Example request

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

```json theme={null}
{ "categories": [3] }
```

### Responses

`200 OK` — category `3` replaces the training's previous category

```json theme={null}
{ "message": "Categories assigned to training successfully." }
```

`422 Unprocessable Entity` — two or more ids, e.g. `{ "categories": [3, 7] }` (translated with `Accept-Language`: `en`, `fr`, `ar`). Nothing is changed.

```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 Unprocessable Entity` — empty array

```json theme={null}
{
  "message": "The categories field must have at least 1 items.",
  "errors": { "categories": ["The categories field must have at least 1 items."] }
}
```

```text theme={null}
403 { "message": "This action is unauthorized." }                            ← you may not update this training
404 { "message": "No query results for model [App\\Models\\Training] 99" }   ← unknown training id (checked after the body)
```

> [!NOTE]
>
> Trainings that had several categories before 01/11/2026 kept the **oldest** one (the first picked): the migration `2026_11_01_000000_keep_one_category_per_item` deleted the others, after writing them to the local disk under `storage/app/backups/category-pivots/{Ymd-His}.json`, and added a unique index on `category_training.training_id`. Read payloads (`GET /trainings`, `GET /training/{id}`, the preview endpoints, the student's `my_trainings[]` cards in [Auth §9](https://theteam-13fc7f49.mintlify.site/api-reference/auth.md)) keep `categories` as an array with one `{ "id", "label" }` item, or none. Quizzes and exams follow the same rule ([Quizzes & Exams §3.3](https://theteam-13fc7f49.mintlify.site/api-reference/quizzes-and-exams-endpoints.md)); the [JSON import](https://theteam-13fc7f49.mintlify.site/api-reference/trainings-export-import.md) keeps the first category label of a file.

***

## Assign tags

`POST /training/{id}/assign_tags_to_training`

Replaces the training's tag assignments with the ones supplied.

### Body parameters

| Name | Type | Required | Description |
| :- | :- | :- | :- |
| `tags` | integer\[] | Yes | Non-empty array of distinct tag IDs. Each must exist in `tags`. |

### Example request

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

```text theme={null}
{ "tags": [4, 5] }
```

`200 OK`

```text theme={null}
{ "message": "Tags assigned to training successfully." }
```

***

## List students of a training

`GET /training/{id}/students_by_training`

Returns the students who own a training, or, with `no_training=true`, the students who don't. Use it to get the emails of everyone who bought or was assigned a training.

> "Owning" means the student has an order (`type = 3`) with status **Paid Online** (`5`) or **Paid Offline** (`4`, which includes free assignments made through `POST /order/assign_trainings_to_students`), and the training's order item has `access_revoked = false`. Carts, wishlists, pending, failed, and cancelled orders don't count. Each student appears once, even with several paid orders for the same training.

### Path parameters

| Name | Type | Required | Description |
| :- | :- | :- | :- |
| `id` | integer | Yes | Training ID. |

### Query parameters

| Name | Type | Default | Description |
| :- | :- | :- | :- |
| `no_training` | boolean | `false` | When `true`, returns students (`role_id = 1`) who do **not** have an active paid order for this training. |
| `withLessons` | boolean | `false` | Only honored when `no_training=false`. Eager-loads each student's validated `lessons`. |
| `search` | string | — | Case-insensitive match on `first_name`, `last_name`, or `email`. |
| `page` | integer | `1` | Page number. |
| `per_page` | integer | `15` | Page size. Raise it (e.g. `1000`) to fetch every owner in one call. |

### Example requests

Owners of the training (with lessons):

```text theme={null}
GET {{base_url}}/api/training/12/students_by_training?withLessons=true&per_page=50
Accept: application/json
Authorization: Bearer {{token}}
```

Students who don't own the training:

```text theme={null}
GET {{base_url}}/api/training/12/students_by_training?no_training=true&search=sarra
Accept: application/json
Authorization: Bearer {{token}}
```

### Responses

The two modes return different pagination shapes.

`200 OK` — owners (`no_training=false`)

```text theme={null}
{
  "data": [
    {
      "id": 88,
      "company_id": 1,
      "role_id": 1,
      "first_name": "Sarra",
      "last_name": "Trabelsi",
      "email": "sarra@example.com",
      "fullname": "Sarra Trabelsi",
      "lessons": [
        {
          "id": 101,
          "label": "Lesson 1",
          "pivot": { "student_id": 88, "lesson_id": 101 }
        }
      ]
    }
  ],
  "total": 4,
  "current_page": 1,
  "per_page": 15
}
```

`lessons` is only present when `withLessons=true`. This mode has no `last_page`; compute it as `ceil(total / per_page)`.

`200 OK` — non-owners (`no_training=true`), standard Laravel paginator

```text theme={null}
{
  "current_page": 1,
  "data": [
    {
      "id": 91,
      "company_id": 1,
      "role_id": 1,
      "first_name": "Sarra",
      "last_name": "Ben Salah",
      "email": "sarra.bs@example.com",
      "fullname": "Sarra Ben Salah"
    }
  ],
  "first_page_url": "{{base_url}}/api/training/12/students_by_training?page=1",
  "from": 1,
  "last_page": 1,
  "last_page_url": "{{base_url}}/api/training/12/students_by_training?page=1",
  "links": [ ... ],
  "next_page_url": null,
  "path": "{{base_url}}/api/training/12/students_by_training",
  "per_page": 15,
  "prev_page_url": null,
  "to": 1,
  "total": 1
}
```

`401 Unauthorized` — missing or invalid token

```text theme={null}
{ "message": "Unauthenticated." }
```

`401 Unauthorized` — blocked user

```text theme={null}
{ "message": "Your account has been Banned. Please contact administrator." }
```

`500 Internal Server Error` — training not found (the controller catches the model exception, so this is **not** a `404`)

```text theme={null}
{ "message": "No query results for model [App\\Models\\Training] 999" }
```

`500 Internal Server Error` — authenticated user has no company

```text theme={null}
{ "message": "Company ID is required but not found." }
```

> Need a downloadable list of owner emails instead? `POST /users/export-owners` with `{ "training_id": 12 }` returns a CSV (`user_id`, `user_mail`) using the same ownership rules, limited to your company. See [Export Owners CSV](https://theteam-13fc7f49.mintlify.site/api-reference/users.md#20-export-owners-csv).
>
> Need the owner emails as JSON (array + comma-separated string) by training id **or slug**? `GET /users/owners-emails?training_id=12` or `?training_slug=intro-to-pmp` — see [Owner Emails (JSON)](https://theteam-13fc7f49.mintlify.site/api-reference/users.md#21-owner-emails-json).

***

## Postman setup

1. **Environment variables.** Create a Postman environment with:
   * `base_url` — your API root, e.g. `http://localhost:8000`
   * `token` — the Sanctum token returned by `POST /auth/login`
2. **Collection-level headers.** Set these once so every request inherits them:
   * `Accept: application/json`
   * `Authorization: Bearer {{token}}`
3. **Bodies.**
   * JSON bodies → **Body → raw → JSON**
   * File uploads → **Body → form-data** (let Postman set `Content-Type` itself)
4. **Smoke-test flow.** A quick end-to-end check:
   1. `POST /training` — create
   2. `GET /training/{id}` — verify
   3. `POST /training/{id}` — update
   4. `POST /training/{id}/assign_catgories_to_training` — set the category (one id)
   5. `POST /training/{id}/archive`
   6. `POST /training/{id}/unarchive`
   7. `DELETE /training/{id}/delete`


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