Search by

goldnead / statamic-courses

goldnead

Courses for Statamic: modules and lessons as entries, per-lesson state, sequencing and drip, and a progress rollup. Decides what a learner may open next; who may open the course at all is asked of statamic-entitlements.

Package info

github.com/goldnead/statamic-courses

Type:statamic-addon

pkg:composer/goldnead/statamic-courses

Statistics

Installs: 594

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v0.7.1 2026-10-19 15:16 UTC

README

Courses for Statamic 6: modules and lessons as entries, a state per learner and lesson, sequencing, drip by schedule or by progress, and a progress rollup; and materials, pages with text and protected downloads that open like a course but carry no progress. Access to a course is asked of statamic-entitlements. Antlers tags and a form route for the front end, and a "Course Progress" screen in the Control Panel.

Commercial, single edition (pro).

Course progress in the Control Panel Lessons with lock states on the front end

Requirements

  • PHP 8.2+, Laravel 12.40+ or 13, Statamic 6
  • Optional: goldnead/statamic-entitlements 1.3+ (without it, or your own CourseAccess, every course is closed)

Install

composer require goldnead/statamic-courses
php artisan migrate
php artisan courses:install   # collections `courses` and `course_lessons`, blueprints, routes

Collection handles are configurable in config/courses.php before installing. The install routes courses at /courses/{slug} and lessons at /courses/{course_slug}/{slug}; change or clear the routes as you like, url is then null and nothing breaks.

Structure

A course entry carries kind (course, the default, or material: see Material), sequencing_mode (none, section, lesson), drip_mode (see below) and product, the entitlements product that opens it (defaults to the course slug), plus bundles, team_seats, on_payment_failure and section_audiences.

Upgrading from 0.1: run php artisan migrate, then php artisan courses:install --merge. It adds the fields and select options the site's blueprints lack and changes nothing else; see first with --merge --dry-run. (--force rewrites both blueprints and loses fields added by hand.)

A lesson entry points at its course (course) and orders itself with section_key, section_order and sort_order. Optional: phase_key / phase_order (later phases wait for earlier ones), is_test_out, week (with schedule drip), prerequisite_lessons, item_type and video_duration (mm:ss).

item_type decides how a lesson completes:

Type Completes by
video (default) watching past auto_completion_threshold (90 %), or the learner's own tick
text, milestone, coaching, exercise acknowledgeLesson(); a milestone also by itself once its phase is done
quiz, assignment, reflection (proof_required_types) only completeLesson(), called by the code that checked the proof
anything else the learner's tick or completeLesson()

What locks a lesson

  1. The course's sequencing_mode.

  2. Its prerequisites, until each is completed.

  3. Its phase, until every earlier phase is completed. A test-out lesson stays open; completing it completes the earlier phases as skipped.

  4. The course's drip (drip_mode), with the lesson field it reads:

    drip_mode Lesson field Opens
    schedule week week n opens (n − 1) × 7 days after enrollment, or earlier with advanceToWeek()
    days drip_after n days after enrollment
    date drip_date on that day, in statamic.system.display_timezone (else app.timezone)
    day_of_month drip_after the nth time the course's drip_day_of_month comes round, the enrollment day included
    payments drip_after once n payments went through (the first included)
    after_trial drip_after ≥ 1 once the first charge after a trial went through; at once without a trial

    Payments are recorded by the statamic-payments bridge (Stripe and Mollie alike) or by Courses::recordBilling(). A lesson without the field opens at once.

A completed lesson is never locked. Writes to a locked lesson are refused. Each locked lesson carries a lock_reason: schedule, payment, paused, prerequisite, phase or sequence.

Who sees a lesson

