particle-academy/laravel-courses
A Laravel package for building course platforms: manage courses, lessons, and curriculum structure, handle enrollments, and track learner progress with simple, framework-native APIs that drop into any Laravel app.
This release makes the package usable for in-person training, so the first thing to establish is which of the three course types you are actually running: online (lessons/tests only), in_person (attendance only), hybrid (both).
Minimal setup:
composer require <vendor>/<package>
php artisan vendor:publish --tag=<package>-migrations # if you haven't already
php artisan migrate
v0.3.0 adds the attendances table and a courses.attendance_hours column, so an existing install needs a fresh migration run before anything else works.
Where to look first, in order:
ProgressService::summary() — the single source of truth for "where is this learner". It now returns hours_required, hours_attended, hours_percent alongside the online fields. Build your UI off it rather than recomputing.EnrollmentService::enroll() — the ungated way to create an enrollment. This is the path you want in any paid flow.GatesSelfEnrollment — the contract that now guards POST enrollments. You only touch this if the default (FreeEnrollmentOnly) is wrong for you.First use case to wire end to end: take a free online course, self-enrol via POST enrollments, and confirm progress moves as lessons/tests complete. Then take an in_person course with attendance_hours (or hours) set, record an attendance row, and confirm the enrollment completes when the hours are met. That exercises both halves of the new model in ten minutes.
Selling courses: enrol after payment, not before. The intended path is explicit — once your checkout confirms, create the enrollment yourself:
// payment webhook / callback
app(EnrollmentService::class)->enroll($user, $course);
This bypasses the gate by design. Do not "fix" a broken paid flow by loosening FreeEnrollmentOnly; the gate exists so a host can't be walked around with one request.
Loosening the gate when self-enrolment in priced courses is legitimate (memberships, staff comps, internal training):
// AppServiceProvider::register()
$this->app->bind(GatesSelfEnrollment::class, MembershipEnrollment::class);
Implement the contract against your own rule (membership tier, coupon, domain allowlist). Keep the existing-enrollment short-circuit in mind: the gate is only asked for new self-enrolments.
Recording attendance from a check-in flow. Staff-facing routes are already there and admin-gated:
GET|POST enrollments/{enrollment}/attendances
DELETE enrollments/{enrollment}/attendances/{attendance}
A sign-in desk posting date + hours is the natural consumer. For programmatic use (importing a CSV from a training provider, syncing a room scanner), call the service directly so the event fires and completion is evaluated:
$progress = app(ProgressService::class);
$progress->recordAttendance($enrollment, $sessionDate, 3.5);
// dispatches AttendanceRecorded; completes the enrollment when required hours are reached
Don't hand-roll completion. Reaching required hours completes the enrollment exactly the way passing the last test does. If you need a side effect (certificate, email, CRM push), listen for AttendanceRecorded and the existing completion event rather than reimplementing the threshold check — you will get the hybrid averaging rules wrong.
Choose course type deliberately, not by accident. The type decides what counts:
| type | lessons/tests | attendance |
|---|---|---|
online |
count | n/a |
in_person |
ignored | counts |
hybrid |
count | counts, explicit attendance_hours required |
The "sell in person first, author the online material later" workflow is the reason in_person ignores lessons — drafting them must not strand existing enrollments. If lessons are genuinely meant to be completed, the course is hybrid, not in_person.
Rendering progress for mixed courses. For an enrollment with both halves, overall_percent is the average of the online percentage and the attendance percentage — two numbers with different units, averaged. Show the components (hours_attended / hours_required and the online percent) next to the aggregate, because the aggregate alone will look wrong to a learner who has done everything in one half.
POST enrollments and then takes payment, it is broken in v0.3.0 — the gate rejects the priced target before any money moves. Symptom is a 4xx on a flow that "worked yesterday". Move the enrollment to the payment confirmation step.POST enrollments is idempotent, so it won't tell you anything. An existing enrollment is returned without asking the gate. Don't use the endpoint as a "has this user paid?" probe; it will happily return a stale enrollment for a priced course.in_person courses with authored lessons now sit at 0%. This is the second breaking change and the one that will show up as a support ticket: a course that used to reach 100% via lessons now never moves. Fix is a data change — switch those rows to hybrid.hybrid with no attendance_hours never completes by itself. On hybrid, hours is the whole course, so only an explicit attendance_hours counts; null means nothing to reach. On in_person, null falls back to hours. Same column, different semantics per type — check the type before debugging the number.hours (or attendance_hours) at course creation.overall_percent looks pessimistic on lopsided progress. 100% online plus 1 of 10 sessions reads as ~55%. That is by design (units differ), but label it in the UI or you will get bug reports.type → requiredAttendanceHours() → summary() → the raw attendances rows. Nine times out of ten it is the type.How can I help you explore Laravel packages today?