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 (or paste a Vimeo / YouTube link), and let members enroll and work through them at th...

Courses turns the site into a course platform: build structured curriculums from modules and lessons, attach streaming video from the Video Hosting add-on (or paste a Vimeo / YouTube link), 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, sold as a one-time Stripe purchase, or kept invite-only (sold per deal, students enrolled by an admin). 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", the required plan's name, the one-time price, or "By invitation").
  • /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. An invite-only course shows no price and no Enroll/Buy button — just a Request a quote button (link set in Course Settings) and an "Already enrolled? Sign in" link.
  • /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) or an embedded Vimeo / YouTube video, 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). This works for hosted videos and Vimeo embeds; a YouTube embed is embed-only — it plays, and the student uses Mark complete.
  • 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. A certificate never shows the student's email address.
  • 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 — a salesperson or seat approver below Manager sees only Course Seats, in their own sidebar entry):

  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), the lesson video — a hosted video picked from Video Hosting or a pasted Vimeo / YouTube Embed URL (shown when no hosted video is picked) — 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. Managers and admins can enroll a member manually by email (comps, support cases — manual enrollments skip the course's access rules; the address match ignores case) or remove an enrollment. Removing a student who was loaded onto a seat request frees that seat. Students stays manager-only: a salesperson or seat approver doesn't reach it.

    Enroll a list (admins only) enrolls a whole roster at once: pick the course and paste up to 50 addresses — one per line, with Name <email>, email, name and name, email also accepted (comma, semicolon or tab between columns), and a line holding only addresses (a To: field copied from a mail client) read as one row per address. Preview shows how many will be enrolled and lists the invalid lines and duplicates it will skip. Enroll works through the list 10 rows per request and shows a result per row: Enrolled, Already enrolled, Account created, No account (account creation switched off), Staff account — skipped (a dashboard login without a member profile is never turned into a student), or Failed (logged; the other rows carry on). With Create member accounts for unknown emails on (the default), each unknown address becomes an active, email-verified member account; with Send emails on (the default), existing members get the normal enrollment confirmation and new accounts get the set-your-password enrollment invite instead (see Emails). People already enrolled are left alone, so re-running a list is safe; a longer roster goes in chunks of 50. The modal warns when the site has no email transport configured, since new accounts can't sign in without the invite.

  4. Course Seats — a sale's seats, requested, approved and filled. Open to managers and up, and to two Staff grants set on the Users page: a salesperson (Sales) and a seat approver. See Seat requests.

  5. 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, the one-time purchase currency + Stripe webhook signing secret, and the invite-only call-to-action URL (courses.invite_cta_url, default /contact) behind every invite-only course's Request a quote button.

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

Seat requests

A two-person control on a course sold by headcount (an invite-only course invoiced to a company, say). The person who makes the sale can't hand out the seats on their own, and the person who approves them can't approve a sale they made. Dashboard → Courses → Course Seats (dashboard/courses/seats, route dashboard.courses.seats) is where it happens.

Who does what. Two grants on the Users page, each set on a Staff login:

  • Sales (a salesperson) — works Proposals, Client Billing and the CRM (see client-invoicing.md) and, with Courses on, requests seats and loads the employee list once a request is approved. Managers and up can do both by role.
  • Seat approver (the bookkeeper) — approves or declines requests, never one they made themselves. Also valid on a Manager, who does NOT approve by role. An Admin approves by role and MAY approve their own request (the owner's override). A role preview never lends approval.
  • Withdraw — the requester, or a manager and up, while the request is still waiting.

A login holding only Seat approver lands on Course Seats when it signs in and sees nothing else in the CMS. A salesperson finds it in their Sales group. Every rule lives in CourseSeatRequests and is checked on every action, whatever the page shows: a forged call to approve, load or withdraw by a login not allowed to is refused (403), and a load against a request that isn't approved enrolls nobody. The page reads every request fresh before acting and keeps its request ids locked.

The flow.

  1. Request seats — course, client name, number of seats (1–1000), optionally an invoice (the 50 most recent non-void invoices, shown as number · client · status, while Client Billing is on; picking one fills in its client; the service refuses an estimate or a void invoice whatever the browser sends), and a note for the approver. The form warns when nobody else on the site can approve the request: no Admin, and no other login with Seat approver. A Super can still decide but is never emailed, and a salesperson's own Seat approver grant doesn't count, since they can't approve their own request. Every Admin and seat approver except the requester is emailed that a request is waiting, each in their own dashboard language. The email isn't switchable: the approval is the control on the sale.
  2. Review — the approver sees the course, client, seats, the invoice (number, status and paid so far of the total, net of refunds), who asked and their note. Under the invoice it also shows who the invoice bills, and flags the three ways one sale's paperwork can back seats it shouldn't: the invoice bills someone other than the request's client (the seller types the client); part of the paid amount was recorded by hand — a check, cash or a transfer, which anyone who sells can enter — rather than paid online, so confirm it arrived; and another waiting or approved request names the same invoice (allowed — one invoice can cover several courses — but worth a look). The checks come from CourseSeatRequests::invoiceCheck(). They approve or decline with an optional note, and the requester is emailed the decision unless they decided it themselves. A request is decided exactly once: a double click or two approvers at the same moment record one decision, and the second is told it was already decided. A request can't be edited once made. To change the seats or the client, withdraw it and request again.
  3. Load employees — on an approved request, a salesperson (or a manager) pastes the list the same way as Enroll a list on the Students page: up to 50 addresses at a time, in the same formats. Preview shows the count, the invalid lines and the duplicates, warns when the list is longer than the seats left, and lists who is already on the request. Enroll works through the list 10 rows per request. Unknown addresses always get an account, and Send emails (default on) sends the confirmation or set-your-password invite. Each row shows its result: Enrolled, Account created, Already enrolled (takes no seat), Staff account — skipped (takes no seat), No seats left, Request not approved, or Failed (logged; the rest carry on).

The seat cap. Seats used are counted from the enrollments stamped with the request (course_enrollments.course_seat_request_id), never stored. Every row is checked under a lock on the request row, so two people loading at once can't go past the cap together, and re-running the same list takes no extra seats. Removing a student on the Students page frees exactly one seat.

The list. Every request, waiting ones first, filterable by status (Waiting for approval, Approved, Declined, Withdrawn). Each row shows the course, client, seats used of total, the invoice and what's been paid on it (while Client Billing is on), who requested it and when, and who decided it and when, with their note. On a laptop-width dashboard the table scrolls sideways, so the action buttons (Review request, Load employees, Withdraw) are pinned to its right edge and never start off-screen.

Audit trail. Every step is logged in the activity log (content category, the request as subject, the person who took it as causer): requested, approved, declined, canceled, and one roster_loaded per batch of 10 with a count per result.

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; subscribe the webhook to checkout.session.completed, checkout.session.async_payment_succeeded, checkout.session.async_payment_failed, checkout.session.expired and charge.refunded. Site-wide Stripe keys are shared with the shop/donations/booking. A session settles on the purchase that recorded its id; the metadata id is only a fallback for a purchase that has no session recorded yet, so a settlement can never land on another checkout's purchase.
  • Bank payments (ACH) — a member can pay by US bank account (ACH) when your Stripe account offers it. The checkout completes days before the money arrives, so the purchase sits in processing (with payment_processing_at) and enrolls nobody yet: the return page says the payment is processing (a reload never offers the payment form again), the course page swaps its Buy button for the same note, and /courses/{slug}/checkout sends the member back to the course rather than charging them twice. That holds even before Stripe reports the payment: when a checkout is restarted (a changed price or promo code, or another course) after the member finished the previous one in another tab, the finished checkout is settled — held as processing, or paid and enrolled for a card — instead of being deleted as abandoned, and if it was for this course the member is sent to the course page instead of a second checkout. When the debit clears (checkout.session.async_payment_succeeded) the member is enrolled exactly once. If it bounces (checkout.session.async_payment_failed) the purchase is canceled with payment_failed_at, the member gets one email saying it didn't go through (with a link back to the course), and they can buy it again. If Stripe never says how it ended (the webhook isn't subscribed to the two checkout.session.async_payment_* events, or Stripe gave up delivering them), the payment is checked against Stripe once it has been processing for a day — php artisan payments:reconcile-processing, every six hours — and settled or failed exactly as the webhook would have, with a warning in the log.
  • Refunds — refund a purchase from Stripe's dashboard. charge.refunded records the running refunded total on the purchase (a stale or replayed event can't lower it); a full refund marks it refunded and takes the course back: the lessons close and the course page offers the purchase again. The student's enrollment row, progress and any certificate are kept. A partial refund changes nothing about access. Buying again restores access, and so does enrolling the student again on the Students page after the refund — on the same enrollment row, so their progress is kept.
  • Invite only — nobody can enroll or buy on their own: the course page shows the Request a quote button, a member's enroll attempt or lesson visit bounces back to the course page with "This course is provided to enrolled teams…", and /courses/{slug}/checkout 404s. Students get in only when an admin enrolls them from Students, or when a sale's employee list is loaded against an approved seat request — the same bypass manual enrollment already has on a purchase course. Guests are still sent to member login first, so an enrolled student who is signed out lands on their lessons after signing in.
  • 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 landscape-letter PDF, and an automatic email with the PDF attached. Certificates issue lazily too — turning the toggle on later awards them to students who already finished.

