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
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.
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 ofquizzes(ornull), kept for older app builds. Readquizzesin 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, ornull. It is never a chapter quiz, even for an older row that carries both atraining_idand achapter_id.
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.
Example request
Responses
200 OK
"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). Anis_publicparameter is accepted for compatibility but ignored. - Archived and soft-deleted trainings are never returned.
instructoronly carriesid, names, avatar,bioand thepublic_profile_*fields, and those arenullwhile the instructor keepshide_public_profile = true.is_ownedistruewhen 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
- 401
- 403
- 422
- 500
✅ 200 OK — standard Laravel paginator (
data, current_page, per_page, total, last_page, next_page_url, …)Response shapes are produced byapp/Services/MarketplacePreviewService.php. When you extend them, add fields to the whitelist there — never eager-loadquiz_questionsorchapters.lessonswithcontentinto a preview.
catalog_counts(page 1 only). The size of each student Marketplace catalog of the company:trainings(public trainings),exams(published standalone exams) andinstructors(users with role2). 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 ofGET /preview-trainings,GET /preview-examsandGET /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’slessons[](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 deprecatedquiz(= the first ofquizzes, ornull, kept for older app builds).quizzeswas 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 flaggedis_free_preview: true; every other lesson hascontent: null. This mirrors the free-lesson rule of the preview page. reviews[]—id,rating,comment, dates, and a minimaluserblock (id, names, avatar).- No quiz ever includes its questions.
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
- 401
- 403
- 404
- 500
✅ 200 OK — the card fields of the list endpoint, then
chapters and reviewsUpdate 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_percentageis required here (between0and100).slugis 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_idis 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 throughPOST /order/assign_trainings_to_students), and the training’s order item hasaccess_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):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-ownerswith{ "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=12or?training_slug=intro-to-pmp— see Owner Emails (JSON).
Postman setup
- Environment variables. Create a Postman environment with:
base_url— your API root, e.g.http://localhost:8000token— the Sanctum token returned byPOST /auth/login
- Collection-level headers. Set these once so every request inherits them:
Accept: application/jsonAuthorization: Bearer {{token}}
- Bodies.
- JSON bodies → Body → raw → JSON
- File uploads → Body → form-data (let Postman set
Content-Typeitself)
- Smoke-test flow. A quick end-to-end check:
POST /training— createGET /training/{id}— verifyPOST /training/{id}— updatePOST /training/{id}/assign_catgories_to_training— set the category (one id)POST /training/{id}/archivePOST /training/{id}/unarchiveDELETE /training/{id}/delete