goldnead / statamic-courses
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
Requires
- php: ^8.2
- laravel/framework: ^12.40|^13.0
- statamic/cms: ^6.0
Requires (Dev)
- goldnead/statamic-brand-context: ^1.18
- goldnead/statamic-entitlements: ^1.3
- goldnead/statamic-webhook-manager: ^2.9
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0|^5.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0|^5.0
Suggests
- goldnead/statamic-brand-context: Show courses as cards on the funnels map (1.18 or later).
- goldnead/statamic-entitlements: Answers who may open a course (1.3 or later). Without it, or another CourseAccess binding, every course is closed.
- goldnead/statamic-offers: Content boxes under a lesson and hint buttons in the video (1.18 or later).
- goldnead/statamic-webhook-manager: Sends course events (enrolled, lesson completed, quiz passed, ...) to outbound webhooks.
Provides
None
Conflicts
Replaces
None
This package is auto-updated.
Last update: 2026-10-19 15:16:19 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).
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
-
The course's
sequencing_mode. -
Its prerequisites, until each is completed.
-
Its phase, until every earlier phase is completed. A test-out lesson stays open; completing it completes the earlier phases as
skipped. -
The course's drip (
drip_mode), with the lesson field it reads:drip_modeLesson field Opens scheduleweekweek n opens (n − 1) × 7days after enrollment, or earlier withadvanceToWeek()daysdrip_aftern days after enrollment datedrip_dateon that day, in statamic.system.display_timezone(else app.timezone)day_of_monthdrip_afterthe nth time the course's drip_day_of_monthcomes round, the enrollment day includedpaymentsdrip_afteronce n payments went through (the first included) after_trialdrip_after≥ 1once 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_urlon 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>(partialcourses::blocks.image, the same as the lesson block) with a link signed forcourse:<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 forcourse:<slug>, which courses answers withCourses::canAccess(); private-media checks it again when the file is fetched. The format is the file extension. Without private-mediacourses:installleaves 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 lessonimageblock 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