A lesson (audience_entitlements, audience_groups, audience_tags, audience_segments) or a whole section (the course's section_audiences) can be limited to some learners: product handles, Statamic user groups, LeadHub tags, LeadHub segments. One match is enough. For everybody else the lesson is not part of the course: it is not listed, not counted, and does not lock the path. Tags and segments need statamic-leadhub; without it such a rule matches nobody.

When a payment fails

on_payment_failure on the course, applied when statamic-payments reports a failed cycle (SubscriptionCycleFailed) and lifted on the next renewal:

  • keep (default): nothing; access runs out at the end of the paid period, as entitlements has it.
  • pause_drip: the drip clock stops. Afterwards every relative release date moves on by the length of the pause.
  • revoke: what that subscription paid for is taken away until the payment arrives. Any other source still opens the course: a lifetime grant, a bundle, another subscription, a team seat. A new purchase lifts the hold at once.

A hold set by hand (Courses::suspendAccess() without a subscription) is absolute: no grant and no team seat opens the course, and no renewal or purchase lifts it; only restoreAccess() or the Control Panel does.

Holds show on the Course Progress screen under Holds, marked as closed or as open through another purchase, with the provider's subscription number. Somebody with the permission manage course holds can lift one there, after a confirmation. Without payments: Courses::paymentFailed(), paymentRecovered(), pauseDrip(), resumeDrip(), suspendAccess(), restoreAccess().

Bundles and teams

A course opens for its product and for every product listed under bundles: sell one bundle product and list it on each course it contains. (A PackageResolver in entitlements works as well.)

team_seats lets a buyer add that many people by email (Courses::addTeamMember(), the courses:team_form tag). Seats belong to the purchase: bought through a bundle, one team with the same members covers every course of the bundle, and the seat count is the highest team_seats among those courses. A member gets in with that address for as long as the buyer holds the purchase. statamic-entitlements has no seats of its own yet; the team lives in courses_team_members, one row per seat.

Lesson content

Besides the markdown content (kept, rendered first), a lesson has blocks: text, callout, columns, FAQ, video (YouTube and Vimeo become players; with statamic-consent installed they wait behind {{ consent:gate }} for the youtube or vimeo service), image (from private-media's container, signed for the course like a private download; without private-media from the public one, marked as public), download and button.

A download marked “only for learners of this course” takes its file from its own field on statamic-private-media's container and is served through a link signed for course:<slug>. courses answers private-media for that resource with Courses::canAccess(), so buyers, team members and bundle holders all get the file. Public and private downloads sit side by side. Without private-media the toggle is not offered. courses:install points the public field at courses.downloads.container or the first container other than the private one. On a multi-brand site a download is checked in the brand of the request that fetches it.

Boxes under a lesson

With statamic-offers 1.17 or later, a lesson can show content boxes: a small invitation (headline, text, button) such as "how it can go on" with a course, or a waiting list. The lesson blueprint has a "Boxes" section: lesson_boxes says which box stands under the video or at the end of the lesson, video_hints shows a dismissible hint button over the video between two times (mm:ss). The course has course_boxes for a box on every lesson. The words, the target and the audience (LeadHub tags, user groups) are set on the box itself, once, and reused.

$boxes = Courses::lessonBoxes($user, 'cvt-im-chor', 'lektion-5');
// ['below_video' => [...], 'end' => [...], 'hints' => [['from' => 2634, 'to' => null, 'text' => ..., 'label' => ..., 'url' => ...]]]

Courses does not draw anything: the front end of the site renders the lists. Without statamic-offers the lists are empty. A box that must not be shown to this learner (audience, offer sold out) is not in the list.

Material

A course entry has a kind: course (lessons, progress, sequencing, drip) or material. A course without the field is a course, so existing courses need no change. A material is a page with text and downloads, opened exactly like a course (product, bundles, teams, holds), but with none of the pacing: no progress is recorded, nobody is enrolled, no drip runs, and it is not on the Course Progress screen. A sheet-music bundle, a workbook, a quick reference.

  • cover: a public image for the card in a member area (cover_url on every course).
  • body: Bard text. Images go in as the set Image (file, alt text, caption), picked from statamic-private-media's container, because on a paid material the figures are the content. The Control Panel shows the file with its preview. Each image renders as a <figure> (partial courses::blocks.image, the same as the lesson block) with a link signed for course:<slug> for somebody with access, and is left out for everybody else, caption included (no <img>, no path). An empty alt text falls back to the asset's own. Without private-media the set picks from the public container and says so in its instructions. Image nodes saved with Bard's own image button are still read and protected the same way.
  • downloads: one row per group (a voicing, a part), its files underneath, each with a label. The file is picked from private-media's container only and served through a link signed for course:<slug>, which courses answers with Courses::canAccess(); private-media checks it again when the file is fetched. The format is the file extension. Without private-media courses:install leaves the download list out: there is no protected way to serve it.
  • Lessons pointing at a material are further pages. They are never locked and record nothing; their blocks render with {{ courses:blocks }} as usual. The lesson image block works the same way as the text images: from private-media's container, signed, or left out.

In the Control Panel a material shows only these fields: product, bundles, sequencing, drip, payment failure, team seats and section rules are hidden once the kind is Material (their values stay and are ignored). The kind is a column in the entries listing. courses:install --merge gives fields an older blueprint already has the same condition and puts the Material section right behind the main one. The brand field is a choice of the site's brands by name on a multi-brand site (--merge adds brands created since); on a single-brand site it is not installed, and --merge hides the older brand text field. It does not rename what the site has: a collection created before German labels shipped stays "Courses", a section stays "Access and pacing"; rename those in the Control Panel if you like.

{{ courses kind="material" only="accessible" }}<a href="{{ url }}">{{ title }}</a>{{ /courses }}

{{ courses:material course="baraye" }}
    <h1>{{ title }}</h1>
    {{ body }}
    {{ download_groups }}
        {{ if group }}<h2>{{ group }}</h2>{{ /if }}
        {{ downloads }}<a href="{{ url }}">{{ label }}</a> {{ format }}, {{ size }}{{ /downloads }}
    {{ /download_groups }}
    {{ pages }}<a href="{{ url }}">{{ title }}</a>{{ /pages }}
{{ /courses:material }}

courses:material takes the course entry in context when course is left out. It renders nothing for a guest, a learner without access and a course of kind course; super users see the text and pages for the live preview. Courses::material($user, $slug) gives the same data in PHP (downloads only when $user has access). For a material, summary(), outline(), enroll() and the drip writes answer null, and a lesson write is refused with reason material.

Another addon that lists course entries (a picker for what a product opens, say) asks Courses::kind($slugOrEntryId): course, material, or null for no such course. The raw field is kind on the entry; a missing value means course.

How a material is sold is not this addon's business: a product in entitlements opens it, like a course.

Quiz

A quiz lesson names a statamic-assessments questionnaire (assessment) and optionally assessment_min_score and assessment_pass_levels. When the signed-in learner submits it, a pass completes the lesson (source assessment, opening whatever waited on it) and a fail records the attempt.

Usage

use Goldnead\Courses\Facades\Courses;

Courses::canAccess($user, 'cvt-101');              // entitlements decides
Courses::enroll($user, 'cvt-101');                 // starts the drip clock
Courses::outline($user, 'cvt-101');                // sections, lessons, progress, locks, rollup
Courses::summary($user, 'cvt-101');                // status, percent, continue_lesson
Courses::lessons($user, 'cvt-101');                // flat, in reading order
Courses::lesson($user, 'cvt-101', 'intro');
Courses::updateLessonProgress($user, 'cvt-101', 'intro', ['watched_seconds' => 540, 'resume_seconds' => 540]);
Courses::setLessonCompletion($user, 'cvt-101', 'intro', true);
Courses::acknowledgeLesson($user, 'cvt-101', 'reading');
Courses::updateLessonItem($user, 'cvt-101', 'quiz-1', ['attempts' => 1, 'best_score' => 40]);
Courses::completeLesson($user, 'cvt-101', 'quiz-1', 'quiz', ['best_score' => 90]);

updateLessonProgress() is for videos only and trusts the entry's video_duration over the player's; the player's number counts only when the entry has none.

$user is a Statamic user, any Authenticatable, or an id. Reads never check access; ask canAccess() first.

Events: LessonCompleted on every transition to completed (with its source), CourseCompleted once per learner and course, LearnerEnrolled, LessonUnlocked (for unlocks a write caused), QuizPassed, QuizFailed, DripPaused, DripResumed, CourseAccessSuspended, CourseAccessRestored, TeamMemberAdded, TeamMemberRemoved. Every event carries brandId (null without statamic-brand-context): the course's brand (its brand field, else the brand brand-context.sites gives its site), else the brand current when it fired, so a listener started from a webhook runs in the right brand. Courses::courses($brandId) lists one brand's courses plus those without a brand. Every start, quarter mark and completion is logged to courses_lesson_events. All tables carry the courses_ prefix.

Antlers

All tags work for the signed-in learner. Without access to the course they render nothing.

{{ courses only="accessible" }}
    <a href="{{ url }}">{{ title }}</a> {{ progress:percent }} %
{{ /courses }}

{{ courses:progress course="cvt-101" }}{{ completed_lessons }}/{{ total_lessons }}{{ /courses:progress }}

{{ courses:lessons course="cvt-101" }}
    {{ title }}: {{ if is_completed }}done{{ elseif is_locked }}{{ lock_reason }}{{ else }}open{{ /if }}
{{ /courses:lessons }}

{{ courses:continue course="cvt-101" }}<a href="{{ url }}">Continue with {{ title }}</a>{{ /courses:continue }}

{{ courses:form course="cvt-101" lesson="reading" do="acknowledge" redirect="/courses/cvt-101" }}
    <button>Mark as read</button>
{{ /courses:form }}

{{# on a lesson page #}}
{{ courses:blocks }}
{{ courses:quiz }}{{ if passed }}Passed{{ else }}<a href="{{ url }}">Take the quiz</a>{{ /if }}{{ /courses:quiz }}

{{ courses:team course="cvt-101" }}{{ left }} of {{ seats }} seats free{{ /courses:team }}
{{ courses:team_form course="cvt-101" }}<input type="email" name="email"> <button>Add</button>{{ /courses:team_form }}

courses:blocks renders the shipped partials (override one at resources/views/vendor/courses/blocks/{type}.antlers.html) or, as a pair, hands over the blocks. It renders nothing for a lesson that is locked or hidden for the learner; super users see every lesson. courses:team_form posts to POST /!/courses/team (action add or remove, email); refusals: not_owner (403), no_seat (422).

courses:form posts to POST /!/courses/progress (fields course, lesson, action = complete, incomplete, acknowledge or progress, plus watched_seconds / resume_seconds). The route needs a signed-in user with access to the course, carries CSRF, answers JSON when asked and otherwise redirects back or to a local _redirect. A refusal carries a reason code, as {"error": "…"} in JSON and as the flashed courses error ({{ get_error:courses }}) on a form post: no_access (403), unknown_course (404), unknown_lesson, locked, proof_required, not_video, not_acknowledgeable, material (422). Courses::refusalReason() gives the same answer in PHP.

In courses:lessons, a lesson a test-out completed has status: skipped and is_skipped: true; its progress:status stays completed. Switch it off with COURSES_ROUTES_ENABLED=false; the form tag then renders nothing.

Control Panel

Course Progress (permission view course progress): per course the learners, how many are in progress or completed, the completion rate among those who started, how many are stuck (no activity for cp.stuck_after_days, default 14) and the last activity. Materials record no progress and are not listed. It sits in the suite's shared nav section when statamic-payments provides one, under Content otherwise.

Webhooks

With statamic-webhook-manager installed, every course event is a trigger an outbound webhook can listen to (source type courses). Without it nothing is loaded; courses.webhook_manager.enabled (env COURSES_WEBHOOK_MANAGER, default true) switches the bridge off.

A hook fires in the course's brand, so a webhook or console run with no brand current still reaches the hooks of the right brand. A course whose brand no longer exists sends nothing (logged), rather than reaching the hooks of whichever brand is current. In the delivery log it is filed under the course.

A moment is sent after its database transaction commits, and not at all if it is rolled back. Order is not guaranteed (retries, queues): sort by occurred_at and deduplicate on event_id.

Every payload starts with the same keys as the other suite addons:

Key Value
event the trigger handle
event_id sha1(handle|<type>:<id>|<time the row records>), the recipe of every suite addon; the same for the same moment however often it is sent
occurred_at when the moment happened (ISO 8601 with offset), not when it was sent
brand {id, handle}, or null without statamic-brand-context
subject_type, subject_id course and the course entry id

People are looked up as Statamic users: {id, email, name}. A user that is not found is sent as {id} only. course is {id, slug, title}. Nothing else leaves the addon: no watch positions, no quiz answers, no lesson state rows.

Trigger Fields after the frame
courses.learner_enrolled learner, course
courses.lesson_completed learner, course, lesson {id, slug}, source, completed_at
courses.lesson_unlocked learner, course, lesson {slug}, source
courses.quiz_passed learner, course, lesson {slug}, assessment, score, passed: true, result_key, response_id
courses.quiz_failed as quiz_passed, passed: false
courses.course_completed learner, course
courses.drip_paused learner, course, reason (payment_failed, manual)
courses.drip_resumed learner, course, reason, paused_seconds
courses.access_suspended learner, course, reason (payment_failed, manual)
courses.access_restored learner, course, reason (payment_recovered, manual)
courses.team_member_added member (the user, or {email} without an account), owner, course, product
courses.team_member_removed as team_member_added

Funnels map

With statamic-brand-context 1.17 or later, the funnels map can show courses as cards. The provider is Goldnead\Courses\Integrations\Map\CourseMapObjects (type course, id = course slug). Each card shows the title, a link to the entry in the Control Panel, published or not, and the learners who started the course in the period. Without brand-context nothing is registered and the map simply has no course type. With brand-context 1.18 or later, CourseMapObjects also draws one edge per course from its product (product → unlocks → course; the course slug when the product field is empty).

Access

Goldnead\Courses\Contracts\CourseAccess is the seam. With statamic-entitlements installed it asks Entitlements::allows(); without it every course is closed.

How a learner reaches entitlements: an Eloquent model passes through; a Statamic eloquent user (User::current() on an eloquent install) is unwrapped to its model; anything else is looked up as subject type courses.entitlements.subject_type. Unset, that is the auth model's morph class on an eloquent install and user on a flat-file one.

courses also asks under the learner's email address, as subject type email. That is where statamic-payments grants when the site has no SubjectResolver that knows the buyer's address, so a buyer on such a site gets in with the account that has the same address. A site with a resolver loses nothing by it.

A host with its own access rules binds its own implementation, and it wins over the default:

$this->app->bind(\Goldnead\Courses\Contracts\CourseAccess::class, MyCourseAccess::class);

Tests

composer test
DB_DRIVER=mysql DB_PORT=3306 composer test:mysql