- The
lw_lms_has_course_accessfilter is now reachable for paid courses. Previously the paid branch returned the legacy-purchase result before the filter line, so an integration could never grant (or deny) paid access through it. The built-in paid checks now short-circuit into a single filtered result.
AccessRepository::revoke_by_source( int $user_id, int $course_id, string $source, ?int $source_id = null )— revokes only the access rows a given grant source owns, so an integration can cancel its own grant without trampling another source's access for the same course.
- Minimum PHP is now 8.2.
- Added PHPStan level 5 static analysis and a PHPUnit test suite (with an access-control regression test) to CI.
- Maintenance release — internal housekeeping, no functional changes.
- WooCommerce Memberships integration: a
paidcourse can be unlocked by an active WooCommerce Membership. Newmembership_plan_idscourse meta, selectable as a checkbox list in the Access Settings metabox.AccessCheckergrants access when the user is an active member of any selected plan (live check viawc_memberships_is_user_active_member, after the subscription checks and before the legacy-purchase fallback). The deniedaccessREST payload now includes amembershipsarray (planid,name, and ajoinURL). No DB schema change; no-op when WooCommerce Memberships is inactive.
- WP-CLI workflow (issue #10):
wp lw-lms course create|list|delete|set-sectionwp lw-lms lesson create|list|assignwp lw-lms enroll <user> <course> [--source=…] [--expires-at=…]wp lw-lms revoke <user> <course>wp lw-lms force-complete <user> <course>- User refs accept ID, login, or email; course/lesson refs accept ID or slug. Each subcommand lives in its own
CLI/*Command.phpfile (max 200 lines).
lw_lms_settings_tabsfilter — third-party plugins can append, remove, or reorder admin Settings tabs by returning a modified list ofTabInterfaceinstances. Issue #12.SettingsPage::get_settings_group()— exposes the settings group identifier so companion plugins canregister_setting()against the same group and save options through the existing form (single nonce, single submit).
- Course
contentis no longer gated behindhas_accessin the RESTtransform_fullresponse. The coursepost_contentis the public marketing/about description; per-lesson content stays gated by the lessonaccessibleflag and the lessons REST endpoint.content_raw(unfiltered source) remains editor-only. Issue #13. AccessChecker::has_lesson_access()now short-circuits onACCESS_OPENbefore consulting the preview-lesson branch, so open-course lessons remain accessible to guests even when marked as preview. Previously, preview lessons on open courses wrongly required login. Issue #11.
lw_lms_after_grantaction — fires after access is granted (issue #9). Args:user_id,course_id,source,source_id,expires_at(5 args, callers must register with$accepted_args = 5).lw_lms_after_revokeaction — fires only when an active row is actually flipped to revoked. Args:user_id,course_id,source(3 args).lw_lms_pre_grantfilter — return false to abort a grant before any DB work. Args:$allow,user_id,course_id,source,source_id,expires_at(6 args).- Free-course implicit enrollment. First time a logged-in user accesses a free course, a
source='free'access row is inserted (idempotent), solw_lms_after_grantfires for free enrollments and downstream automation (drip / welcome email / cohort analytics) can listen to a single grant signal. AccessQueries::has_active_access( user_id, course_id, source = null )— optional$sourceargument for source-specific active-access checks ('free','manual','woocommerce','subscription').ProgressRepository::mark_course_completed( user_id, course_id )— enumerates published lessons assigned to the course and upserts each as completed. The final upsert naturally fireslw_lms_lesson_completedandlw_lms_course_completed.
lw_lms_lesson_completedandlw_lms_course_completedare now fired centrally byProgressRepository::upsert()andCompletionTracker::maybe_record()respectively, instead of by individual REST endpoints. Existing 2-arg signatures (lesson_id, user_idandcourse_id, user_id) are preserved.AccessRepositorysplit intoAccessRepository(writes:grant,revoke) andAccessQueries(reads:has_active_access,get_user_access,get_user_enrollments). Each class stays within the 200-line limit. Direct callers of the read methods onAccessRepositoryshould migrate toAccessQueries.ProgressRepositorysplit intoProgressRepository(writes:upsert,delete,mark_course_completed) andProgressQueries(reads:get,get_course_progress,get_user_progress,get_completed_lessons). Direct callers of the read methods onProgressRepositoryshould migrate toProgressQueries.
- Standalone Abilities API support.
Integrationnow registers a fallback on the officialwp_abilities_api_categories_initandwp_abilities_api_inithooks (priority 20, gated bydid_action()so the Site Manager bridge wins when active). Abilities are usable with only the WordPress 6.9+ Abilities API or the feature plugin — no Site Manager required. - Detailed
output_schemadefinitions for every ability (OutputSchemas): course summary fragment, full course with sections + lessons, per-lesson progress map with status enum, options. Lets AI agents introspect the exact response shape instead of guessing from'type' => 'array'. list-coursesresponse includestotal_pages,page, andper_pagealongsidetotal.AbilityPermissionsfactory: prefers Site Manager'sPermissionManagerwhen injected (via thelw_site_manager_register_abilitiesbridge), falls back to acurrent_user_can()map (can_edit_posts→edit_posts,can_manage_options→manage_options,can_edit_users→edit_users) when registering directly.
- SiteManager folder restructured into focused
Abilities/,Service/, andSchema/namespaces.LmsAbilitiesandLmsServicesplit per concern: course / progress / options. set-progressdescription explicitly notes the destructive aspect: reverting from completed loses the completion timestamp.
- Variation-level WooCommerce Subscriptions access (issue #8). New
subscription_variation_idscourse meta acceptsparent_id:variation_idpairs, so a course can be tied to specific variations of a variable-subscription product (e.g. only the "Yearly" variation grants access, not "Monthly"). Implemented in the newSubscriptionVariationChecker(runtime check viawcs_get_users_subscriptions()againstactivesubscriptions; matches on the variation ID line item, not the parent). Existing parent-levelsubscription_idsbehaviour is unchanged and still evaluated first. - New
Subscription Variationsfield in the Course Access metabox — textarea, oneparent_id:variation_idper line. AccessChecker::get_access_info()now includes asubscription_variationsarray for paid courses without access (parent_id, variation_id, name, attributes, price, url) so REST clients can render variation-specific upsells.
AccessMetaboxRendererconverted from trait to a static helper class (was 78 lines as a trait, would have exceeded the 80-line trait limit with the new render method).
- Lock-on-complete progress snapshot (issue #7). When a user first reaches 100% in a course, the lesson count is captured and frozen for that user × course pair. Adding a lesson to the course later no longer demotes completed users from 100% — they stay at 100%, and the new lesson is "extra material". Users still in progress see the current (larger) total and a freshly-recalculated percentage.
- New
wp_lms_completion_snapshotstable (user_id,course_id,total_lessons,completed_at, UNIQUE onuser_id, course_id). - New
ProgressSnapshotTable,ProgressSnapshotRepository,CompletionTracker,ProgressSnapshotMigrationclasses. ProgressRepository::upsert()now triggersCompletionTracker::maybe_record()after every status change, so the snapshot is written exactly once at the moment of completion.- Activation migration (
ProgressSnapshotMigration::backfill()) retroactively writes a snapshot for every user × course pair already at 100% inwp_lms_progress. Idempotent — safe to call on every activation.
- New
- DB version bumped to
1.2.0to trigger the migration on update.
ProgressCalculator::calculate()now resolves the total via the snapshot when one exists, otherwise falls back to the current course size.- Percentage is now clamped to 100% defensively.
- Course builder drag&drop now persists
lesson_section_idandlesson_orderfor every lesson on save (issue #3). Previously only thecourse_sectionsarray was written, leaving the lessons' meta untouched, which caused two bugs: (a) the frontend / REST response went out of sync with the editor, and (b) a "drag → drag back" gesture left orphan state because the second move never triggered a re-save. The newLessonAssignmentSaverreads the JSON payload emitted by the JS on every change (and on init) and updates the metas in one pass, defensively skipping lessons not actually attached to the course being saved.
- All Lessons admin list now has two new columns (issue #5):
- Order — shows
lesson_order, sortable - Course — clickable link to the parent course's editor
- Order — shows
CourseContentMetaboxwas split into a coordinator +CourseContentRenderer+LessonAssignmentSaverto keep each file under 200 lines
ProgressCalculatorclass doc now explicitly states the known limitation that adding a lesson to a course mid-progress does not retroactively recalculate users' completion percentages (issue #7); LearnDash has the same behaviour
- LW Site Manager integration - LMS abilities for AI agents
lw-lms/list-coursesability - list courseslw-lms/get-courseability - get course details with lessonslw-lms/get-progressability - get user progresslw-lms/set-progressability - update lesson completionlw-lms/get-optionsability - get LMS settings
list-coursesinput schema now accepts empty requests
- Smarter autoloader fallback - supports root Composer dependency installs
- Graceful error when autoloader is missing (admin notice instead of fatal error)
- Minor fix
- Hash-based tab navigation on settings page
- New chalkboard-user icon
- Updated ParentPage with SVG icon support from registry
- Suppressed expected PHPCS warnings for custom capabilities and meta queries
- Admin notice isolation for notices relocated by WordPress core JS
- Isolate third-party admin notices on LW plugin pages
- Fresh POT file and Hungarian (hu_HU) translation
- Central plugin registry from GitHub JSON
- WP-CLI LearnDash migration command (
wp lw-lms migrate-learndash) - Support for
--dry-runand--verboseflags - Migrates courses, lessons, sections, and lesson order
- Instructor field to Course Data metabox
- Instructor meta registered with REST API support
- Improved Add Enrollment form with explicit Course and Expires labels
- Manual course enrollment on user profile pages (wp-admin)
- Enrollment table with course name, source, granted date, and expiry
- Course grant/revoke actions with nonce and capability protection
AccessRepository::get_user_enrollments()method
- Auto-create access table on plugin update (not just activation)
- Time-limited course access (per-product duration)
wp_lms_accessdatabase table for fast access lookups- AccessGranter - automatic access on WooCommerce order completion
product_id:daysformat in Course Access metaboxexpires_atto REST API access infoaccess_durationto product info in REST API- Backward-compatible fallback for legacy purchases
- DB version updated to 1.1.0
- Initial release
- Course and Lesson custom post types
- Course sections and lesson ordering
- Access control (open, free, paid)
- WooCommerce integration
- Progress tracking
- REST API
- Video support (YouTube, Vimeo, Wistia, self-hosted)
- Attachments and downloads