All use cases
Build this app

An online course and coaching platform

Curriculum, lessons, graded tests and certificates — with the selling side already attached.

The problem

A course platform is two apps wearing one coat. There is the learning side — curriculum, lessons, progress, grading, certificates — and the commercial side, which is a subscription app with different nouns. Most builds do one well and bolt the other on, which is why so many course sites can tell you what you bought but not where you got to.

Progress is the part that looks trivial and is not. It is per learner, per lesson, resumable, and it decides whether someone is certified — so it is also the thing you get support tickets about. Grading adds a second trap: an attempt that has not been marked yet is not a failure, and a UI that cannot tell those apart will tell a learner they failed a test nobody has looked at.

What you are building

Live surfaces, not screenshots — every one composed from the same components you would install. laravel-courses owns courses, lessons, attempts and certificates; classroom is the React surface over it. Access is gated by the same feature layer that runs billing.

Curriculum overview

A curriculum and its courses, with enrollment state — classroom's <CurriculumOverview>.

Curriculum4 courses
Published0 courses

Fancy UI Curriculum

From primitives to agent-driven surfaces.

Course player

The real <CoursePlayer> from classroom — modules, lessons, progress and the graded test.

CourseEnrolled

Human+ UX

Surfaces people and agents share.

0/1 lessons

0%

What is Human+ UX?

~6 min

Agents and people share one surface, trading control fluidly.

Certificate

An issued certificate and its verification code. Literal-coloured on purpose, so it looks the same on every theme.

CertificateIssued

Certificate of Completion

Course Completion

This is to certify that

Learner

has successfully completed the program.

Issued 8/1/2026 · Verification FANCY-2026-0001

Cohort gradebook

Composed from <Table>, <Avatar> and <Progress> — the coach-facing view classroom does not ship.

Cohort12 learners
LearnerProgressScoreStatus
DW
Dana Whitfield
94%Certified
MI
Marcus Iyer
In progress
SB
Sofia Bergman
At risk

The code

The player is one controlled componenttsx
// classroom ships the whole learner surface -- modules, lessons, progress and
// the graded test -- as controlled components. You supply the data and the
// handlers; there is no course-player UI to rebuild.
<CoursePlayer
  course={course}
  enrollment={enrollment}
  completedLessonIds={completedLessonIds}
  onMarkLessonComplete={(lessonId) => router.post(`/lessons/${lessonId}/complete`)}
  onStartAttempt={() => api.post(`/tests/${test.id}/attempts`)}
  onSubmitAttempt={(answers) => api.post(`/attempts/${attempt.id}/submit`, answers)}
/>
Progress belongs to (learner, lesson)php
// Not to a route and not to a video player -- that is what makes it resumable,
// and what makes certification a query rather than a guess.
public function complete(Request $request, Lesson $lesson): RedirectResponse
{
    $request->user()->progress()->updateOrCreate(
        ['lesson_id' => $lesson->id],
        ['completed_at' => now(), 'seconds' => $request->integer('seconds')],
    );

    return back();
}
Ungraded is not failedphp
// An attempt with no score yet is AWAITING GRADING. Collapsing that into a
// boolean pass/fail is how a learner gets told they failed a short-answer test
// nobody has marked. The component renders the three states separately, so the
// model has to keep them separate too.
public function getStatusAttribute(): string
{
    return match (true) {
        $this->graded_at === null => 'awaiting_grading',
        $this->score >= $this->test->pass_mark => 'passed',
        default => 'failed',
    };
}
Gate the course on the subscriptionphp
use ParticleAcademy\Fms\Facades\FMS;

// The learning side asks the SAME question the billing side answers, so an
// expired subscription closes the lessons without a second source of truth --
// and without the content staying reachable by URL.
'features' => [
    'cohort-coaching' => [
        'name'    => 'Live cohort coaching',
        'type'    => 'boolean',
        'enabled' => fn ($user) => $user->subscribedToProduct('coaching'),
    ],
],

abort_unless(FMS::canAccess('cohort-coaching'), 403);

How to solve it

  1. Install the engine and the surface

    laravel-courses owns the models — curriculum, courses, lessons, tests, attempts, certificates. classroom is the React surface over them, so the learner UI is not a rebuild.

    Run thisbash
    composer require particle-academy/laravel-courses && npm i @particle-academy/classroom
  2. Model progress per learner and per lesson

    Not per page, not per video. That is what makes it resumable and what turns certification into a query.

  3. Keep ungraded separate from failed

    Short-answer questions need marking. An attempt awaiting grading is a third state, and the surface already renders it as one.

  4. Attach access to the subscription

    One feature gate answers for the paywall, the lesson list and the route, so there is no second entitlement to keep in step.

  5. Issue certificates with a verification code

    A certificate nobody can verify is a picture. The issued record carries a code, and the view renders it literal-coloured so it looks the same on every theme.