Skip to main content

Documentation

No results found.
Features Members

Courses

Courses turns the site into a course platform: build structured curriculums from modules and lessons, attach streaming video from the Video Hosting add-on, and let members enroll and work through them at their own pace with saved progress....

Courses turns the site into a course platform: build structured curriculums from modules and lessons, attach streaming video from the Video Hosting add-on, and let members enroll and work through them at their own pace with saved progress. Access rides the site's own member accounts — a course can be free for any member (lead capture), reserved for a membership plan, or sold as a one-time Stripe purchase. Lessons can drip on a per-enrollment or cohort schedule, gate completion behind quizzes, carry downloadable resources and a Q&A thread, and finishing a course can award a verified PDF certificate. Video lessons remember playback position and auto-complete at ~90% watched, stalled students get a re-engagement nudge, and enrollments feed the CRM and Marketing drips automatically.


The problem

Selling or gating a course usually means renting a second platform — Kajabi, Teachable, Thinkific — with its own bill, its own login, its own contact list, and a marketing site that can't carry real SEO. The course business ends up split across two systems, and cancelling the rented one takes the whole catalog dark.

The fix

A self-contained feature module under app/Features/Courses/ gives the site its own course platform on rails the CMS already ships: member accounts and plan gating from Memberships, streaming video (with signed playback URLs) from Video Hosting, and transactional email through the shared marketing transport. There is no separate checkout to configure — selling a course means gating it behind a paid membership plan the site already sells.

Like every addon, it's structured as a feature module gated by feature:courses middleware. The service provider boots unconditionally; routes 404 and the sidebar entry hides when the feature is off. Toggling it on runs the module's migrations. Because member accounts are the site-wide account system, enabling Courses alone is enough to light up the member login/register/dashboard pages (same any-of gate as Ecommerce and Real Estate).

