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>.
Fancy UI Curriculum
From primitives to agent-driven surfaces.
Course player
The real <CoursePlayer> from classroom — modules, lessons, progress and the graded test.
Human+ UX
Surfaces people and agents share.
0/1 lessons
0%
What is Human+ UX?
~6 min
Certificate
An issued certificate and its verification code. Literal-coloured on purpose, so it looks the same on every theme.
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.
| Learner | Progress | Score | Status |
|---|---|---|---|
DW Dana Whitfield | 94% | Certified | |
MI Marcus Iyer | — | In progress | |
SB Sofia Bergman | — | At risk |
The code
// 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)}
/>// 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();
}// 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',
};
}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
Install the engine and the surface
laravel-coursesowns the models — curriculum, courses, lessons, tests, attempts, certificates.classroomis 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/classroomModel 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.
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.
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.
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.
