Quizzes, Exams, Question Bank, Import / Export Guide
This file documents the data model and the API surface that powers Quizzes and Exams on the platform. It covers the Eloquent models (QuizQuestion, QuizQuestionOption, QuizQuestionOptionItem, QuizQuestionType, QuizStudentAttempt) and the CRUD/management controller (QuizzesController).
Bulk import / export (export quiz/exam JSON, export-all ZIP archives, destructive question import) is documented separately in Quizzes & Exams export / import. The student attempt lifecycle (start / finish / save score / show answers) is documented in Quizzes & Exams progress tracking.
v4 — contract change of 01/11/2026: one category per quiz / exam.
categories stays an array on the four write endpoints (§5.3–5.6), so older app builds keep working, but it now holds exactly one id: two or more answer 422 (§3.3). Read payloads keep the categories array, with one item (or none). The list filters (categories=[2,5] on §5.1 and §5.10) still take several ids.v3 — contract change of 19/10/2026.
- A chapter holds any number of quizzes. Creating a quiz on a chapter, or moving one into it, no longer turns the chapter’s existing quiz into an exam. Only the training’s final quiz is still one per training (§3, §5.9).
slugis optional and server-generated on all four write endpoints (§3.2). Every create / update response now returns it. Before,POST /quizrequired it andPOST /quiz_with_questions_and_optionsignored 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
422instead of500. - Read payloads: every chapter now carries a
quizzesarray plus the deprecatedquiz(= the first one). See Chapters and Retrieve a training. quiz_questionsis optional on 5.6: left out, the questions are kept.
Table of Contents
- Overview
- Base URL and Headers
- 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
- 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
- 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)
- Export / Import Endpoints → moved to Quizzes & Exams export / import
- Business Rules & Edge Cases
- History Tracking Actions
- Related Files
1. Overview
AQuiz 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 withtraining_id = nulland 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_idand nochapter_id. The course-level quiz that covers the whole training rather than one chapter. - Exam — has neither
training_idnorchapter_id(bothNULL). Standalone, sold on its own with its ownpriceand instructorrevenue_percentage.
- A quiz (either kind) is learning content inside a course, never sold on its own. Its
revenue_percentageis force-set to0(the instructor is paid through the training), and itsinstructor_idmust match the parent training’s instructor. Every quiz and exam has a uniqueslug(generated by the backend when you don’t send one, §3.2), but only an exam’s slug is recorded in a user’sowned_exams— quizzes are never added to ownership because they cannot be purchased. - An exam is the only sellable variant:
pricebecomes required (required_without_all:training_id,chapter_id), and it inherits the instructor’sdefault_revenue_percentagewhen 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 nullstraining_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.
2. Base URL and Headers
Base API URL:company_idmust be passed explicitly on most endpoints and is validated against thecompaniestable.App\Http\Middleware\CompanyPolicyMiddlewareenforces that the value matches the authenticated user’s owncompany_id(super admins bypass this check). The only exception is the exam listing endpoint, which resolvescompany_iddirectly fromauth()->user()->company_idand does not require it in the request.
3. Concepts: Quiz vs. Exam
When you create a final quiz for a training that already has one (or move a quiz onto that training as its final quiz), the existing final quiz is silently converted to an exam through QuizzesController@turnQuizToExam and unpublished. This is intentional — see §5.9. Chapter quizzes never trigger it: a chapter keeps every quiz you add to it (before 19/10/2026 the chapter’s previous quiz was converted the same way).
3.1 Where a quiz is attached (checked before any write)
The four write endpoints (§5.3–5.6) decide what the row becomes fromchapter_id and training_id, and check everything before anything is written. Only then do they open one database transaction (convert the previous final quiz if needed, save the quiz, sync categories, write the questions, add the history row). If something fails inside that transaction, nothing is kept and the request answers 500.
“The company” is the
company_id you send on create (§5.3, §5.5) and the quiz’s own company on update (§5.4, §5.6). An archived chapter or training is accepted; a soft-deleted one gets the same errors.chapter_id / errors.training_id answer.
Order of the checks:
- Field validation (
422with anerrorsobject). instructor_idpresent → else422 { "message": "Please provide an instructor." }(§5.6 falls back to the quiz’s current instructor).- Attachment (table above) →
422witherrors.chapter_idorerrors.training_id. - Instructor and revenue →
422 { "message": "Instructor mismatch: ..." }(quiz) or422 { "message": "Please contact your platform admin to set the default revenue percentage value." }(exam).
422 Instructor mismatch had already created the quiz and converted the old one.
The 422 bodies of step 3:
3.2 Slug (server-generated)
slug is optional on all four write endpoints (POST /quiz, POST /quiz/{id}, POST /quiz_with_questions_and_options, POST /quiz/update_quiz_with_questions_and_options/{id}), for quizzes and exams alike — the same model as training slugs.
- Create, slug omitted or empty → the backend builds one from the
label(Quiz::generateUniqueSlug(), run by the model’ssavinghook): 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 toexamfor an exam andquizfor a chapter / final quiz. - Slug sent → trimmed and lowercased, then it must match
^[a-z0-9()\-,._=\[\]:]+$, be at most 255 characters and be unique inquizzes(on update, the quiz’s own slug does not count) → else422 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 isNULLor empty — mostly those created throughPOST /quiz_with_questions_and_options, which ignoredslugbefore 19/10/2026. It leavesupdated_atuntouched and itsdown()does nothing.
3.3 One category per quiz / exam (since 01/11/2026)
A quiz or an exam has one category, like a training (Trainings → Assign categories). The four write endpoints (POST /quiz, POST /quiz/{id}, POST /quiz_with_questions_and_options, POST /quiz/update_quiz_with_questions_and_options/{id}) keep the categories array, so older app builds still work, but validate it with required|array|min:1|max:1 (Category::REQUIRED_SINGLE_RULE):
- One id (
"categories": [5]) → saved withsync: on update, the id you send replaces the quiz’s current category. - Two or more ids →
422onerrors.categories(body below), before anything is written. The message is translated from theAccept-Languageheader (en,fr,ar; anything else falls back toen). - Empty array or no
categories→422onerrors.categories, as before. - Read payloads keep the array:
GET /quizzes,GET /quiz/{id},GET /preview-exams,GET /preview-exam/{idOrSlug}and the student’smy_exams[]cards (Auth §9) returncategoriesas a list of{ "id", "label" }with one item, or none for a row that never had a category. - Filters are unchanged:
categories=[2,5]on 5.1 and 5.10 still takes several ids and returns the rows whose category is one of them. - Existing rows: the migration
2026_11_01_000000_keep_one_category_per_item(php artisan migrate) kept the oldest category of every quiz / exam that had several (lowest pivot id: the one picked first) and deleted the others, after writing them to the local disk understorage/app/backups/category-pivots/{Ymd-His}.json(no file when nothing had to go). It then added a unique index oncategory_quiz.quiz_id, so the database itself refuses a second category. Itsdown()drops the index only: put a removed category back by hand from the backup file.
4. Data Model
4.1 Quiz (parent)
Not redefined in this file — seeapp/Models/Quiz.php. The fields most relevant to this doc are:
isQuiz() (has training_id or chapter_id), isExam() (both null), onlyArchived().
4.2 QuizQuestion
File:app/Models/QuizQuestion.php
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 pivotattempt, quiz_id, quiz_question_option_idquiz_student_attempts()—belongsToMany(QuizStudentAttempt::class, 'quiz_student_answers', ...)with pivotattempt, quiz_question_option_id
4.3 QuizQuestionType
File:app/Models/QuizQuestionType.php
label. Relation: quiz_questions() — hasMany(QuizQuestion::class).
Behavioral rules tied to type:
- For
DRAG_AND_DROP_ID(4), eachQuizQuestionOptionmust carryquiz_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: oneis_correct = true, oneis_correct = false. - For
SINGLE_ANSWERexactly one option hasis_correct = true; forMULTIPLE_ANSWERone or more do.
4.4 QuizQuestionOption
File:app/Models/QuizQuestionOption.php
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
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.
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 pivotquiz_question_option_id, quiz_question_option_item_idquiz_question_options()— same join throughquiz_question_option_idquiz_question_option_items()— same join throughquiz_question_option_item_id
quiz_student_answers is what stores the student’s actual answers per attempt. Each row links: attempt × question × option (and, for drag & drop, × option item).
This model is consumed by the progress-tracking controller, documented in Quizzes & Exams progress tracking.
4.7 Entity Relationship Diagram
5. QuizzesController Endpoints
Controller:app/Http/Controllers/QuizzesController.php. All routes are under the QuizzesController group in routes/api.php.
5.1 List Quizzes / Exams
GET /api/quizzes
Authorize: viewAny on Quiz.
Query Parameters:
Example Requests:
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:
5.2 Show Quiz
GET /api/quiz/{id} (numeric id only).
Authorize: view on the quiz.
⚠️ Answer data is fully exposed here. WithQuery Parameters:WithQuizQuestions=true(the default), the response includes each option’sis_correctflag, everyexplanation/tip, and the intact drag-&-drop grouping (quiz_question_options[].quiz_question_option_items).QuizPolicy::viewlets through super-admins, admins, holders ofQUIZZES_VIEW(83) and students who own the exam — and every company seeds83to the student role by default (PermissionRole::assign_permissions_to_student), so today any logged-in student can read the answer key of any exam in their company by id, bought or not. Use this endpoint for authoring / admin screens only. The student exam preview page must call 5.11 Preview an Exam, which never returns questions. The instructor listingGET /api/instructors/{id}/quizzesalready strips answers (see Instructor API doc §7).
Student callers also get anExample Requests:attemptsblock (19/10/2026):{ "used", "allowed", "remaining", "reset_count", "best_score_percentage", "passed", "last_attempt_at" }for the calling student on this quiz — the ceilingstart_student_quizenforces (allowed = max_attempts * (reset_count + 1),null= unlimited). Staff callers do not get it.questions_countis always present (counted in SQL whenWithQuizQuestions=false).
WithQuizQuestions=true (default):
5.3 Create Quiz (metadata only)
POST /api/quiz
Authorize: create on Quiz.
Request Fields:
Example A — Final quiz linked to a training (no price required, slug generated):
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:
{ "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):
{ "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):
python-data-structures-2026.
Example D — two categories (refused since 01/11/2026):
422, and nothing is written. Send one id ("categories": [3]); the same answer comes from §5.4–5.6.
Behavior (every check runs before anything is written, see §3.1):
- Validates the fields (
422witherrors),categoriesincluded: exactly one id (§3.3). Theslug, when sent, is lowercased first. - Returns
422 Please provide an instructor.ifinstructor_idis missing. - Resolves the attachment: chapter quiz (
training_idstored asnull), final quiz or exam, and checks that the chapter / training belongs tocompany_id(422 errors.chapter_id/errors.training_id). - Quiz (chapter or final):
instructor_idmust be the training’s instructor (422 Instructor mismatch),revenue_percentageis forced to0. Exam: the instructor must have adefault_revenue_percentage(422asking the admin to set it), used when the request sends no positiverevenue_percentage. - 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 thecreatedhistory row. A chapter’s existing quizzes are never touched.
slug is the stored slug: the one you sent (lowercased) or the generated one.
Error Responses:
All the bodies are spelled out in §3.1, §3.2 and §3.3.
5.4 Update Quiz (metadata only)
POST /api/quiz/{id} (numeric id).
Same validation rules as store, minus company_id, and the same checks in the same order (§3.1), using the quiz’s own company for the chapter / training ownership check. Every field is overwritten from the request, so send the full form.
- Moving a quiz into a chapter (
chapter_id) keeps the chapter’s other quizzes untouched (since 19/10/2026; before, the chapter’s existing quiz was converted into an exam). The quiz is stored withtraining_id = null. - Moving a quiz onto a training as its final quiz (
training_idwithoutchapter_id) still converts the training’s current final quiz into an unpublished exam (turnQuizToExam, §5.9). slugis 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.categoriestakes 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.
company_id is not accepted.
Example — update exam 5 and rename its slug:
slug in the body, the response carries the unchanged slug ("advanced-python-exam").
Renaming an exam’s slug does not rewrite the owned_exams arrays of the users who already bought it (ownership is slug-based). Change the slug of a sold exam with care.
Error Responses: same as §5.3 (the ownership checks use the quiz’s company), plus:
5.5 Create Quiz With Questions & Options
POST /api/quiz_with_questions_and_options
Authorize: create on Quiz.
Combines quiz metadata + all questions and options in one request. Same rules as §5.3: optional slug (generated from the label when omitted, §3.2), any number of quizzes per chapter, the same checks before any write (§3.1). The quiz, its category, questions, options, drag-and-drop items and the history row are written in one transaction: if a question fails to save, no quiz is left behind.
Before 19/10/2026 this endpoint ignoredRequest Fields (quiz-level): same as §5.3, including the optionalslugand saved the quiz withslug = NULL. Those rows are filled by the backfill migration (§3.2).
slug and categories with exactly one id (§3.3).
Additional question-level fields:
Full Example — all 4 question types (Standalone Exam):
{ "quiz_id": 8, "slug": "javascript-fundamentals-full-exam", "message": "Quiz created successfully." }.
Minimal Example — Final quiz of a training, 2 questions only:
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):
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_questionsis 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.slugis optional: omitted or empty keeps the stored slug; sent → lowercased, checked and unique (§3.2).instructor_idis optional here: when left out, the quiz’s current instructor is kept (and checked against the training).categoriestakes 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
quizrelation returns the newest (highestid). is_publishedis 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,priceordiscounted_priceis saved asnull.
company_id; quiz_questions and instructor_id are optional.
Example — replace all questions on quiz 8:
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):
{ "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 — whenquiz_questionsis sent, this is a full replace, not a diff: every question you leave out is deleted. To update metadata only, leavequiz_questionsout (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.
Unarchive —
POST /api/quiz/{id}/unarchive
Authorize: restore on a quiz fetched with onlyArchived(). History: unarchived.
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.
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? UseQuery Parameters:GET /api/users/owners-emails?exam_id=5/?exam_slug=advanced-python-exam— see Owner Emails (JSON).
Example A — Students who purchased this exam:
5.9 Helpers: turnQuizToExam, auto_change_instructors_for_quizzes
turnQuizToExam(Quiz $quiz)— clearstraining_idandchapter_id, setsis_published = false, requires aninstructor_id(throws otherwise), requires the instructor’sdefault_revenue_percentage > 0(throws otherwise), defaultsrevenue_percentageto the instructor’s default if it’s empty, saves. History:converted_to_exam. Used internally bystore/update/create_quiz_with_questions_and_optionsto 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 answers500with 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 throughtraining_id, a chapter quiz through its chapter (since 19/10/2026). Returns400if a quiz is attached to no training. Used byTrainingsController@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’straining_idfollowed, so chapter quizzes kept the old instructor. History:changed_instructor.
5.10 Preview Exams (marketplace-safe)
GET /api/preview-exams
The marketplace version of 5.1 List Quizzes / Exams, built for the student exam catalog (/student-exam-catalog). Same authorization (viewAny on Quiz, held by students by default), same paginator envelope, but every row is built from a whitelist:
Rules enforced server-side, whatever the query string says: standalone exams only (
training_id and chapter_id both null), published only (is_published = 1), never archived / soft-deleted. is_exam, is_quiz and is_published parameters are accepted for compatibility and ignored. is_owned is true when the caller has a paid, non-revoked order for the exam. training, training_label and training_slug are always null (kept for parity with 5.1). views_count is the number of times the exam’s marketplace page was opened (incremented by 5.11 only; listing exams never counts as a view).
Query Parameters:
Example Requests:
instructor public-profile fields are null while the instructor keeps hide_public_profile = true. Response shapes are produced by app/Services/MarketplacePreviewService.php — extend the whitelist there, never eager-load quiz_questions into a preview.
catalog_counts (page 1 only). The size of each student Marketplace catalog of the company: trainings (public trainings), exams (published standalone exams) and instructors (users with role 2). They are counted without the request’s own filters (search, categories, price, …), leave archived and deleted rows out, and are cached per company for 5 minutes; the cache is cleared as soon as a training, exam or instructor is created, published / unpublished, archived, deleted or restored (app/Services/MarketplaceCountsService.php, MarketplaceCountsObserver). The same object comes on page 1 of GET /preview-trainings, GET /preview-exams and GET /instructors, so the app fills the three Marketplace tab counts from whichever catalog loads first, with no extra request. Pages 2 and later do not carry it.
5.11 Preview an Exam (marketplace-safe)
GET /api/preview-exam/{idOrSlug}
The marketplace version of 5.2 Show Quiz, built for the exam preview page (/student-exam-catalog/quiz-preview/:quizId). Returns the same card as 5.10 plus reviews[] (id, rating, comment, dates, minimal user block). The questions are never included — only quiz_questions_count (the key the Flutter Quiz model already maps to questionsCount).
Each successful call counts one view: views_count is incremented before the response is built, so the returned value already includes this visit (like GET /api/preview-training/{idOrSlug} does for trainings). The counter is updated without touching the exam’s updated_at.
Authorization: viewAny on Quiz. The company is taken from the caller’s token (super-admins may pass company_id). Standalone, published exams only: a quiz attached to a training or chapter, an unpublished exam, an archived exam or another company’s exam all answer 404.
Example Requests:
6. Export / Import Endpoints
Moved to its own file. The export / import endpoints — export quiz JSON, export exam JSON, export-all-quizzes ZIP, export-all-exams ZIP, and the destructive question/option import (with images) — are now documented separately in Quizzes & Exams export / import.
7. Business Rules & Edge Cases
- Instructor is always required.
store,updateandcreate_quiz_with_questions_and_optionsreturn422 Please provide an instructor.if it is missing — even though the validator marks itnullable.update_quiz_with_questions_and_optionsfalls back to the quiz’s current instructor.turnQuizToExamthrows (→500) when the quiz it converts has none. - Instructor must match parent training. When
training_idorchapter_idis set, the quiz’sinstructor_idmust equal the training’sinstructor_id(for a chapter quiz, the chapter’s training). Otherwise422 Instructor mismatch. - Every check runs before anything is written (§3.1), and the write is one transaction. Since 19/10/2026 a
422never 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. Atraining_idsent withchapter_idmust be the chapter’s own training (422otherwise) and is dropped. A training’squizrelation (its final quiz) never returns a chapter quiz, even an older row that carries both ids. revenue_percentageis forced to0for quizzes linked to training/chapter (the instructor is already paid via the training’s split).- For standalone exams, if
revenue_percentageis0or empty the instructor’sdefault_revenue_percentageis used. If the instructor doesn’t have one set, the request fails with422 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_questionsis 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
categoriesas a one-item array, two or more ids →422(§3.3). The pivotcategory_quizhas a unique index onquiz_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}, theGET /auth/userlogin payload, the chapter exports) carriesquizzes(all of them, creation order) and the deprecatedquiz(= the first one, ornull) for older app builds. OnGET /chapters,GET /chapter/{id}andGET /training/{id}students only get the published chapter quizzes while staff also get the drafts; the login payload andGET /preview-training/{idOrSlug}only ever list published ones; the exports carry every quiz. See Chapters and Trainings. students_by_examis for exams only — it filters withwhereNull('training_id')->whereNull('chapter_id').- The student marketplace must use the
preview-*endpoints (5.10 / 5.11 for exams, andGET /api/preview-trainings/GET /api/preview-training/{idOrSlug}for trainings).GET /api/quizzesexposes the instructor’s email and revenue values, andGET /api/quiz/{id}exposes the full answer key to any student holdingQUIZZES_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 loadquiz_questionsor lessoncontent, and select only safe instructor columns. Extend them throughapp/Services/MarketplacePreviewService.php.
8. History Tracking Actions
Calls toinsert_in_history_table($action, $resource_id, $table) made by these controllers:
9. Related Files
app/Models/Quiz.php— parent model (label, slug, price, scopesisQuiz,isExam,Archivable,SoftDeletes;SLUG_PATTERN,generateUniqueSlug()and thesavinghook that fills a missing slug).app/Models/Chapter.php—quizzes()(every quiz of the chapter,idorder) and the deprecatedquiz();relationsToArray()addsquiz= the first ofquizzesto the JSON.app/Models/Training.php—quiz()(the final quiz only:chapter_idnull, newest first) andallQuizzesQuery()(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_RULEandsingleCategoryMessages()(themessages.one_category_onlytext inresources/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.phpguards the one-category rule (§3.3).app/Models/QuizQuestion.phpapp/Models/QuizQuestionType.phpapp/Models/QuizQuestionOption.phpapp/Models/QuizQuestionOptionItem.phpapp/Models/QuizStudentAttempt.phpapp/Http/Controllers/QuizzesController.phpapp/Http/Controllers/Export_Import_Quizzes_Exams_Controller.php— see Quizzes & Exams export / importapp/Http/Controllers/Quizzes_Exams_Progress_Tracking_Controller.php— see Quizzes & Exams progress trackingroutes/api.php—QuizzesController,Export_Import_Quizzes_Exams_Controllergroupsapp/Traits/HandlesStudentListing.php,app/Traits/QuizzesAndTrainingsListing.php— pagination/search helpers- Archive, delete & restore flow — archive / restore / hard-delete semantics
- Instructor dashboard —
default_revenue_percentagesetter