What visitors see

  • /courses — a public, cacheable catalog of published courses: featured image, excerpt, lesson count, and an access badge ("Free for members" or the required plan's name).
  • /courses/{slug} — the course overview: description, the full curriculum outline (modules and lessons with durations), and a context-aware call to action. Anonymous visitors are asked to sign in or create an account; members see Enroll now (or Upgrade to access when the course needs a plan they don't hold); enrolled students see their progress bar and a Continue learning button that jumps to the next incomplete lesson.
  • /courses/{slug}/lessons/{lesson} — the lesson player: streaming video (signed playback URL when the video provider is configured for it, with captions and chapters when the video has them), the rich-text lesson body, Mark complete & continue, previous/next navigation, and a curriculum sidebar with completion checkmarks and drip locks. Lessons a student hasn't reached on a dripped schedule show a lock screen with the exact unlock date.
  • Video resume + watch completion — the player remembers each student's playback position (throttled background pings) and seeks back to it on the next visit; reaching ~90% of the video marks the lesson complete automatically (unless a required quiz still gates it).
  • Knowledge checks — a lesson with quiz questions shows them under the body: multiple choice (single or select-all-that-apply), instant scoring against the lesson's pass threshold, and unlimited retries. When the quiz is required, Mark complete stays gated until the student passes.
  • Resources — downloadable files (worksheets, slides, source files) listed under the lesson. Downloads stream through an access-checked route, so files on gated or still-locked lessons can't be hotlinked.
  • Lesson Q&A — with the Forum feature on, each lesson carries a discussion thread for enrolled students. Questions and replies follow the forum's moderation settings, staff answers get an Instructor badge, and the boards stay completely off the public forum.
  • Free previews — any lesson can be flagged as a free preview, viewable by anyone (even logged-out visitors) as a course trailer; it ends with an enroll prompt.
  • Certificates — finishing a course with certificates enabled awards a branded PDF (emailed automatically) plus a public verification page at /certificates/{code} anyone can use to confirm it's genuine.
  • Member dashboard — /members/dashboard gains a Your courses card listing every enrollment with a progress bar, a Continue link, and a Certificate link once earned.

Enrolling requires a member account even for free courses — that's deliberate: a free course is a lead magnet, and every student lands in the site's own member base.

What the dashboard gets

Under Courses in the sidebar (managers and up):

  1. Courses — the course list (status, access, lesson/student counts, publish toggle) and the curriculum builder: modules with inline rename and reordering, lessons with per-module ordering, and per-course settings — title/slug/excerpt/description, featured image from the media library, publish state, access mode (including a one-time price), drip toggle + anchor, certificates, lesson Q&A, and an optional marketing sequence students are dripped into on enrollment. The course page also hosts the question bank (write questions once, attach anywhere) and, for cohort-anchored courses, the cohorts card (name + start date, student counts).
  2. Lesson editor — each lesson gets a full-page editor: rich-text body (same Tiptap editor as the blog, with media-library images, video embeds, and shortcodes), an attached hosted video, duration, the drip day, the free-preview flag, the quiz builder (attach bank questions, set the pass threshold, toggle whether passing is required to complete), and resource uploads.
  3. Students — every enrollment with search, per-course filtering, cohort, progress percentage, and completion badges. Admins can enroll a member manually by email (comps, support cases — manual enrollments skip the course's access rules) or remove an enrollment.
  4. Settings (admins) — toggles for the enrollment-confirmation, lesson-unlock, and re-engagement nudge emails (with the inactivity window), a courses-specific sender override, an owner-notification address, and the one-time purchase currency + Stripe webhook signing secret.

A dashboard widget card shows active students and published course counts.

Promo codes

The course page shows a promo-code field beside the price when a course is sold as a one-time purchase. An applied code strikes through the list price and shows the discounted one; CourseCheckoutStarter re-reads the code when it mints the Stripe session, so a code that goes stale between the page and checkout can't mislead. The purchase row stores both the discounted amount_cents and the discount_cents taken off, so a fully discounted sale is distinguishable from a free course.

The field sits on the course page rather than the checkout page deliberately: the checkout page mints its Stripe session in mount() behind wire:ignore, so a code applied there could not refresh the embedded payment form.

Codes come from the shared list at Dashboard → Promo Codes (see gift-cards.md for how they interact with credits). A redemption is counted only once the purchase is paid.

Access model

  • Any member (free) — any logged-in, active member can enroll.
  • Members on a plan — the course stores a required membership plan; the standard Memberships rule applies (the member needs an active line item on that plan or a higher tier in the same category). Unlike blanket content gating, this check is enforced regardless of the install-level memberships.content_gating_enabled toggle, because plan-gating a course is an explicit per-course choice. Members who don't qualify are sent to the upgrade page for that plan.
  • One-time purchase — the course carries a price; buyers pay once through embedded Stripe checkout (/courses/{slug}/checkout) and are enrolled automatically. Same trio as the other payment features: a pending course_purchases row + Stripe session with fingerprint reuse, a finalizer shared by the return page and the courses/stripe/webhook endpoint (atomic paid-claim, so whichever lands first wins and the other is a no-op), and per-feature webhook signing secret stripe.webhook_secret.courses. Site-wide Stripe keys are shared with the shop/donations/booking.
  • Plan access is re-checked on every lesson view, so a lapsed subscription loses access immediately; the enrollment and its progress are kept for if they return. A one-time purchase is permanent.

Dripped lessons & cohorts

With Drip lessons on for a course, each lesson's "unlocks on day N" counts from the student's enrollment date (day 0 = immediately). Switching the drip anchor to cohort makes everyone unlock together: the admin creates cohorts with start dates, new students automatically join the current one, day counts run from the cohort start, and nothing unlocks before the cohort starts (day-0 lessons included). Students without a cohort — or on courses whose cohorts were deleted — fall back to their enrollment date. Locked lessons show their unlock date on the course outline and the lesson page. A LazyCron task (courses:drip, every 15 minutes) emails students when a scheduled lesson unlocks — idempotent per (enrollment, lesson), and unlocks older than 72 hours are claimed silently so switching the emails on late never floods long-standing enrollments.

Quizzes

Each course owns a question bank: multiple-choice questions (2–8 options, one or several correct answers, optional explanation) written once and attached to any lesson from the lesson editor. A lesson with attached questions renders a Knowledge check in the player; grading is exact-set matching per question, the score is compared to the lesson's pass threshold (default 80%), and every attempt is recorded with its answers. When Required to complete is on (the default), neither the Mark complete button nor watch-based auto-complete counts the lesson until a passing attempt exists — that's the "prove you learned it" gate.

Certificates

With Completion certificates on for a course, the first time a student finishes every lesson they're issued a certificate: a 40-character verification code, a public verification page (/certificates/{code} — the code is the capability, so students can share it with employers), a branded landscape PDF rendered with dompdf (business name, brand color, student, course, date, code), and an automatic email with the PDF attached. Certificates issue lazily too — turning the toggle on later awards them to students who already finished.

Emails

Five transactional emails, all through the shared marketing transport with the white-label email chrome: enrollment confirmation (with a start link), lesson unlocked (dripped courses), the completion certificate, an owner notification about new enrollments, and the re-engagement nudge — students inactive for N days (default 7, configurable) get one "pick up where you left off" email pointing at their next incomplete unlocked lesson, sent by the hourly courses:nudges LazyCron. A nudge is claimed once per (enrollment, lesson) forever — progressing and stalling again re-arms it, and enrollments idle for over 60 days are left alone entirely. Each email is toggleable/configurable under Courses → Settings, and every send is claimed in course_email_logs so retries and webhook races never double-send.

Marketing & CRM hooks

Enrollment funnels through one lifecycle pipeline regardless of how it happened (enroll button, purchase, manual dashboard add):

  • CRM (when enabled) — the student is captured/updated as a contact (source course), the enrollment and completion land on their timeline, and two new automation triggers — Enrolls in a course and Completes a course, each scopeable to a specific course — compose with every CRM action (create task, assign owner, add to group, send email, enroll in sequence). "Completes course A → enroll in the course-B upsell sequence" is a two-dropdown rule.
  • Marketing (when enabled) — a course can name a drip sequence; new students are enrolled into it automatically (via their CRM contact when CRM is on, or straight onto the marketing list otherwise). Sequence enrollment stays once-per-person-forever, exactly like every other entry point.

Both hooks are fire-and-forget: a missing feature or a throwing integration never blocks the student.

Video

Lessons reference hosted_videos rows from the Video Hosting add-on by id. At render the player asks the video's own provider for a signed playback URL (Cloudflare Stream token, Bunny token auth, or Mux signed JWT) and falls back to the public HLS URL when signing isn't configured — so gated playback tightens automatically once the provider is set up, with captions and chapter tracks included when present. Without the Video Hosting feature, lessons are text/rich-media only.

SEO surface

Published course pages emit schema.org Course JSON-LD (provider, offer with price/access category, hasCourseInstance with the online course mode and total workload) plus canonical/description/OG tags, and are listed in the sitemap and llms.txt. A Featured Courses design-library row (@requiresFeature courses, backed by the courses collection preset) drops live course cards — cover, lesson count, price or access level — onto any landing page.

Data model

courses → course_modules → course_lessons (rich-text body, hosted video id, drip day, free-preview flag, quiz pass threshold + required flag, forum thread pointer), plus course_enrollments (unique per member+course, cohort, completion + last-activity timestamps), course_lesson_completions (unique per enrollment+lesson), course_lesson_progress (video resume positions), course_questions + course_lesson_questions (bank + per-lesson attachment), course_quiz_attempts, course_certificates (unique per enrollment, capability code), course_purchases (Stripe session id, atomic paid claim), course_cohorts, course_lesson_attachments (private-disk files), and course_email_logs (idempotency claims). Deleting a course cascades; removing a member cascades their enrollments; deleting a cohort keeps its students and reverts them to enrollment-anchored drip.

What's still out of scope

Learning paths (course sequences with prerequisites), graded assignments with instructor review, and live-session scheduling — the quiz + certificate + cohort set covers the self-paced course promise first.