{"slug":"lw-lms-backend-extend","title":"lw-lms-backend-extend","summary":"Backend extension contract for LW LMS v1.6.0. Use when extending enrollment, access, source-scoped revocation, progress, certificates, automation, analytics, settings tabs, companion-plugin logic, `lw_lms_after_grant`, `lw_lms_after_revoke`, `lw_lms_pre_grant`, `lw_lms_has_course","platform":"Claude","tags":[],"authorName":"LLM Mart","authorSlug":"llm-mart","score":0,"source":"github","price":null,"verified":false,"createdAt":"2026-09-16T14:52:00.773235Z","repo":{"url":"https://github.com/Lonsdale201/wp-agent-skills","stars":22,"forks":2,"license":"MIT","updatedAt":"2026-09-26T23:03:36Z"},"bodyHtml":"<hr>\n<h2>name: lw-lms-backend-extend\ndescription: Backend extension contract for LW LMS v1.6.0. Use when extending enrollment, access, source-scoped revocation, progress, certificates, automation, analytics, settings tabs, companion-plugin logic, <code>lw_lms_after_grant</code>, <code>lw_lms_after_revoke</code>, <code>lw_lms_pre_grant</code>, <code>lw_lms_has_course_access</code>, <code>AccessChecker</code>, <code>AccessRepository</code>, <code>AccessQueries</code>, <code>ProgressRepository</code>, <code>ProgressQueries</code>, <code>CompletionTracker</code>, <code>wp_lms_progress</code>, <code>wp_lms_access</code>, <code>_lw_lms_*</code> meta, or WooCommerce Memberships/Subscriptions access.\nmetadata:\nwp-skills-author: \"Soczó Kristóf\"\nwp-skills-contact: \"mailto:lonsdale201@hotmail.com\"\nwp-skills-plugin: \"lw-lms\"\nwp-skills-plugin-version-tested: \"1.6.0\"\nwp-skills-php-min: \"8.2\"\nwp-skills-last-updated: \"2026-07-20\"</h2>\n<h1>LW LMS: backend extension contract</h1>\n<p>For companion plugins or themes extending LW LMS from PHP: enrollment automation, certificates, progress writes, access checks, custom settings tabs, analytics, admin tooling, and integrations with WooCommerce, WooCommerce Subscriptions, or WooCommerce Memberships.</p>\n<blockquote>\n<p><strong>BETA NOTICE.</strong> The plugin README says the plugin is under active development and not recommended for production use. Pin a tested version and review <code>CHANGELOG.md</code> before upgrading. This skill is verified against local lw-lms <strong>v1.6.0</strong>.</p>\n</blockquote>\n<h2>Version deltas that matter</h2>\n<ul>\n<li><strong>v1.6.0</strong>: <code>lw_lms_has_course_access</code> is reachable again as the final logged-in paid-course decision after all built-in checks. <code>AccessRepository::revoke_by_source()</code> adds source-scoped stored-access revocation. Minimum PHP is now 8.2.</li>\n<li><strong>v1.5.1</strong>: maintenance release, no functional changes.</li>\n<li><strong>v1.5.0</strong>: WooCommerce Memberships access. Paid courses can link membership plans through <code>_lw_lms_membership_plan_ids</code>. Active members get access at read time through <code>MembershipChecker</code>; no DB schema change and no access row is written.</li>\n<li><strong>v1.4.0</strong>: WP-CLI operational workflow added. Use <code>lw-lms-wp-cli-operations</code> for those commands.</li>\n<li><strong>v1.4.0</strong>: <code>lw_lms_settings_tabs</code> filter and <code>SettingsPage::get_settings_group()</code> added for companion settings tabs and shared <code>options.php</code> saving.</li>\n<li><strong>v1.4.0</strong>: course REST <code>content</code> is public; only lesson content remains access-gated.</li>\n<li><strong>v1.3.0</strong>: enrollment/progress hook contract added or centralized: <code>lw_lms_pre_grant</code>, <code>lw_lms_after_grant</code>, <code>lw_lms_after_revoke</code>, <code>ProgressRepository::mark_course_completed()</code>, read/write splits.</li>\n</ul>\n<h2>v1.6.0 course-access filter contract</h2>\n<p><code>AccessChecker::has_course_access()</code> now reaches <code>lw_lms_has_course_access</code> after the complete built-in paid-course cascade: access rows, parent subscriptions, variation subscriptions, WooCommerce Memberships, then legacy purchases. The callback receives the aggregate built-in result and has final say, so it can grant or deliberately deny logged-in paid-course access.</p>\n<p>The filter is not universal:</p>\n<ul>\n<li><code>open</code> returns <code>true</code> before the filter;</li>\n<li>anonymous <code>free</code> or <code>paid</code> access returns <code>false</code> before the filter;</li>\n<li><code>free</code> access for a logged-in user lazily grants <code>source='free'</code> and returns <code>true</code> before the filter;</li>\n<li>logged-in <code>paid</code> access reaches the filter after all built-in checks.</li>\n</ul>\n<p>Preserve a built-in grant unless the integration intentionally implements a denial policy:</p>\n<pre><code>add_filter(\n    'lw_lms_has_course_access',\n    static function ( bool $has_access, int $course_id, int $user_id ): bool {\n        if ( $has_access ) {\n            return true;\n        }\n\n        return MyMembership::has_course_access( $user_id, $course_id );\n    },\n    10,\n    3\n);\n</code></pre>\n<p>This is a runtime decision only: returning <code>true</code> does not create an access row and does not fire grant/revoke actions. Keep the callback deterministic, side-effect-free, and fast. <code>CourseTransformer::transform_full()</code> currently calls <code>has_course_access()</code> directly and again through <code>get_access_info()</code>, so the filter runs twice while producing one full paid-course response. A time-varying result can make <code>access.has_access</code> disagree with lesson/attachment gating.</p>\n<p>Use <code>AccessRepository::grant()</code> instead when the entitlement should be durable, auditable, expirable, or should fire enrollment automation.</p>\n<p><code>lw_lms_has_lesson_access</code> is more useful, but it is still not universal: it fires for open-course lessons and the normal course-access branch, but preview lessons return before that filter.</p>\n<h2>Detailed contract reference</h2>\n<p>Read <a href=\"references/backend-contract-details.md\">references/backend-contract-details.md</a> when the task needs the plugin identity, CPT/taxonomy/table/meta map, exact repository read/write examples, settings-tab implementation, or expanded wrong/right examples. The access-filter, hook, workflow, and safety rules needed for normal extension work remain below.</p>\n<h2>Hooks</h2>\n<h3>Actions</h3>\n<table>\n<thead>\n<tr>\n<th>Hook</th>\n<th>Args</th>\n<th>Fires</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code>lw_lms_after_grant</code></td>\n<td><code>$user_id, $course_id, $source, $source_id, $expires_at</code> (5)</td>\n<td>After <code>AccessRepository::grant()</code> inserts or updates successfully</td>\n</tr>\n<tr>\n<td><code>lw_lms_after_revoke</code></td>\n<td><code>$user_id, $course_id, $source</code> (3)</td>\n<td>After <code>revoke()</code> changes its first active row, or once after <code>revoke_by_source()</code> changes one or more matching rows</td>\n</tr>\n<tr>\n<td><code>lw_lms_lesson_completed</code></td>\n<td><code>$lesson_id, $user_id</code> (2)</td>\n<td>When <code>ProgressRepository::upsert()</code> transitions a lesson to <code>completed</code></td>\n</tr>\n<tr>\n<td><code>lw_lms_course_completed</code></td>\n<td><code>$course_id, $user_id</code> (2)</td>\n<td>Once, when <code>CompletionTracker::maybe_record()</code> writes the completion snapshot</td>\n</tr>\n<tr>\n<td><code>lw_lms_attachment_downloaded</code></td>\n<td><code>$attachment_id, $user_id</code> (2)</td>\n<td>After a protected attachment download passes access checks</td>\n</tr>\n</tbody>\n</table>\n<h3>Filters</h3>\n<table>\n<thead>\n<tr>\n<th>Hook</th>\n<th>Args</th>\n<th>Caveat</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code>lw_lms_pre_grant</code></td>\n<td><code>$allow, $user_id, $course_id, $source, $source_id, $expires_at</code> (6)</td>\n<td>Return <code>false</code> to abort <code>AccessRepository::grant()</code> before DB write</td>\n</tr>\n<tr>\n<td><code>lw_lms_has_course_access</code></td>\n<td><code>$has_access, $course_id, $user_id</code> (3)</td>\n<td>Final logged-in paid-course result in v1.6.0; open/free/anonymous paths return before it; may execute more than once per request</td>\n</tr>\n<tr>\n<td><code>lw_lms_has_lesson_access</code></td>\n<td><code>$has_access, $lesson_id, $user_id</code> (3)</td>\n<td>Not reached for preview-lesson short-circuit</td>\n</tr>\n<tr>\n<td><code>lw_lms_settings_tabs</code></td>\n<td><code>array&lt;TabInterface&gt; $tabs</code> (1)</td>\n<td>Add/remove/reorder settings tabs; non-<code>TabInterface</code> values are dropped</td>\n</tr>\n</tbody>\n</table>\n<p>Always register callbacks with the right accepted-args value:</p>\n<pre><code>add_action( 'lw_lms_after_grant', 'my_enrollment_handler', 10, 5 );\nadd_action( 'lw_lms_after_revoke', 'my_revoke_handler', 10, 3 );\nadd_filter( 'lw_lms_pre_grant', 'my_pre_grant_guard', 10, 6 );\n</code></pre>\n<h2>Access paths</h2>\n<table>\n<thead>\n<tr>\n<th>Path</th>\n<th style=\"text-align: right\">Stored row?</th>\n<th style=\"text-align: right\">Fires <code>lw_lms_after_grant</code>?</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>WooCommerce completed order through <code>AccessGranter</code></td>\n<td style=\"text-align: right\">yes, <code>source='woocommerce'</code></td>\n<td style=\"text-align: right\">yes</td>\n</tr>\n<tr>\n<td>Admin user-profile grant</td>\n<td style=\"text-align: right\">yes, <code>source='manual'</code></td>\n<td style=\"text-align: right\">yes</td>\n</tr>\n<tr>\n<td>Free course first access by logged-in user</td>\n<td style=\"text-align: right\">yes, <code>source='free'</code></td>\n<td style=\"text-align: right\">yes</td>\n</tr>\n<tr>\n<td>Programmatic <code>AccessRepository::grant()</code></td>\n<td style=\"text-align: right\">yes</td>\n<td style=\"text-align: right\">yes</td>\n</tr>\n<tr>\n<td>Parent WC subscription active check</td>\n<td style=\"text-align: right\">no</td>\n<td style=\"text-align: right\">no</td>\n</tr>\n<tr>\n<td>Variation-level WC subscription active check</td>\n<td style=\"text-align: right\">no</td>\n<td style=\"text-align: right\">no</td>\n</tr>\n<tr>\n<td>WooCommerce Memberships active member check</td>\n<td style=\"text-align: right\">no</td>\n<td style=\"text-align: right\">no</td>\n</tr>\n<tr>\n<td>Legacy WooCommerce purchase fallback</td>\n<td style=\"text-align: right\">no</td>\n<td style=\"text-align: right\">no</td>\n</tr>\n</tbody>\n</table>\n<p>If downstream automation must react to subscriptions or memberships, hook WooCommerce Subscriptions or WooCommerce Memberships lifecycle events directly, or write an access row yourself through <code>AccessRepository::grant()</code> when your integration decides access should become durable.</p>\n<h2>Common workflows</h2>\n<h3>React to enrollment</h3>\n<pre><code>add_action(\n    'lw_lms_after_grant',\n    static function ( int $user_id, int $course_id, string $source, ?int $source_id, ?string $expires_at ): void {\n        MyAnalytics::track( 'lms_enrolled', compact( 'user_id', 'course_id', 'source' ) );\n        MyDripScheduler::start( $user_id, $course_id );\n    },\n    10,\n    5\n);\n</code></pre>\n<p>This catches Woo completed orders, admin manual grants, free-course lazy grants, and your own <code>AccessRepository::grant()</code> calls. It does not catch live subscription, membership, or legacy purchase access checks.</p>\n<h3>Abort a grant</h3>\n<pre><code>add_filter(\n    'lw_lms_pre_grant',\n    static function ( bool $allow, int $user_id, int $course_id, string $source, ?int $source_id, ?string $expires_at ): bool {\n        if ( ! $allow ) {\n            return false;\n        }\n\n        if ( MySeats::is_full( $course_id ) ) {\n            return false;\n        }\n\n        return true;\n    },\n    10,\n    6\n);\n</code></pre>\n<p>Returning <code>false</code> prevents the DB write and prevents <code>lw_lms_after_grant</code>.</p>\n<h3>Issue a certificate once</h3>\n<pre><code>add_action( 'lw_lms_course_completed', static function ( int $course_id, int $user_id ): void {\n    MyCertificateGenerator::issue( $user_id, $course_id );\n}, 10, 2 );\n</code></pre>\n<p>The completion snapshot makes this a one-shot event per user/course pair.</p>\n<h3>Give runtime access from another membership system</h3>\n<pre><code>add_filter(\n    'lw_lms_has_course_access',\n    static function ( bool $has_access, int $course_id, int $user_id ): bool {\n        return $has_access || MyMembership::has_course_access( $user_id, $course_id );\n    },\n    10,\n    3\n);\n</code></pre>\n<p>Use this only for a fast, deterministic live check. It affects logged-in paid access but writes no enrollment row and fires no enrollment lifecycle action.</p>\n<h3>Persist access from another membership system</h3>\n<pre><code>if ( MyMembership::user_joined_plan( $user_id, 'pro' ) ) {\n    \\LightweightPlugins\\LMS\\Access\\AccessRepository::grant(\n        $user_id,\n        $course_id,\n        'my_membership',\n        MyMembership::membership_id( $user_id ),\n        null\n    );\n}\n</code></pre>\n<p>When that membership ends, revoke only the row owned by the integration:</p>\n<pre><code>\\LightweightPlugins\\LMS\\Access\\AccessRepository::revoke_by_source(\n    $user_id,\n    $course_id,\n    'my_membership',\n    MyMembership::membership_id( $user_id )\n);\n</code></pre>\n<h2>Critical rules</h2>\n<ul>\n<li>Use <code>AccessRepository::grant()</code> and <code>revoke_by_source()</code> for integration-owned stored access changes. The broad <code>revoke()</code> changes only the first active row regardless of origin. Direct SQL skips hooks.</li>\n<li>Use <code>ProgressRepository::upsert()</code> and <code>mark_course_completed()</code> for progress changes. Direct SQL skips completion hooks and snapshots.</li>\n<li>Do not call old read methods on repositories. Reads live in <code>AccessQueries</code> and <code>ProgressQueries</code>.</li>\n<li><code>lw_lms_after_grant</code> needs 5 accepted args; <code>lw_lms_pre_grant</code> needs 6; <code>lw_lms_after_revoke</code> needs 3.</li>\n<li>Subscriptions, subscription variations, memberships, and legacy purchases are live checks unless your integration writes an access row.</li>\n<li>The access table unique key and <code>grant()</code> lookup ignore <code>source</code>; use a stable, collision-resistant non-null <code>source_id</code> for external grants.</li>\n<li><code>expires_at</code> is enforced on read by <code>AccessQueries::has_active_access()</code>. No expiry cron fires <code>lw_lms_after_revoke</code>.</li>\n<li><code>ProgressRepository::delete()</code> does not delete completion snapshots. If an admin reset must also undo completion, call <code>ProgressSnapshotRepository::delete()</code> deliberately.</li>\n<li><code>lw_lms_has_course_access</code> is the final logged-in paid-course decision in v1.6.0, but open/free/anonymous paths bypass it. Keep it deterministic, side-effect-free, and cheap.</li>\n<li><code>revoke_by_source( ..., $source, null )</code> revokes every active row for that source, not only null-source-ID rows.</li>\n<li>Add companion settings through <code>lw_lms_settings_tabs</code> plus <code>SettingsPage::get_settings_group()</code>, not a second unrelated form.</li>\n</ul>\n<h2>Cross-references</h2>\n<ul>\n<li>Use <code>lw-lms-rest-frontend</code> for learner-facing <code>/wp-json/lms/v1</code> consumers.</li>\n<li>Use <code>lw-lms-abilities</code> for admin/agent <code>lw-lms/*</code> Abilities API calls.</li>\n<li>Use <code>lw-lms-wp-cli-operations</code> for operational CLI commands added in v1.4.0.</li>\n<li>Use <code>lw-lms-learndash-migration</code> for the one-time LearnDash migration.</li>\n<li>Use WooCommerce Subscriptions/Memberships specific skills when reacting to their lifecycle events.</li>\n</ul>\n<h2>What this skill does NOT cover</h2>\n<ul>\n<li>Public frontend rendering. Core lw-lms is headless.</li>\n<li>LearnDash import details.</li>\n<li>Custom REST route registration.</li>\n<li>Replacing the plugin's access calculator or progress calculator.</li>\n<li>Treating <code>WooCommerceChecker</code>, <code>SubscriptionVariationChecker</code>, or <code>MembershipChecker</code> as stable public services. Prefer <code>AccessChecker</code> or a stored grant.</li>\n</ul>\n<h2>References</h2>\n<ul>\n<li>Plugin entry: <code>lw-lms.php</code>.</li>\n<li>Main wiring: <code>includes/Plugin.php</code>.</li>\n<li>DB/tables/caps: <code>includes/Activator.php</code>, <code>includes/Access/AccessTable.php</code>, progress table classes.</li>\n<li>Access cascade and current filter placement: <code>includes/Access/AccessChecker.php</code>.</li>\n<li>Stored access writes: <code>includes/Access/AccessRepository.php</code>.</li>\n<li>Stored access reads: <code>includes/Access/AccessQueries.php</code>.</li>\n<li>Woo order grants: <code>includes/Access/AccessGranter.php</code>.</li>\n<li>Subscriptions and memberships: <code>includes/Access/WooCommerceChecker.php</code>, <code>SubscriptionVariationChecker.php</code>, <code>MembershipChecker.php</code>.</li>\n<li>Progress writes and hooks: <code>includes/Progress/ProgressRepository.php</code>, <code>CompletionTracker.php</code>.</li>\n<li>Settings extension: <code>includes/Admin/SettingsPage.php</code>, <code>includes/Admin/Settings/TabInterface.php</code>.</li>\n<li>Changelog source of version deltas: <code>CHANGELOG.md</code>.</li>\n<li>Official documentation: <a href=\"https://github.com/lwplugins/lw-lms\">https://github.com/lwplugins/lw-lms</a></li>\n<li>Verified source paths:\n<ul>\n<li><code>wp-content/plugins/lw-lms/includes/Options.php</code></li>\n<li><code>wp-content/plugins/lw-lms/includes/Admin/UserProfile.php</code></li>\n<li><code>wp-content/plugins/lw-lms/includes/Admin/UserProfile/EnrollmentHandler.php</code></li>\n<li><code>wp-content/plugins/lw-lms/includes/Meta/CourseMeta.php</code></li>\n<li><code>wp-content/plugins/lw-lms/includes/Meta/LessonMeta.php</code></li>\n<li><code>wp-content/plugins/lw-lms/includes/Meta/SubscriptionVariationMeta.php</code></li>\n<li><code>wp-content/plugins/lw-lms/includes/Progress/ProgressQueries.php</code></li>\n<li><code>wp-content/plugins/lw-lms/includes/Progress/ProgressCalculator.php</code></li>\n<li><code>wp-content/plugins/lw-lms/includes/Progress/ProgressSnapshotRepository.php</code></li>\n<li><code>wp-content/plugins/lw-lms/includes/Progress/ProgressSnapshotTable.php</code></li>\n<li><code>wp-content/plugins/lw-lms/includes/Progress/ProgressSnapshotMigration.php</code></li>\n<li><code>wp-content/plugins/lw-lms/includes/Api/Controllers/ProgressController.php</code></li>\n<li><code>wp-content/plugins/lw-lms/includes/Api/Controllers/DownloadController.php</code></li>\n<li><code>wp-content/plugins/lw-lms/includes/SiteManager/Integration.php</code></li>\n</ul>\n</li>\n</ul>\n","files":[{"path":"references/backend-contract-details.md","sizeBytes":2836,"isText":true},{"path":"SKILL.md","sizeBytes":8282,"isText":true}],"reviewScore":null,"reviewSummary":null,"trust":{"provenance":"trusted-source-unreviewed","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow.","bodySource":null},"bodyLocked":false,"purchaseUrl":null,"sourceUrl":null,"report":{"provenance":"trusted-source-unreviewed","screen":{"ran":true,"outcome":"clean","suspicious":0,"notes":0,"hiddenCharacters":false},"virusScan":{"engine":"clamav","status":"clean","scannedAt":"2026-09-29T23:41:14.427144Z","sha256":"EA45D1284E8D1ABF5308B718B03F3C2387DB5046850F759731795B0C29B6A547","sizeBytes":4846},"review":null,"source":{"repositoryUrl":"https://github.com/Lonsdale201/wp-agent-skills","path":"lw-plugins/lw-lms-backend-extend","license":"MIT","commit":"c51b571a259f0c4b5f5c0a3bc50ed580c6851f98","subtreeSha":"D460403E32E7C305D289AC7E1BF498CCF07DBFC46859599E97AF9A92E35C1560","lastSyncedAt":"2026-09-29T23:33:03.303675Z"},"reviewedAt":"2026-09-29T23:45:35.710787Z","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow."},"install":[{"target":"skills-cli","command":"npx skills add https://github.com/Lonsdale201/wp-agent-skills/tree/main/lw-plugins/lw-lms-backend-extend"},{"target":"claude-code","command":"claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lonsdale201-wp-agent-skills@llmmart"},{"target":"git","command":"git clone https://github.com/Lonsdale201/wp-agent-skills.git"}]}