The design. Both the PDF and the verification page draw the same certificate: a near-white sheet with a faint chevron texture, a thin gold bracket frame, the site logo (a raster; an SVG or unset logo falls back to the business name in small caps), the title split into a large serif headline and a script tail ("CERTIFICATE" / "of Completion"), "Proudly presented to" over the student's name, the course title, the completion date in a small box, and a footer of a signature block, a flat gold seal and the issuing business. Type is Playfair Display and Great Vibes (bundled under public/fonts/certificate/, SIL Open Font License); the seal is drawn as SVG and the texture is generated with GD, so nothing else ships as an asset. Long names and titles step down in size rather than running into the frame.

The student's name — never their email. The verification page is public, so the certificate prints the name the student chose for it (course_certificates.recipient_name), else their account name — but an account name that is an email address, or was made from one, never counts. Accounts created without a name (an Enroll a list row with no name, some social sign-ins) are named after the email's local part, and a name that is only a handle taken from it is treated as no name: compared letters-and-digits only, jane.doe, jane_doe, Jsmith1987 or a lone Jane matching the address. A name of two or more plain words prints even when the address is made of it — John Smith from john.smith@… is how most people's address reads, and can't be told from a name the student typed (CourseCertificate::usableName()). With no usable name the certificate reads Certificate holder. When the student opens their own certificate while signed in, a form above it asks for the name to print (or, once set, a Change the name on this certificate link); only the student the certificate belongs to can save it, and an email address is refused. The certificate email adds a line telling the student to do this when no name is known.

