Skip to main content
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

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.
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 instead; keep this endpoint for the admin tables that need the full payload.

Query parameters

Example request

Example response — 200 OK

Error responses

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

Example request

Responses

200 OK — slug is the generated (or supplied) slug
422 Unprocessable Entity — missing instructor default
422 Unprocessable Entity — duplicate or malformed slug (only when the client sends one)

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

Example request

Responses

200 OK
Called by a student. A staff caller would also see chapter 40’s draft quizzes (with "is_published": 0) in quizzes. 404 Not Found

Preview trainings (marketplace-safe)

GET /preview-trainings The marketplace version of 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. 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

Example request

Responses

✅ 200 OK — standard Laravel paginator (data, current_page, per_page, total, last_page, next_page_url, …)
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, built for the training preview page (/student-training-catalog/student-training-preview/:id). Returns the same card as Preview trainings 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

Example requests

Responses

✅ 200 OK — the card fields of the list endpoint, then chapters and reviews

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

200 OK

Archive a training

POST /training/{id}/archive Soft-archives the training. The record is hidden from the default catalog but not deleted.

Example request

200 OK

Restore an archived training

POST /training/{id}/unarchive Restores a previously archived training. Fails with 404 if the target isn’t archived.

Example request

200 OK

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

200 OK

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

Example request

Responses

200 OK — category 3 replaces the training’s previous category
422 Unprocessable Entity — two or more ids, e.g. { "categories": [3, 7] } (translated with Accept-Language: en, fr, ar). Nothing is changed.
422 Unprocessable Entity — empty array
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) keep categories as an array with one { "id", "label" } item, or none. Quizzes and exams follow the same rule (Quizzes & Exams §3.3); the JSON import 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

Example request

200 OK

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

Query parameters

Example requests

Owners of the training (with lessons):
Students who don’t own the training:

Responses

The two modes return different pagination shapes. 200 OK — owners (no_training=false)
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
401 Unauthorized — missing or invalid token
401 Unauthorized — blocked user
500 Internal Server Error — training not found (the controller catches the model exception, so this is not a 404)
500 Internal Server Error — authenticated user has no company
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. 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).

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