Courses → Settings → Certificates holds what prints on every certificate: the title (blank = "Certificate of Completion", translated), the signer name and title (blank = the business name on the signature line), and an optional signature image picked from the media library (a dark signature on a transparent PNG; it is scaled to a printable size before embedding, as is the logo). Preview a sample certificate opens a PDF for a made-up student using the saved settings. Images resolve only from the media library or the public folder; anything else is left off, and the PDF renderer caches its font metrics under storage/framework/cache/dompdf-fonts.

Emails

Six transactional emails to students and the owner, all through the shared marketing transport with the white-label email chrome: enrollment confirmation (with a start link), the enrollment invite for accounts created by Enroll a list (or a seat request's employee list), 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.

The enrollment invite replaces the confirmation for a student whose account the list import just created (a confirmation would point them at a sign-in they have no password for): "You've been enrolled in :course on :site" with a Set your password button. The link comes from the members password broker and expires after an hour, so the email also points at Forgot password for a fresh one. It is gated by the import's own Send emails switch, not the enrollment-confirmation toggle in Settings, and is claimed once per enrollment.

Two more go to the site's own people, in each recipient's dashboard language: a seat request is waiting (to every Admin and seat approver except the requester) and your seat request was approved / declined (to the requester, with the approver's note). Each is sent once, by the step itself, and neither can be switched off. See Seat requests.

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. They run once the enrollment is committed: when the enrollment is made inside a transaction (a seat request's employee list, Enroll a list creating an account), a rollback sends no automation email and enrolls nobody in a sequence, and no row lock is held while an automation sends.

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.

A lesson can instead (or as well) carry a pasted embed URL — Vimeo or YouTube only; the lesson editor rejects anything else. It needs no Video Hosting feature, so a course can run entirely on a Vimeo account:

  • Link shapes — any Vimeo link works (vimeo.com/{id}, the unlisted vimeo.com/{id}/{hash} share link, channel/group/showcase/manage links, player.vimeo.com/video/{id}?h=…), as do YouTube watch, youtu.be, embed and Shorts links. An unlisted Vimeo video only plays with its privacy hash, so the hash is carried through to the player URL (?h=…); public videos embed exactly as before.
  • Domain-restricted Vimeo videos — for a private video, set its Vimeo embed privacy to Specific domains and add the site's domain. Vimeo then refuses to play it anywhere else — including a preview hostname or a local .test site until that domain is whitelisted too. That's Vimeo's behaviour, not a bug.
  • Consent — the embed is the same consent-gated component the rich-text body uses (x-inline-video): with cookie consent on, the iframe carries only data-src behind a placeholder until the visitor grants marketing consent; with consent off it loads directly.
  • Tracking — Vimeo embeds get resume + ~90% auto-complete through the Vimeo Player SDK (lazy-loaded as its own chunk only on a lesson that needs it; it attaches once the consent gate sets the iframe src). YouTube is embed-only — tracking it would mean loading YouTube's IFrame API script, which the pasted-link feature deliberately avoids.
  • Precedence — a ready hosted video wins, then the embed, then the "still processing" note. Saving never clears either field, so switching Video Hosting off (or a hosted video that is still encoding) falls through to the embed rather than to nothing.

Without either, a lesson is text/rich-media only.

SEO surface

Published course pages emit schema.org Course JSON-LD (provider, offer with price/access category — omitted for invite-only courses, which have no public offer — 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, video_embed_url (pasted Vimeo / YouTube link), 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, the student-chosen recipient_name), course_purchases (Stripe session id, atomic paid claim; status pending_payment → processing (a bank payment clearing) → paid, or canceled when the bank payment bounced, then refunded), course_cohorts, course_lesson_attachments (private-disk files), course_seat_requests (course, client name, optional invoice_id — indexed, no foreign key, since Client Billing can be off — seats 1–1000, note, status pending → approved / declined / canceled, requested-by and decided-by users, decision time and note; immutable once made, the status changed only by one conditional update) with course_enrollments.course_seat_request_id stamping each seat taken, and course_email_logs (idempotency claims, unique per enrollment + lesson + type; types enrollment_confirmation, enrollment_invite, lesson_unlocked, owner_notification, certificate, nudge). Deleting a course cascades (its seat requests included); deleting a seat request keeps its students (the stamp is nulled); 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.