# Mammoth WP: WordPress plugins, add-ons and developer tools. WordPress plugins, add-ons and developer tools. Open source, no tracking in the plugins, written for people who read the source. ## Plugins - [MammothWP Acceptance Logs](https://mammothwp.com/plugins/mammoth-acceptance-logs/): A record of who accepted which version of your terms, and when. Free on wordpress.org. ## Developer tools - support: `composer require mammothwp/support` (version 1.1.1, released 31 October 2026, PHP 8.1 or later, GPL-2.0-or-later, 261 downloads) - wp-kit: `composer require mammothwp/wp-kit` (version 2.0.2, released 31 October 2026, PHP 7.4 or later, GPL-2.0-or-later, 356 downloads) - wp-release: `composer require mammothwp/wp-release --dev` (version 1.0.1, released 31 October 2026, PHP 8.1 or later, GPL-2.0-or-later, 222 downloads) - wp-self-test: `composer require mammothwp/wp-self-test` (version 2.2.5, released 31 October 2026, PHP 7.4 or later, GPL-2.0-or-later, 328 downloads) --- # Plugins - [MammothWP Acceptance Logs](https://mammothwp.com/plugins/mammoth-acceptance-logs/): A record of who accepted which version of your terms, and when. Free on wordpress.org. --- # Packages - support: `composer require mammothwp/support` (version 1.1.1, released 31 October 2026, PHP 8.1 or later, GPL-2.0-or-later, 261 downloads) - wp-kit: `composer require mammothwp/wp-kit` (version 2.0.2, released 31 October 2026, PHP 7.4 or later, GPL-2.0-or-later, 356 downloads) - wp-release: `composer require mammothwp/wp-release --dev` (version 1.0.1, released 31 October 2026, PHP 8.1 or later, GPL-2.0-or-later, 222 downloads) - wp-self-test: `composer require mammothwp/wp-self-test` (version 2.2.5, released 31 October 2026, PHP 7.4 or later, GPL-2.0-or-later, 328 downloads) --- # MammothWP Acceptance Logs A record of who accepted which version of your terms, and when. Free on wordpress.org. Does not: It is not a consent plugin. Recording acceptance of terms is an access-control and contractual-compliance mechanism (GDPR Art. 6(1)(b) and 6(1)(f)), not consent under Art. 6(1)(a). Acceptance is therefore not withdrawable and records are not deleted on request. - Status: Available - Version: 1.0.0 - Last updated: 31 October 2026 - Requires WordPress: 6.2 or later - Requires PHP: 8.1 or later - Tested up to: 7.1.4 - Download: https://downloads.wordpress.org/plugin/mammoth-acceptance-logs.1.0.0.zip - Composer: composer require wpackagist-plugin/mammoth-acceptance-logs ## Overview MammothWP Acceptance Logs asks logged-in users in the roles you choose to accept your Terms & Conditions, and keeps a verifiable record of who accepted which version, and when. - Versioned archive. Every published version is stored in full and never deleted, so each acceptance can be tied to the exact text shown. - Tamper-evident log. Each record is signed twice (immutable core, erasable context) and chained to the previous one. A verifier on the log screen flags altered rows. - Role-scoped prompt. A popup with "skip for now" and a persistent notice until the user accepts. - Receipts. An emailed PDF copy of what was accepted (optional, needs the bundled PDF library). - GDPR tooling. Personal-data exporter and eraser, retention-based anonymisation, and suggested privacy policy text. - Manage it your way. Admin screens, a REST API (mmwp/v1), WP-CLI (wp mmwp terms) and the WordPress Abilities API. - A public block that shows the current terms, effective date and history. The plugin makes no outbound requests, loads no remote scripts, fonts or images, and sends no telemetry. It sends email only through your site's normal wp_mail(): the acceptance receipt, and a support request if an administrator presses Send on the Support screen. ## Install from source 1. Download the ZIP of the main branch from github.com/mammothwp/admin-terms-acceptance (Code > Download ZIP). 2. Unzip it and rename the folder to mammothwp-acceptance-logs if GitHub named it admin-terms-acceptance-main, then zip it again. 3. In wp-admin, go to Plugins > Add New > Upload Plugin and choose the ZIP, then activate the plugin. The GitHub ZIP (not the wordpress.org release) is the source tree and does not bundle its Composer dependencies. The acceptance flow and audit trail work without them; the REST, WP-CLI and Abilities surfaces, the Support screen and PDF receipts need them. To get all of that, run composer install --no-dev --optimize-autoloader inside the plugin folder before zipping. ## Install In wp-admin: Plugins > Add New, search for "MammothWP Acceptance Logs", install and activate. Then go to Settings > Terms & Conditions, write your terms, choose which roles must accept them, then publish the version. ## Usage Users in the selected roles are prompted the next time they load the site or the dashboard while logged in. Whoever activated the plugin becomes its first manager; managers are the only people who can read the acceptance log, under Users > Terms acceptance log. ## Privacy & data - Stores: Every published version of your terms in full, and a signed, chained log of who accepted which version and when. - Sends: Nothing. The plugin makes no outbound requests and sends no telemetry. Email goes only through your site's wp_mail(): the acceptance receipt, and a support request if an administrator presses Send. - On uninstall: Settings, transients and scheduled hooks are removed. The acceptance evidence is kept unless the site owner chose to delete it. ## Changelog ### 1.0.0 - First release on wordpress.org. See CHANGELOG.md for the full history. ## Support Email info@mammothwp.com, or use the Mammoth WP > Support screen in wp-admin. Nothing is sent unless an administrator presses Send, and a site report is attached only if they tick the box after previewing it. Developer docs: https://mammothwp.com/docs/mammoth-acceptance-logs/ --- # Docs Developer documentation for Mammoth WP products. Hooks, routes and commands are read from the public GitHub source at build time. ## MammothWP Acceptance Logs A record of who accepted which version of your terms, and when. Free on wordpress.org. - [Hooks & filters](https://mammothwp.com/docs/mammoth-acceptance-logs/hooks/) - [REST & Abilities](https://mammothwp.com/docs/mammoth-acceptance-logs/rest/) - [Command line](https://mammothwp.com/docs/mammoth-acceptance-logs/cli/) - [Use cases](https://mammothwp.com/docs/mammoth-acceptance-logs/use-cases/) ## support Shared support screen for Mammoth WP plugins: wp_mail tickets with an opt-in site report. - [Hooks & filters](https://mammothwp.com/docs/support/hooks/) - [REST & Abilities](https://mammothwp.com/docs/support/rest/) - [Command line](https://mammothwp.com/docs/support/cli/) - [Use cases](https://mammothwp.com/docs/support/use-cases/) ## wp-kit Declare a feature once and expose it as a REST route, a WP-CLI command and a WordPress Ability. - [Hooks & filters](https://mammothwp.com/docs/wp-kit/hooks/) - [Use cases](https://mammothwp.com/docs/wp-kit/use-cases/) ## wp-release Build, check and ship a plugin release: production zip, Plugin Check, wordpress.org SVN deploy. - [Command line](https://mammothwp.com/docs/wp-release/cli/) - [Use cases](https://mammothwp.com/docs/wp-release/use-cases/) ## wp-self-test Manifest-driven self-test suite for plugin REST, WP-CLI and Abilities checks. - [Hooks & filters](https://mammothwp.com/docs/wp-self-test/hooks/) - [REST & Abilities](https://mammothwp.com/docs/wp-self-test/rest/) - [Command line](https://mammothwp.com/docs/wp-self-test/cli/) - [Use cases](https://mammothwp.com/docs/wp-self-test/use-cases/) --- # MammothWP Acceptance Logs: docs A record of who accepted which version of your terms, and when. Free on wordpress.org. Generated from admin-terms-acceptance at branch main. ## Install In wp-admin: Plugins > Add New, search for "MammothWP Acceptance Logs", install and activate. Then go to Settings > Terms & Conditions, write your terms, choose which roles must accept them, then publish the version. ```bash composer require wpackagist-plugin/mammoth-acceptance-logs ``` 1. Download the ZIP of the main branch from github.com/mammothwp/admin-terms-acceptance (Code > Download ZIP). 2. Unzip it and rename the folder to mammothwp-acceptance-logs if GitHub named it admin-terms-acceptance-main, then zip it again. 3. In wp-admin, go to Plugins > Add New > Upload Plugin and choose the ZIP, then activate the plugin. ## Usage snippets ### Download and install ```bash composer install --no-dev --optimize-autoloader ``` ### PHP ```php // Publish, asking everyone in scope to accept. $result = mmwp_terms_publish_version( $html, array( 'version_number' => '2.1', 'effective_date' => '2026-10-01', 'change_summary' => 'Clarified section 4.', ) ); if ( is_wp_error( $result ) ) { // Duplicate version number, empty content, bad date, or a failed write. } // For sync jobs: no-op when the text already matches the active version. mmwp_terms_publish_version_if_changed( $html, array( 'version_number' => '2.1' ) ); // Re-read the content source now instead of waiting for the daily cron. $new_version_id = mmwp_terms_refresh(); // null if nothing changed mmwp_terms_active_version(); // array|null mmwp_terms_user_status( $user_id ); // accepted|pending|not_required ``` ### Public read routes ``` curl https://example.com/wp-json/mmwp/v1/public/version ``` ### Example: source the terms from a JSON file instead of the settings page ```php add_filter( 'mmwp_terms_content_source', function ( string $content ) { $path = WP_CONTENT_DIR . '/mmwp-terms/terms.json'; if ( ! is_readable( $path ) ) { return $content; } $data = json_decode( (string) file_get_contents( $path ), true ); return (string) ( $data['content'] ?? $content ); } ); ``` ### Example: exclude WooCommerce customers, keep staff roles gated ```php add_filter( 'mmwp_terms_excluded_roles', fn ( array $roles ) => array_merge( $roles, [ 'customer' ] ) ); ``` ### Example: record provider-side delivery status from an ESP webhook ```php // The plugin cannot poll your provider; wire its webhook to this action. do_action( 'mmwp_terms_delivery_status_recorded', $acceptance_id, $message_id, 'delivered' ); ``` ### WP-CLI ```bash wp mmwp terms get wp mmwp terms list --limit=10 --format=json wp mmwp terms show 3 wp mmwp terms publish --content="$(cat terms.html)" --version-number=2.1 --effective-date=2026-10-01 --change-summary="Clarified section 4." --only-if-changed wp mmwp terms refresh wp mmwp terms acceptances --version-id=3 --format=csv wp mmwp terms purge --dry-run # report evidence; add --force to delete it permanently ``` ## In these docs - [Hooks & filters](https://mammothwp.com/docs/mammoth-acceptance-logs/hooks/) - [REST & Abilities](https://mammothwp.com/docs/mammoth-acceptance-logs/rest/) - [Command line](https://mammothwp.com/docs/mammoth-acceptance-logs/cli/) - [Use cases](https://mammothwp.com/docs/mammoth-acceptance-logs/use-cases/) --- # MammothWP Acceptance Logs: hooks and filters Read from admin-terms-acceptance at main. ## Actions Example (mmwp_terms_accepted): ```php add_action( 'mmwp_terms_accepted', function ( $user_id, $version_hash, $acceptance_id, $version_id ) { // Your code. }, 10, 4 ); ``` ### mmwp_terms_accepted Action. Anchor: https://mammothwp.com/docs/mammoth-acceptance-logs/hooks/#mmwp_terms_accepted Fires right after an acceptance is persisted. | Name | Type | Description | | --- | --- | --- | | $user_id | int | | | $version_hash | string | | | $acceptance_id | int | | | $version_id | int | | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Acceptance/AcceptanceService.php#L190 ### mmwp_terms_content_saved Action. Anchor: https://mammothwp.com/docs/mammoth-acceptance-logs/hooks/#mmwp_terms_content_saved Fires when the settings page is saved. | Name | Type | Description | | --- | --- | --- | | $version_hash | string | | | $meta | array | Version number, effective date, change summary. | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Admin/SettingsPage.php#L167 ### mmwp_terms_record_failed Action. Anchor: https://mammothwp.com/docs/mammoth-acceptance-logs/hooks/#mmwp_terms_record_failed Fires when an acceptance could not be persisted. The user has NOT been recorded as accepting and their meta is untouched. Hook this to alert someone: silently losing acceptance evidence is the worst failure this plugin has. | Name | Type | Description | | --- | --- | --- | | $user_id | int | | | $version_id | int | | | $db_error | string | | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Acceptance/AcceptanceRepository.php#L93 ### mmwp_terms_skipped Action. Anchor: https://mammothwp.com/docs/mammoth-acceptance-logs/hooks/#mmwp_terms_skipped | Name | Type | Description | | --- | --- | --- | | $user_id | int | | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Acceptance/AcceptanceService.php#L203 ### mmwp_terms_version_changed Action. Anchor: https://mammothwp.com/docs/mammoth-acceptance-logs/hooks/#mmwp_terms_version_changed Fires when the terms content hash changes. | Name | Type | Description | | --- | --- | --- | | $old_hash | string | | | $new_hash | string | | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Acceptance/AcceptanceService.php#L94 ### mmwp_terms_version_published Action. Anchor: https://mammothwp.com/docs/mammoth-acceptance-logs/hooks/#mmwp_terms_version_published Fires once a new terms version has been archived and made active. | Name | Type | Description | | --- | --- | --- | | $version_id | int | | | $version_hash | string | | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Versioning/VersionArchivist.php#L235 ## Filters Example (mmwp_terms_version_meta): ```php add_filter( 'mmwp_terms_version_meta', function ( array $meta, $content ) { // Someone published deliberately — don't second-guess them. if ( 'settings' === ( $meta['source'] ?? '' ) || ! empty( $meta['version_number'] ) ) { return $meta; } $doc = my_legal_api_fetch(); $meta['version_number'] = $doc['version']; $meta['effective_date'] = $doc['effective_from']; $meta['change_summary'] = $doc['changelog']; return $meta; }, 10, 2 ); ``` ### mmwp_terms_can_manage Filter. Anchor: https://mammothwp.com/docs/mammoth-acceptance-logs/hooks/#mmwp_terms_can_manage Filters whether a user may manage the terms plugin. The escape hatch for sites that genuinely want to map this onto a capability or an external permission system. Note it cannot re-enable anything while the UI kill-switch is on — that check runs first, above. | Name | Type | Description | | --- | --- | --- | | $allowed | bool | | | $user_id | int | | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Support/ManagerAccess.php#L96 ### mmwp_terms_content_source Filter. Anchor: https://mammothwp.com/docs/mammoth-acceptance-logs/hooks/#mmwp_terms_content_source Filters the raw terms & conditions content used both for display and for computing the version hash. | Name | Type | Description | | --- | --- | --- | | $default | string | The content saved on the settings page. | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Content/ContentSource.php#L59 ### mmwp_terms_drop_data_on_uninstall Filter, since 1.0.0. Anchor: https://mammothwp.com/docs/mammoth-acceptance-logs/hooks/#mmwp_terms_drop_data_on_uninstall Whether to delete the acceptance log, version archive, audit events and per-user acceptance markers when the plugin is deleted. Defaults to the stored choice, which is false (keep) unless the site owner explicitly chose to delete. After deletion no acceptance can be reproduced or proved, so only return true deliberately. | Name | Type | Description | | --- | --- | --- | | $drop | bool | Whether to delete the evidence. | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/uninstall.php#L77 ### mmwp_terms_eligible_managers Filter. Anchor: https://mammothwp.com/docs/mammoth-acceptance-logs/hooks/#mmwp_terms_eligible_managers Filters the users offered when appointing a manager. | Name | Type | Description | | --- | --- | --- | | $users | \WP_User[] | | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Support/ManagerAccess.php#L140 ### mmwp_terms_enable_admin_ui Filter. Anchor: https://mammothwp.com/docs/mammoth-acceptance-logs/hooks/#mmwp_terms_enable_admin_ui Filters whether the plugin's management UI is available at all. Returning false hides every admin screen from everyone, managers included. The acceptance flow itself keeps running. | Name | Type | Description | | --- | --- | --- | | $enabled | bool | | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Support/AdminUi.php#L50 ### mmwp_terms_excluded_roles Filter. Anchor: https://mammothwp.com/docs/mammoth-acceptance-logs/hooks/#mmwp_terms_excluded_roles | Name | Type | Description | | --- | --- | --- | | $roles | string[] | Role slugs to exclude from the gate. | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Scope/RoleScope.php#L71 ### mmwp_terms_excluded_users Filter. Anchor: https://mammothwp.com/docs/mammoth-acceptance-logs/hooks/#mmwp_terms_excluded_users | Name | Type | Description | | --- | --- | --- | | $user_ids | int[] | User IDs to exclude from the gate. | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Scope/RoleScope.php#L86 ### mmwp_terms_ip_header Filter. Anchor: https://mammothwp.com/docs/mammoth-acceptance-logs/hooks/#mmwp_terms_ip_header Filters which request header carries the forwarded client IP. | Name | Type | Description | | --- | --- | --- | | $header | string | A $_SERVER key, e.g. HTTP_CF_CONNECTING_IP. | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Support/ClientIp.php#L77 ### mmwp_terms_notify_admin_email Filter. Anchor: https://mammothwp.com/docs/mammoth-acceptance-logs/hooks/#mmwp_terms_notify_admin_email | Name | Type | Description | | --- | --- | --- | | $arg1 | | | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Notifications/CertificateMailer.php#L192 ### mmwp_terms_popup_strings Filter. Anchor: https://mammothwp.com/docs/mammoth-acceptance-logs/hooks/#mmwp_terms_popup_strings Filters the button/label strings shown in the popup. | Name | Type | Description | | --- | --- | --- | | $strings | array | | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Frontend/PopupRenderer.php#L91 ### mmwp_terms_public_api_enabled Filter. Anchor: https://mammothwp.com/docs/mammoth-acceptance-logs/hooks/#mmwp_terms_public_api_enabled Filters whether the public, unauthenticated read routes exist at all. Returning false unregisters them entirely — they 404 rather than 401, because a site whose terms are internal should not advertise that the endpoint is there. | Name | Type | Description | | --- | --- | --- | | $enabled | bool | Defaults to true. | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Api/PublicRestController.php#L68 ### mmwp_terms_retention_settings Filter. Anchor: https://mammothwp.com/docs/mammoth-acceptance-logs/hooks/#mmwp_terms_retention_settings Filters the retention settings. | Name | Type | Description | | --- | --- | --- | | $settings | | | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Retention/RetentionPolicy.php#L61 ### mmwp_terms_send_pdf_email Filter. Anchor: https://mammothwp.com/docs/mammoth-acceptance-logs/hooks/#mmwp_terms_send_pdf_email Allows disabling the emailed PDF receipt entirely. | Name | Type | Description | | --- | --- | --- | | $enabled | bool | | | $user_id | int | | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Notifications/CertificateMailer.php#L84 ### mmwp_terms_site_identifier Filter. Anchor: https://mammothwp.com/docs/mammoth-acceptance-logs/hooks/#mmwp_terms_site_identifier Filters the source application identifier stored on each record. | Name | Type | Description | | --- | --- | --- | | $default | string | | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Support/RequestContext.php#L65 ### mmwp_terms_skip_duration Filter. Anchor: https://mammothwp.com/docs/mammoth-acceptance-logs/hooks/#mmwp_terms_skip_duration | Name | Type | Description | | --- | --- | --- | | $arg1 | | | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Acceptance/AcceptanceService.php#L196 ### mmwp_terms_trusted_proxies Filter. Anchor: https://mammothwp.com/docs/mammoth-acceptance-logs/hooks/#mmwp_terms_trusted_proxies Filters the proxies whose forwarded-IP header may be trusted. Empty by default: without this configured the plugin records the connecting address, which behind a proxy is the proxy itself. That is deliberate — an honest wrong-ish value beats a client-spoofable one in an audit log. | Name | Type | Description | | --- | --- | --- | | $proxies | string[] | IP addresses or CIDR ranges. | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Support/ClientIp.php#L114 ### mmwp_terms_version_meta Filter. Anchor: https://mammothwp.com/docs/mammoth-acceptance-logs/hooks/#mmwp_terms_version_meta Filters the version metadata attached to a publication. This is how a programmatic content source names its own versions. Set `version_number`, `effective_date` (Y-m-d) and `change_summary` to have them recorded; anything left empty is derived, except the effective date, which is simply not recorded. PRECEDENCE: this filter runs last and wins, per WordPress convention — including over a number typed on the settings screen or passed to the API. That is easy to get wrong in a way that mislabels a legal document, so scope the filter to the sources it is meant for and leave values the caller already supplied alone: `$meta['source']` is one of settings|filter|api|rest|cli|code. | Name | Type | Description | | --- | --- | --- | | $meta | array | Version metadata so far. | | $content | string | The content being published. | | $hash | string | Its version hash. | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Versioning/VersionArchivist.php#L135 --- # MammothWP Acceptance Logs: REST routes and abilities Read from admin-terms-acceptance at main. ## REST routes ### GET /wp-json/mmwp/v1/public/version Permission: Public: no authentication. Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Api/PublicRestController.php#L72 ### GET /wp-json/mmwp/v1/public/versions Permission: Public: no authentication. | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | per_page | integer | | 20 | | | offset | integer | | 0 | | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Api/PublicRestController.php#L82 ### GET /wp-json/mmwp/v1/public/versions/(?P\d+) Permission: Public: no authentication. Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Api/PublicRestController.php#L105 ### GET /wp-json/mmwp/v1/terms/version Show the active terms version Permission: Capability: edit_posts. Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Api/TermsResource.php#L1 ### GET /wp-json/mmwp/v1/terms/versions List archived terms versions Permission: Capability: edit_posts. | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | limit | integer | | 20 | Rows to return. | | offset | integer | | 0 | Rows to skip. | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Api/TermsResource.php#L1 ### GET /wp-json/mmwp/v1/terms/versions/(?P\d+) Get one archived terms version Permission: Capability: edit_posts. | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | id | integer | yes | | Version ID. | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Api/TermsResource.php#L1 ### POST /wp-json/mmwp/v1/terms/version Publish a new terms version Permission: Set by the resource default (not overridden on this operation). | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | content | string | yes | | The terms text. HTML is filtered through wp_kses_post(). | | version_number | string | | | Human version number, e.g. 2.1. Derived if omitted. | | effective_date | string | | | Effective date, Y-m-d. | | change_summary | string | | | Shown to returning users above the terms. | | only_if_changed | boolean | | false | Do nothing if the text matches the active version. | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Api/TermsResource.php#L1 ### POST /wp-json/mmwp/v1/terms/refresh Re-read the terms content source Permission: Set by the resource default (not overridden on this operation). Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Api/TermsResource.php#L1 ### GET /wp-json/mmwp/v1/terms/acceptances List acceptance log entries Permission: Callback can_read_log(). | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | user_id | integer | | 0 | Only this user. 0 for everyone. | | version_id | integer | | 0 | Only this terms version. 0 for all. | | date_from | string | | | First day, Y-m-d (UTC). | | date_to | string | | | Last day, Y-m-d (UTC). | | limit | integer | | 50 | Rows to return. | | offset | integer | | 0 | Rows to skip. | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Api/TermsResource.php#L1 ## Abilities ### mmwp-acceptance-logs/get-active-version Show the active terms version: Returns the terms version users must currently have accepted. Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Api/TermsResource.php#L1 ### mmwp-acceptance-logs/list-versions List archived terms versions: Lists every archived terms version, newest first. Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Api/TermsResource.php#L1 ### mmwp-acceptance-logs/get-version Get one archived terms version: Returns one archived terms version, including its full text. Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Api/TermsResource.php#L1 ### mmwp-acceptance-logs/publish-version Publish a new terms version: Publishes the text as the active version. Everyone in scope is asked to accept it again. Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Api/TermsResource.php#L1 ### mmwp-acceptance-logs/refresh-content Re-read the terms content source: Re-reads the terms content source now instead of waiting for the daily check, and publishes a new version if it changed. Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Api/TermsResource.php#L1 ### mmwp-acceptance-logs/list-acceptances List acceptance log entries: Read-only, redacted export of the acceptance log. Restricted to the plugin managers. Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Api/TermsResource.php#L1 --- # MammothWP Acceptance Logs: commands Read from admin-terms-acceptance at main. ## wp mmwp terms get Show the active terms version Returns the terms version users must currently have accepted. ``` wp mmwp terms get ``` Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Api/TermsResource.php#L1 ## wp mmwp terms list List archived terms versions Lists every archived terms version, newest first. ``` wp mmwp terms list [--limit=] [--offset=] ``` | Argument | Description | | --- | --- | | --limit= | Rows to return. | | --offset= | Rows to skip. | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Api/TermsResource.php#L1 ## wp mmwp terms show Get one archived terms version Returns one archived terms version, including its full text. ``` wp mmwp terms show ``` | Argument | Description | | --- | --- | | | Version ID. | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Api/TermsResource.php#L1 ## wp mmwp terms publish Publish a new terms version Publishes the text as the active version. Everyone in scope is asked to accept it again. ``` wp mmwp terms publish --content= [--version_number=] [--effective_date=] [--change_summary=] [--only_if_changed] ``` | Argument | Description | | --- | --- | | --content= | The terms text. HTML is filtered through wp_kses_post(). | | --version_number= | Human version number, e.g. 2.1. Derived if omitted. | | --effective_date= | Effective date, Y-m-d. | | --change_summary= | Shown to returning users above the terms. | | --only_if_changed | Do nothing if the text matches the active version. | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Api/TermsResource.php#L1 ## wp mmwp terms refresh Re-read the terms content source Re-reads the terms content source now instead of waiting for the daily check, and publishes a new version if it changed. ``` wp mmwp terms refresh ``` Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Api/TermsResource.php#L1 ## wp mmwp terms acceptances List acceptance log entries Read-only, redacted export of the acceptance log. Restricted to the plugin managers. ``` wp mmwp terms acceptances [--user_id=] [--version_id=] [--date_from=] [--date_to=] [--limit=] [--offset=] ``` | Argument | Description | | --- | --- | | --user_id= | Only this user. 0 for everyone. | | --version_id= | Only this terms version. 0 for all. | | --date_from= | First day, Y-m-d (UTC). | | --date_to= | Last day, Y-m-d (UTC). | | --limit= | Rows to return. | | --offset= | Rows to skip. | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Api/TermsResource.php#L1 ## wp mmwp terms purge Report or permanently delete all acceptance evidence Without --force this only reports what exists. With --force it drops the log, the version archive and the audit events. ``` wp mmwp terms purge [--dry_run] [--force] ``` | Argument | Description | | --- | --- | | --dry_run | Report only, even with --force. | | --force | Actually delete. Irreversible. | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/src/Api/TermsResource.php#L1 --- # MammothWP Acceptance Logs: use cases ## Usage Users in the selected roles are prompted the next time they load the site or the dashboard while logged in. Whoever activated the plugin becomes its first manager; managers are the only people who can read the acceptance log, under Users > Terms acceptance log. ## Download and install The wordpress.org listing is pending review. Until it is live, install from this repository, as described on : 1. Download the ZIP of `main` from GitHub (**Code → Download ZIP**). 2. Unzip it, rename the folder `admin-terms-acceptance-main` to `mammoth-acceptance-logs`, and zip that folder again. 3. In WordPress go to **Plugins → Add New → Upload Plugin**, choose the zip and install it. 4. **Activate** the plugin, then open **Settings → Terms & Conditions**. The GitHub tree has no `vendor/` directory. The core acceptance flow works without it, but the REST API, WP-CLI, the Abilities API, the Support screen and PDF receipts need the dependencies. Run this inside the plugin folder before zipping (or on the server): ```bash composer install --no-dev --optimize-autoloader ``` Requires WordPress 6.2+ and PHP 8.1+. To build a distributable zip from a checkout, use `composer build` (see Contributing). Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/README.md ## Usage (README.md) Full details in [`docs/usage.md`](docs/usage.md). | Surface | Example | |---|---| | Admin | Settings → Terms & Conditions; Users → Terms acceptance log | | REST | `GET /wp-json/mmwp/v1/terms/acceptances?version_id=3` (plugin managers) | | WP-CLI | `wp mmwp terms publish --content="$(cat terms.html)" --only-if-changed` | | Abilities | `mmwp-acceptance-logs/list-acceptances` (read-only) | Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/README.md ## Support Use the support forum on wordpress.org, or e-mail info@mammothwp.com. The plugin never sends anything on its own; see [`docs/privacy.md`](docs/privacy.md). Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/README.md ## Questions answered in the plugin readme ### Is this a cookie or consent plugin? No, and the distinction drives the whole design. Accepting Terms & Conditions is an access-control and contractual-compliance mechanism resting on Art. 6(1)(b) and 6(1)(f) GDPR, not consent under Art. 6(1)(a). Practically: acceptance is not withdrawable in the Art. 7(3) sense, and records are not deleted on request. If you need cookie consent, you need a different plugin — run both. ### Which roles are prompted? Until you choose, **every role**. The plugin does not guess a safe subset, and picking the roles is step 3 of the setup for that reason. Settings → Terms & Conditions lists every role on the site. ### Does it lock users out until they accept? No. The popup has a "skip for now" button, and a user who clicks it keeps working. Skipping is not recorded as an acceptance or a decline: it only hides the popup for that user for 12 hours (filterable with `mmwp_terms_skip_duration`) and fires the `mmwp_terms_skipped` action; nothing is written to the acceptance log or audit events. What persists is a non-dismissable notice on every wp-admin screen, shown only to logged-in users in scope who have not accepted the current version, until they accept. It is a prompt with no exit, not a wall — a hard lockout risks stranding an administrator who needs the backend to fix the terms. ### What happens when I edit the terms? Editing and publishing creates a **new version** with the next sequential number, archives the full text permanently, and re-prompts everyone in scope. Previous versions are never deleted, and republishing earlier text is a new publication with a new number rather than a silent return to an old one — so "revert the text and everyone counts as accepted again" cannot happen. Changing the text without publishing changes nothing. The daily cron only picks up changes made through a content source that bypasses the settings screen. ### Can a user withdraw their acceptance? No. The record is evidence that a particular version was accepted at a particular moment; that fact does not stop being true. A user who does not want to accept can only skip the popup for now; that is not recorded (see above), and the acceptance prompt returns until they accept. ### How do I handle an erasure request? Through WordPress's own Tools → Erase Personal Data — the plugin registers an eraser. It returns the acceptance records as **retained**, with the reasoning shown to the requester rather than applied silently, and erases the context (IP, User-Agent) of any record already past its retention period. Art. 17(3)(b) and (e) disapply the right to erasure where processing is necessary for legal claims, which is what proof of acceptance is. See the retention policy document in the plugin repository. ### How long is data kept? Records live while the account exists, plus a dispute period defaulting to five years, after which the IP address, IP source and User-Agent are erased and the record is retained in anonymised form. Records are anonymised, never deleted. Published versions of the terms are never deleted at all — an acceptance whose text cannot be reproduced proves almost nothing. ### I'm an administrator. Why can't I see the acceptance log? Access is an explicit list of user accounts, not a capability. That is deliberate: `manage_options` gets handed out liberally by role plugins and during onboarding, and the log is evidence about other people. Whoever activated the plugin is the first manager and can appoint others; the last manager cannot be removed. ### Can I delete a record, or fix a wrong one? No, by design. There is no update or delete path in the code and no edit or delete affordance in the admin. If a record is wrong, the answer is a new record plus an audit event. Their absence is the control, not an oversight. ### Does it work behind Cloudflare or a load balancer? Yes, but configure the trusted proxies first or the audit log records the proxy's address instead of the visitor's. Return your proxy's address or CIDR range from the `mmwp_terms_trusted_proxies` filter. Forwarded IP headers are ignored by default because trusting them unconditionally would let any client write its own address into the log. ### Does it support multisite? Each site gets its own tables and settings, so activate it per site. Network activation is not supported: the tables are created for the site the activation runs on, not for every site in the network. ### Where do I get evidence for a dispute? Users → Terms acceptance log, which shows every record with its integrity and delivery status, filterable, with CSV export and an on-demand integrity check. `the evidence-handling document in the plugin repository` covers the procedure, what the evidence actually shows, and the limitations you should disclose alongside it. ### What personal data is stored? Per acceptance: user ID, login and e-mail as they were at the time, the version accepted, UTC and local timestamps with timezone, the site identifier, IP address (and how it was determined), User-Agent and cryptographic signatures. Three user-meta markers record the version each user accepted. No fingerprinting, MAC address or geolocation is collected. ### Does it support the WordPress personal data tools? Yes. It registers a personal-data exporter and eraser (`wp_privacy_personal_data_exporters`, `wp_privacy_personal_data_erasers`) and adds suggested text to the Privacy Policy guide with `wp_add_privacy_policy_content()`. ### Does it contact any external service? No. There are no remote calls, no content delivery network assets, no update checks and no telemetry. The bundled Support screen (Mammoth WP > Support) sends one e-mail through `wp_mail()` to info@mammothwp.com, only when an administrator presses Send. A site report is attached only if they tick "Include site report" after previewing exactly what it contains. ### What happens when I delete the plugin? By default the acceptance evidence is kept. When you click Delete on the Plugins screen, a dialog asks whether to **keep the acceptance evidence (recommended)** or **delete it permanently**; deleting requires typing the plugin name. Either way the plugin's settings, scheduled tasks and options are removed. If you keep the evidence, the log, the version archive, the audit events, the per-user acceptance markers and the signing key stay in your database, so they can still be verified later. You can change the choice at any time under Settings > Terms & Conditions, or purge the evidence immediately with `wp mmwp terms purge` (it only reports what exists; add `--force` to delete, `--dry-run` to never delete). Developers can override the choice with the `mmwp_terms_drop_data_on_uninstall` filter. ### Can I manage it without the admin screens? Yes: REST (`mmwp/v1/terms/...`), WP-CLI (`wp mmwp terms ...`) and the Abilities API, all declared once. Reading the acceptance log over any of them requires being a plugin manager and returns a redacted shape (no IP address, User-Agent or e-mail). ## Producing acceptance evidence for a dispute What to do when someone has to demonstrate that a named person accepted a named version of the terms — and what to be careful about while doing it. ### The procedure 1. **Verify the log before you export it.** Users → Terms acceptance log → *Verify integrity*. Do this first: an export taken from a log you have not checked tells you nothing about whether the log is intact. Record the result, including a clean one. 2. **Filter to the person.** Filter by user ID, or open their profile and follow "View this user's acceptance records". 3. **Export the CSV.** It carries every field, both signatures, the chain link, and a per-row verification verdict. 4. **Attach the version.** From Settings → Terms & Conditions → Version history, download the PDF of the version they accepted. The record names a version; the version supplies the text. One without the other proves half the point. 5. **State the integrity position honestly** — see below. ### What the evidence actually shows | Element | What it establishes | |---|---| | User ID + username/email snapshot | Which account acted, readable even after the account is deleted | | UTC timestamp + local time and offset | When, unambiguously, regardless of later timezone changes | | Version ID, number, effective date, hash | Which document | | Archived version text | What that document said — the part a hash alone cannot supply | | Action | That the act was an acceptance | | Site identifier | Which installation it happened on | | IP address + source | Where the request came from, and whether that came from the connection or a trusted proxy header | | User-Agent | Corroborating circumstances only — trivially spoofable, never primary proof | | record_hash / prev_record_hash | That the record has not been altered or unlinked since it was written | ### Integrity: state it accurately The signatures are **tamper-evidence, not tamper-proofing**. Overstating this is worse than claiming nothing, because the overstatement is what gets attacked. - **Key in `wp-config.php`** (`MMWP_TERMS_INTEGRITY_KEY`): the key sits outside the database. Someone with SQL access alone can alter a record but cannot produce a matching signature, so the alteration is detectable. - **Key in the database** (the default when the constant is absent): detects accidental corruption and careless direct edits. Someone who holds the database also holds the key and could re-sign an altered record. Say so. - Neither protects against an attacker with full filesystem access, who could alter the plugin itself. Set the constant *before* you need to rely on the log. Records signed under the old key stop verifying when the key changes. ### Known limitations to disclose Disclose these rather than let them be discovered. A limitation you volunteered costs you far less than one found in cross-examination. - **Context signature on anonymised records.** Once a record passes its retention period, the IP address and User-Agent are erased and the context signature is cleared with them. This is the retention policy working as documented, not tampering; the record's own signature still verifies. The `anonymised_at_utc` field carries the date, and an `anonymised` audit event carries the reason. - **Chain gaps.** If two acceptances are recorded at the same instant, the advisory lock that orders them can time out. The plugin records the gap as a `chain_gap` audit event and links nothing, rather than writing a link that looks valid and is not. The per-record signature is unaffected. A gap is a known, logged state — not evidence of interference. - **Proxy addresses.** If the site sits behind a content delivery network or load balancer and `mmwp_terms_trusted_proxies` was never configured, `ip_address` is the proxy's address, and `ip_source` says `remote_addr`. Check that field before asserting anything about where a request came from. - **User-Agent weight.** Corroborating only. - **Receipt email.** The emailed PDF is supplementary. Delivery is logged (`email_sent` / `email_failed`), but delivery to a mail server is not proof the person read it. Where an ESP webhook is wired to `mmwp_terms_delivery_status_recorded`, provider-side status is available too. ### If verification fails Do not "fix" anything. There is no repair function, deliberately: re-signing a record that failed verification would destroy the only evidence that something happened to it. 1. Note which record IDs failed and how (record signature, context signature, or chain link). 2. Preserve a database backup taken before any further change. 3. Check whether the integrity key changed — the most common innocent explanation, and one that invalidates every signature at once rather than a few. 4. Check the audit events around the affected IDs. 5. Treat a small number of failures against an otherwise valid chain as the more serious signal: wholesale failure usually means a key change, while isolated failure suggests a specific record was touched. ### Versions published programmatically The `source` column on each version says how it was published: `settings` (an admin typed it), `filter` (picked up from an external content source), `rest`, `cli`, `api` or `code`. Quote it — "the version was published by the legal team's CMS sync at 03:14 UTC" is a more complete account than "the version existed". Two things follow for programmatic publications: - Where no version number was supplied, one was **derived** (`1.0` → `1.1`, or a dated identifier). A derived number is still unique and still names one specific document, but it was assigned by the system rather than chosen by a person — say so rather than implying a human numbered it. - Where no effective date was supplied, none is recorded. The field is empty rather than inherited from earlier text, which is deliberate: an inherited date would be a false claim. An empty effective date is not a gap in the evidence — the publication time in `created_at_utc` is authoritative for when the version became active. ### Without the admin UI On installs where the management UI is switched off (`MMWP_TERMS_DISABLE_ADMIN_UI`), there is no acceptance log screen and no CSV button. Either re-enable it temporarily on the install, or read the tables directly: ```bash wp db query "SELECT a.*, v.version_number, v.effective_date \ FROM wp_mmwp_terms_acceptances a \ LEFT JOIN wp_mmwp_terms_versions v ON v.id = a.version_id \ WHERE a.user_id = 123 ORDER BY a.id DESC" --skip-column-names ``` Reading the table directly gives you no integrity verification. Re-enable the UI and run the check, or verify the signatures separately, before relying on what you pull out this way. The version archive itself is reachable without the UI: ```bash wp mmwp terms list wp mmwp terms get --format=json ``` Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/docs/evidence-handling.md ## Privacy ### What is stored Per acceptance (table `{prefix}mmwp_terms_acceptances`): user ID, login and e-mail snapshot, the version accepted and its hash, UTC and local time with timezone, the site identifier, IP address and its source, User-Agent, and two signatures plus a chain link. Events about a record (receipt delivery, anonymisation) go to `{prefix}mmwp_terms_audit_events`. Each user also gets three user-meta markers recording the version they accepted. No device fingerprinting, MAC address or geolocation is collected. ### Legal basis GDPR Art. 6(1)(b) and 6(1)(f). Not consent (Art. 6(1)(a)); acceptance is not withdrawable and records are not deleted on request. See `dpia-screening.md`, `ropa.md`, `retention-policy.md`. ### Retention IP address, its source and the User-Agent (the erasable context) are cleared after the configurable dispute period (default five years) by a daily sweep; the core record keeps verifying. The anonymisation is audited. ### Personal-data tools - **Exporter** (Tools → Export Personal Data) lists a user's acceptances. - **Eraser** anonymises the erasable context where retention allows and reports what it retained and why; the core evidence stays. - **Privacy policy text** is suggested via `wp_add_privacy_policy_content()`. ### Network The plugin makes no outbound requests, loads no remote assets, and sends no telemetry. Two kinds of e-mail leave through `wp_mail()`: the receipt to the accepting user (and an optional address you configure), and a support request to info@mammothwp.com that an administrator sends from Mammoth WP → Support. The support request carries a site report only if the administrator ticks "Include site report" after previewing exactly what it contains. ### Uninstall: what is kept and what is deleted Always removed when the plugin is deleted: settings, options, scheduled tasks and transients. Kept by default (the acceptance evidence): the three tables (`{prefix}mmwp_terms_acceptances`, `{prefix}mmwp_terms_versions`, `{prefix}mmwp_terms_audit_events`), the per-user acceptance markers and the signing key, so kept records can still be verified. The choice is asked when you click Delete on the Plugins screen ("Keep acceptance evidence (recommended)" or "Delete all acceptance evidence permanently", the latter needing the plugin name typed back). It is stored in the `mmwp_terms_uninstall_data` option and can be changed under Settings → Terms & Conditions. The filter `mmwp_terms_drop_data_on_uninstall` overrides it. ### Purging evidence now ```bash wp mmwp terms purge # report what exists, delete nothing wp mmwp terms purge --dry-run # same, explicitly wp mmwp terms purge --force # permanently delete the evidence ``` Irreversible: after a purge no acceptance can be reproduced or proved. Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/docs/privacy.md ## Programmatic API reference The supported programmatic surface of `mammoth-acceptance-logs`: `Api\TermsApi` and its REST, WP-CLI and helper-function wrappers, plus every action and filter the plugin fires. ### Programmatic management Every route below goes through the same code path, so a version published by any of them is archived, numbered and audited identically. All of them keep working with the admin UI switched off — a locked-down install is precisely the one that has to be driven by code. **Publishing re-prompts everyone in scope.** That is the point, but it means publishing on every deploy when nothing changed would nag your users into ignoring the dialog. Use the "only if changed" variants in automation. #### Version numbers are derived, never inherited If you publish without a `version_number`, one is derived — `1.0` becomes `1.1`, `3` becomes `4`, and a non-numeric or absent number falls back to a dated identifier, stepping until it finds an unused one. It is never copied from the version being replaced: two different documents sharing a number is the exact ambiguity the archive exists to remove, and the settings screen already refuses to allow it. The **effective date is never inherited either**. It is a statement about a specific document, so a version published without one simply has none recorded, rather than borrowing a date that belonged to different text. #### PHP ```php // Publish, asking everyone in scope to accept. $result = mmwp_terms_publish_version( $html, array( 'version_number' => '2.1', 'effective_date' => '2026-10-01', 'change_summary' => 'Clarified section 4.', ) ); if ( is_wp_error( $result ) ) { // Duplicate version number, empty content, bad date, or a failed write. } // For sync jobs: no-op when the text already matches the active version. mmwp_terms_publish_version_if_changed( $html, array( 'version_number' => '2.1' ) ); // Re-read the content source now instead of waiting for the daily cron. $new_version_id = mmwp_terms_refresh(); // null if nothing changed mmwp_terms_active_version(); // array|null mmwp_terms_user_status( $user_id ); // accepted|pending|not_required ``` Call these on or after `plugins_loaded`. #### WP-CLI, REST and Abilities These three are declared once with wp-kit (`Api\TermsResource`) and share one validation and authorisation path. Commands, routes and abilities are listed in [usage.md](usage.md). Permissions are decided by wp-kit's capability resolver (`edit_posts` for reads, `manage_options` for publishing, plugin managers for the acceptance listing) and can be adjusted with its `mmwp_kit_operation_capability` filter. #### Public read routes Publishing your terms on a public page needs the text readable without a login, which the routes above deliberately are not. A second, narrower set covers that — read-only, versions only, and redacted: | Route | Method | Purpose | |---|---|---| | `/public/version` | GET | The active version, with its full text | | `/public/versions` | GET | The archive, newest first, *without* the text (`per_page`, `offset`) | | `/public/versions/` | GET | One version, with its full text | ``` curl https://example.com/wp-json/mmwp/v1/public/version ``` Each response carries `id`, `version_number`, `label`, `effective_date`, `change_summary` and `is_active`, plus `content` on the single-version routes. `version_hash`, `source`, `created_at_utc` and the activation window are **not** exposed: they are internal provenance for whoever audits the archive, not information a visitor reading the terms needs. Responses are anonymous and sent with `Cache-Control: public, max-age=300`. Set the `mmwp_terms_public_api_enabled` filter to `false` to remove these routes entirely — they then 404 rather than 401, so an install whose terms are internal does not advertise that the endpoint exists. Doing so also leaves the Terms & Conditions block with nothing to fetch. ### Actions | Hook | Fires when | Args | |---|---|---| | `mmwp_terms_accepted` | a user accepts | `$user_id, $version_hash, $acceptance_id, $version_id` | | `mmwp_terms_record_failed` | an acceptance could **not** be persisted | `$user_id, $version_id, $db_error` | | `mmwp_terms_skipped` | a user clicks "skip for now" | `$user_id` | | `mmwp_terms_version_published` | a new version is archived and activated | `$version_id, $version_hash` | | `mmwp_terms_version_changed` | the content hash changes (save or cron) | `$old_hash, $new_hash` | | `mmwp_terms_content_saved` | the settings page is saved | `$version_hash, $meta` | | `mmwp_terms_delivery_status_recorded` | integration point for ESP delivery webhooks | `$acceptance_id, $provider_message_id, $status` | Hook `mmwp_terms_record_failed` and alert someone. Silently losing acceptance evidence is the worst failure this plugin can have. ### Filters | Filter | Purpose | Default | |---|---|---| | `mmwp_terms_enable_admin_ui` | Remove the whole management UI. See "Headless deployment". | `true` | | `mmwp_terms_can_manage` | Override who may manage the plugin. Cannot re-enable anything while the kill-switch is on. | in the manager list | | `mmwp_terms_eligible_managers` | The users offered when appointing a manager. | administrators | | `mmwp_terms_trusted_proxies` | Proxies whose forwarded-IP header may be trusted (IPs or CIDR ranges). | `[]` | | `mmwp_terms_ip_header` | Which `$_SERVER` key carries the forwarded client IP. | `HTTP_X_FORWARDED_FOR` | | `mmwp_terms_site_identifier` | The source application identifier stored on each record. | host + blog ID | | `mmwp_terms_retention_settings` | Dispute period and whether anonymisation runs. | 5 years, enabled | | `mmwp_terms_content_source` | Swap the content source entirely — a JSON file, a CPT, an external legal CMS. | the `mmwp_terms_content` option | | `mmwp_terms_version_meta` | Supply the version number, effective date and change summary for a publication. Runs last and wins — see "Pulling from an external system". | `[]` | | `mmwp_terms_public_api_enabled` | Whether the unauthenticated `/public/*` read routes are registered at all. The Terms & Conditions block depends on them. | `true` | | `mmwp_terms_excluded_roles` | Role slugs exempt from the gate. | `[]` | | `mmwp_terms_excluded_users` | Individual user IDs exempt from the gate. | `[]` | | `mmwp_terms_send_pdf_email` | Disable the emailed PDF receipt. `($enabled, $user_id)`. | `true` | | `mmwp_terms_notify_admin_email` | Copy every acceptance notification to this address. | `''` | | `mmwp_terms_skip_duration` | How long "skip for now" suppresses the overlay. The admin notice is unaffected. | `12 * HOUR_IN_SECONDS` | | `mmwp_terms_drop_data_on_uninstall` | Whether deleting the plugin also drops the three tables and the acceptance markers. Return false to keep the evidence. | `true` | #### Example: source the terms from a JSON file instead of the settings page ```php add_filter( 'mmwp_terms_content_source', function ( string $content ) { $path = WP_CONTENT_DIR . '/mmwp-terms/terms.json'; if ( ! is_readable( $path ) ) { return $content; } $data = json_decode( (string) file_get_contents( $path ), true ); return (string) ( $data['content'] ?? $content ); } ); ``` The daily cron notices the change and archives the new version automatically, so acceptances against it still point at preserved text. #### Example: exclude WooCommerce customers, keep staff roles gated ```php add_filter( 'mmwp_terms_excluded_roles', fn ( array $roles ) => array_merge( $roles, [ 'customer' ] ) ); ``` #### Example: record provider-side delivery status from an ESP webhook ```php // The plugin cannot poll your provider; wire its webhook to this action. do_action( 'mmwp_terms_delivery_status_recorded', $acceptance_id, $message_id, 'delivered' ); ``` Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/docs/programmatic-api.md ## Usage (usage.md) ### Admin 1. **Settings → Terms & Conditions**: write the terms, choose the roles that must accept, publish the version. 2. **Users → Terms acceptance log**: the evidence, with signature verification. Only plugin managers can open it — being an administrator is not enough. Users in scope see the popup on their next page load while logged in. ### REST (`mmwp/v1`) Authenticate like any WordPress request (cookie + nonce, or an application password). Routes are declared once through wp-kit. | Method and route | Who | |---|---| | `GET /mmwp/v1/terms/version` | `edit_posts` | | `GET /mmwp/v1/terms/versions?limit=&offset=` | `edit_posts` | | `GET /mmwp/v1/terms/versions/` | `edit_posts` | | `POST /mmwp/v1/terms/version` (`content`, `version_number`, `effective_date`, `change_summary`, `only_if_changed`) | `manage_options` | | `POST /mmwp/v1/terms/refresh` | `manage_options` | | `GET /mmwp/v1/terms/acceptances?user_id=&version_id=&date_from=&date_to=&limit=&offset=` | plugin managers | The acceptance listing is read-only and redacted: no IP address, User-Agent or e-mail. The only unauthenticated routes are read-only version routes under `/mmwp/v1/public/` (redacted, no acceptance records); disable them with `mmwp_terms_public_api_enabled`. ### WP-CLI ```bash wp mmwp terms get wp mmwp terms list --limit=10 --format=json wp mmwp terms show 3 wp mmwp terms publish --content="$(cat terms.html)" --version-number=2.1 --effective-date=2026-10-01 --change-summary="Clarified section 4." --only-if-changed wp mmwp terms refresh wp mmwp terms acceptances --version-id=3 --format=csv wp mmwp terms purge --dry-run # report evidence; add --force to delete it permanently ``` Purge is CLI-only: deleting evidence is deliberately not offered over REST or abilities. ### Abilities API (WordPress 6.9+) Category `mmwp-acceptance-logs`: `get-active-version`, `list-versions`, `get-version`, `publish-version`, `refresh-content` and the read-only `list-acceptances`. Each carries its field schema and read/write annotations and enforces the same capability as REST. ### PHP `mmwp_terms_*()` helpers in `src/api-functions.php` and `Api\TermsApi` are the supported programmatic entry points. See `programmatic-api.md`. ### Block Insert **Terms & Conditions** (`mmwp/terms-display`) on a public page to show the current version, its effective date and history. Source: https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/docs/usage.md ## Templates - [DPIA screening — Terms & Conditions acceptance](https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/docs/dpia-screening.md) - [Privacy notice wording](https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/docs/privacy-notice-snippet.md) - [Retention policy — acceptance records](https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/docs/retention-policy.md) - [Record of processing activities — Terms & Conditions acceptance](https://github.com/mammothwp/admin-terms-acceptance/blob/36f6f145beb885d95f3852dfb0faf4d902510f75/docs/ropa.md) --- # support: docs Shared support screen for Mammoth WP plugins: wp_mail tickets with an opt-in site report. Generated from support at release v1.1.1. ## Install ```bash composer require mammothwp/support ``` ## Usage snippets ### Quick start ```bash composer require mammothwp/support ``` ### Quick start ```php \MammothWP\Support\Support::register_plugin( __FILE__, [ 'email' => 'support@example.com', // required: where tickets for THIS plugin go ] ); ``` ### Quick start ```json { "email": "support@example.com", "docs_url": "https://example.com/docs" } ``` ### Install / integrate ```json { "require": { "mammothwp/support": "^1.1" } } ``` ### Adding a report section ```php add_filter( 'mmwp_support_report_sections', function ( array $sections ): array { $sections[] = new My_Plugin_Report_Section(); // implements \MammothWP\Support\Report\Section_Interface return $sections; } ); ``` ## In these docs - [Hooks & filters](https://mammothwp.com/docs/support/hooks/) - [REST & Abilities](https://mammothwp.com/docs/support/rest/) - [Command line](https://mammothwp.com/docs/support/cli/) - [Use cases](https://mammothwp.com/docs/support/use-cases/) --- # support: hooks and filters Read from support at v1.1.1. ## Actions Example (mmwp_support_booted): ```php add_action( 'mmwp_support_booted', function ( $version, $path ) { // Your code. }, 10, 2 ); ``` ### mmwp_support_booted Action, since 1.0.0. Anchor: https://mammothwp.com/docs/support/hooks/#mmwp_support_booted Fires after the winning copy of the package has booted. | Name | Type | Description | | --- | --- | --- | | $version | string | Booted package version. | | $path | string | Directory of the booted copy. | Source: https://github.com/mammothwp/support/blob/v1.1.1/src/Kernel.php#L175 ## Filters Example (mmwp_support_action_link): ```php add_filter( 'mmwp_support_action_link', function ( $enabled, $slug ) { // Change $enabled here. return $enabled; }, 10, 2 ); ``` ### mmwp_support_action_link Filter, since 1.0.0. Anchor: https://mammothwp.com/docs/support/hooks/#mmwp_support_action_link Filters whether a plugin gets the "Support" action link. | Name | Type | Description | | --- | --- | --- | | $enabled | bool | Default true. | | $slug | string | Plugin slug. | Source: https://github.com/mammothwp/support/blob/v1.1.1/src/Config.php#L157 ### mmwp_support_attach_report Filter, since 1.0.0. Anchor: https://mammothwp.com/docs/support/hooks/#mmwp_support_attach_report Filters whether `site-report--.json` is attached to the email. | Name | Type | Description | | --- | --- | --- | | $attach | bool | Default true. | | $slug | string | Plugin slug. | Source: https://github.com/mammothwp/support/blob/v1.1.1/src/Config.php#L118 ### mmwp_support_branding Filter, since 1.0.0. Anchor: https://mammothwp.com/docs/support/hooks/#mmwp_support_branding Filters the resolved branding values (site-owner white-label; wins over everything). Keys: name, menu_title, page_title, icon, logo_url (local URL only), subject_prefix, ref_prefix, show_powered_by, show_vendor_names. | Name | Type | Description | | --- | --- | --- | | $values | array | Resolved branding values. | | $mode | string | mammoth, neutral, vendor or whitelabel. | Source: https://github.com/mammothwp/support/blob/v1.1.1/src/Kernel.php#L243 ### mmwp_support_capability Filter, since 1.0.0. Anchor: https://mammothwp.com/docs/support/hooks/#mmwp_support_capability Filters the capability required for the Support screen and operations. | Name | Type | Description | | --- | --- | --- | | $capability | string | Capability, default `manage_options`. | | $network | bool | True for the network admin screen. | Source: https://github.com/mammothwp/support/blob/v1.1.1/src/Config.php#L65 ### mmwp_support_email Filter, since 1.0.0. Anchor: https://mammothwp.com/docs/support/hooks/#mmwp_support_email Filters the support recipient for a plugin. Wins over the constant, the array and the JSON file. | Name | Type | Description | | --- | --- | --- | | $email | string | Resolved address so far. | | $slug | string | Plugin slug. | Source: https://github.com/mammothwp/support/blob/v1.1.1/src/Plugin_Factory.php#L111 ### mmwp_support_menu Filter, since 1.0.0. Anchor: https://mammothwp.com/docs/support/hooks/#mmwp_support_menu Filters the canonical Support screen location. Wins over the constant and the default. | Name | Type | Description | | --- | --- | --- | | $location | string\|null | Null (no override) or toplevel\|tools\|settings\|hidden\|parent:. | | $default | string | The location the package would use without an override. | Source: https://github.com/mammothwp/support/blob/v1.1.1/src/Kernel.php#L211 ### mmwp_support_menu_icon Filter, since 1.0.1. Anchor: https://mammothwp.com/docs/support/hooks/#mmwp_support_menu_icon Filters the Mammoth WP menu icon (SVG data URI or dashicon name). Use a 20x20 viewBox, one path, `fill="black"` so WordPress recolours it to the admin scheme. | Name | Type | Description | | --- | --- | --- | | $icon | string | Data URI or dashicon name. | Source: https://github.com/mammothwp/support/blob/v1.1.1/src/Kernel.php#L272 ### mmwp_support_menu_position Filter, since 1.0.0. Anchor: https://mammothwp.com/docs/support/hooks/#mmwp_support_menu_position Filters the position of the top-level menu. | Name | Type | Description | | --- | --- | --- | | $position | int | Default 80. | Source: https://github.com/mammothwp/support/blob/v1.1.1/src/Config.php#L136 ### mmwp_support_rate_limit Filter, since 1.0.0. Anchor: https://mammothwp.com/docs/support/hooks/#mmwp_support_rate_limit Filters the send rate limit (per user). | Name | Type | Description | | --- | --- | --- | | $limit | array{max:int,window:int} | Max sends and window in seconds. | | $user_id | int | User id. | Source: https://github.com/mammothwp/support/blob/v1.1.1/src/Config.php#L91 ### mmwp_support_report_sections Filter, since 1.0.0. Anchor: https://mammothwp.com/docs/support/hooks/#mmwp_support_report_sections Filters the report sections. Add a `Section_Interface` object. Contract: no secrets; redaction runs over everything, but do not rely on it. | Name | Type | Description | | --- | --- | --- | | $sections | Section_Interface[] | Sections in output order. | Source: https://github.com/mammothwp/support/blob/v1.1.1/src/Report/Report_Builder.php#L112 ### mmwp_support_subject Filter, since 1.0.0. Anchor: https://mammothwp.com/docs/support/hooks/#mmwp_support_subject Filters the email subject. | Name | Type | Description | | --- | --- | --- | | $text | string | Formatted subject. | | $prefix | string | Subject prefix from branding. | | $slug | string | Plugin slug. | | $subject | string | User-entered subject. | | $ref | string | Reference. | Source: https://github.com/mammothwp/support/blob/v1.1.1/src/Mail/Ticket_Formatter.php#L74 ### mmwp_support_transport Filter, since 1.0.0. Anchor: https://mammothwp.com/docs/support/hooks/#mmwp_support_transport Filters the mail transport. Only MailTransport (wp_mail) ships. Any transport that sends data off-site needs the same explicit user action as the built-in one. | Name | Type | Description | | --- | --- | --- | | $transport | Transport_Interface | Transport. | Source: https://github.com/mammothwp/support/blob/v1.1.1/src/Kernel.php#L359 --- # support: REST routes and abilities Read from support at v1.1.1. ## REST routes ### GET /wp-json/mmwp/v1/report Site report Permission: Set by the resource default (not overridden on this operation). | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | format | string | | 'text' | Output format. | Source: https://github.com/mammothwp/support/blob/v1.1.1/src/Kit/Support_Resource.php#L1 ### GET /wp-json/mmwp/v1/plugins Registered plugins Permission: Set by the resource default (not overridden on this operation). Source: https://github.com/mammothwp/support/blob/v1.1.1/src/Kit/Support_Resource.php#L1 ### POST /wp-json/mmwp/v1/send Send a support request Permission: Set by the resource default (not overridden on this operation). | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | plugin | string | yes | | Slug of a registered plugin. | | subject | string | | | Subject. | | message | string | yes | | Message. | | reply_to | string | | | Reply-To address (default: current user). | | name | string | | | Sender name. | | include_report | boolean | | false | Include the site report (default false). | | include_log | boolean | | false | Include the last debug.log lines (default false). | | dry_run | boolean | | false | Print the email instead of sending it. | Source: https://github.com/mammothwp/support/blob/v1.1.1/src/Kit/Support_Resource.php#L1 ## Abilities ### mmwp/support-report Site report: Returns the redacted site report. Nothing is sent anywhere. Source: https://github.com/mammothwp/support/blob/v1.1.1/src/Kit/Support_Resource.php#L1 ### mmwp/support-plugins Registered plugins: Plugins registered with the Support screen. Source: https://github.com/mammothwp/support/blob/v1.1.1/src/Kit/Support_Resource.php#L1 ### mmwp/support-send Send a support request: Sends one email to the plugin's support address. The site report is included only with include_report. Source: https://github.com/mammothwp/support/blob/v1.1.1/src/Kit/Support_Resource.php#L1 --- # support: commands Read from support at v1.1.1. ## wp mmwp support report Site report Returns the redacted site report. Nothing is sent anywhere. ``` wp mmwp support report [--format=] ``` | Argument | Description | | --- | --- | | --format= | Output format. | Source: https://github.com/mammothwp/support/blob/v1.1.1/src/Kit/Support_Resource.php#L1 ## wp mmwp support plugins Registered plugins Plugins registered with the Support screen. ``` wp mmwp support plugins ``` Source: https://github.com/mammothwp/support/blob/v1.1.1/src/Kit/Support_Resource.php#L1 ## wp mmwp support send Send a support request Sends one email to the plugin's support address. The site report is included only with include_report. ``` wp mmwp support send --plugin= [--subject=] --message= [--reply_to=] [--name=] [--include_report] [--include_log] [--dry_run] ``` | Argument | Description | | --- | --- | | --plugin= | Slug of a registered plugin. | | --subject= | Subject. | | --message= | Message. | | --reply_to= | Reply-To address (default: current user). | | --name= | Sender name. | | --include_report | Include the site report (default false). | | --include_log | Include the last debug.log lines (default false). | | --dry_run | Print the email instead of sending it. | Source: https://github.com/mammothwp/support/blob/v1.1.1/src/Kit/Support_Resource.php#L1 --- # support: use cases ## Privacy statement Nothing leaves the site without an explicit user action (wordpress.org guideline 7). The package has no tracking, no phoning home, no remote assets, no update checks and no automatic sends. The only outgoing data is the one email a logged-in administrator sends by pressing **Send**. The site report is added only when they tick **Include site report**, and **Preview what will be sent** shows the exact text. Secrets are never collected and a redaction pass runs over everything. Copy-paste text for your `readme.txt`: [docs/PRIVACY.md](docs/PRIVACY.md). Source: https://github.com/mammothwp/support/blob/v1.1.1/README.md ## Where the screen lives One canonical screen; plugins can only add shortcuts to it, never move it. | Location | Meaning | |---|---| | `toplevel` | Own top-level menu (position filterable). Default **when a registered plugin has vendor "Mammoth WP"**, titled "Mammoth WP" with the submenu "Support". | | `tools` | Tools → Support. Default otherwise (neutral title "Support", no new top-level menu). | | `settings` | Settings → Support. | | `parent:` | Submenu of any existing menu. | | `hidden` | Registered page without a menu item (shortcuts and links only). | Site owners override with the constant `MMWP_SUPPORT_MENU` or the filter `mmwp_support_menu` (the filter wins). A plugin adds a shortcut under its own menu with `'menu' => [ 'parent' => 'my-plugin-settings', 'title' => 'Support' ]`; it links to the canonical screen with that plugin preselected. Every registered plugin also gets a "Support" action link on the Plugins screen (`mmwp_support_action_link` turns it off). `Support::MENU_PARENT` (`mmwp-support`) is the top-level menu slug, so Mammoth WP plugins can add pages under it. In multisite the screen is per site, plus a network admin screen when a registered plugin is network-activated. `wp mmwp support plugins` and the report show the resolved location and where it came from. Source: https://github.com/mammothwp/support/blob/v1.1.1/README.md ## White-label Branding (name, menu and page titles, icon, local logo, subject prefix, reference prefix, "Powered by", vendor names) resolves from defaults (Mammoth WP when a Mammoth WP plugin is registered, neutral otherwise), then a plugin's `'branding'` array (honoured only when every registered plugin has the same vendor), then the site owner's `MMWP_SUPPORT_WHITELABEL` constant (`true` = fully neutral, or an array of overrides), then the `mmwp_support_branding` filter. Agency guide: [docs/WHITELABEL.md](docs/WHITELABEL.md). Source: https://github.com/mammothwp/support/blob/v1.1.1/README.md ## Site-owner overrides | Setting | Constant | Filter | Order | |---|---|---|---| | Recipient | `MMWP_SUPPORT_EMAIL_` | `mmwp_support_email` | filter > constant > array > JSON | | Menu location | `MMWP_SUPPORT_MENU` | `mmwp_support_menu` | filter > constant > default | | White-label | `MMWP_SUPPORT_WHITELABEL` | `mmwp_support_branding` | filter > constant > vendor > defaults | Source: https://github.com/mammothwp/support/blob/v1.1.1/README.md ## Adding a report section ```php add_filter( 'mmwp_support_report_sections', function ( array $sections ): array { $sections[] = new My_Plugin_Report_Section(); // implements \MammothWP\Support\Report\Section_Interface return $sections; } ); ``` `Section_Interface` has `key()`, `label()` and `collect()` (scalars, lists or one level of associative arrays). Contract: **no secrets** (no keys, tokens, passwords, customer data). A redaction pass over the whole report removes absolute paths (ABSPATH), emails, IP addresses, URL credentials, `key=value` pairs with secret-looking keys, JWTs and long tokens, but do not rely on it. Source: https://github.com/mammothwp/support/blob/v1.1.1/README.md ## Single instance across many plugins Every bundled copy registers its version and path in a shared registry. On `plugins_loaded` (priority -50) exactly one copy boots: the highest version. It prepends a class loader for `MammothWP\Support\` pointing at its own `src/`, so its classes are the ones used. Only `Support` (a thin shim) and the functions in `bootstrap.php` come from whichever copy loaded first, so they stay tiny and backwards compatible forever. `register_plugin()` may be called before boot (queued, replayed) or after (applied at once). The menu appears only if at least one plugin is registered. ### Bundling wp-kit has no election across plugins: whichever copy loads first wins. Prefix bundled libraries (for example with [Strauss](https://github.com/BrianHenryIE/strauss)) so your copies cannot collide with another plugin's. This package guards against it (it checks the loaded wp-kit version) but cannot fix it. Source: https://github.com/mammothwp/support/blob/v1.1.1/README.md ## Transport `TransportInterface` with one implementation, `MailTransport` (`wp_mail()`); swap through `mmwp_support_transport`. There is no API transport. Any future one needs the same explicit user consent. Source: https://github.com/mammothwp/support/blob/v1.1.1/README.md ## Branches `develop` is the working branch. `main` is release-only. Source: https://github.com/mammothwp/support/blob/v1.1.1/README.md --- # wp-kit: docs Declare a feature once and expose it as a REST route, a WP-CLI command and a WordPress Ability. Generated from wp-kit at release v2.0.3. ## Install ```bash composer require mammothwp/wp-kit ``` ## Usage snippets ### Loading ```php // Composer's autoload.files requires bootstrap.php; nothing else to include. \MammothWP\WpKit\Kit::on_ready(function () { // declare resources, register adapters }); ``` ### Declaring a resource ```php use MammothWP\WpKit\{Field, Operation, Resource}; final class Things_Resource extends Resource { public function name() { return 'things'; } public function service() { return Things_Service::instance(); } public function rest_namespace() { return 'my-plugin/v1'; } public function cli_base() { return 'my-plugin things'; } public function ability_category() { return 'my-plugin-things'; } public function operations() { return [ 'list' => Operation::read('list_items') ->label('List things') ->input([ Field::enum('status', ['enabled', 'disabled'])->multiple(), Field::int('limit')->default_value(50)->max(200)->describe('Rows to return.'), ]) ->rest('GET') ->cli('list', ['columns' => ['id', 'title', 'status']]) ->ability('list-things'), 'delete' => Operation::destructive('delete_items') ->input([Field::int_list('ids')->positional()->required()]) ->rest('DELETE', '/delete/') ->cli('delete', ['success' => 'Deleted %count% thing(s).']) ->ability('delete-things'), ]; } } ``` ### Declaring a resource ```php $registry = (new \MammothWP\WpKit\Registry())->add(new Things_Resource())->ready(); foreach ($registry->all() as $resource) { (new \MammothWP\WpKit\Adapters\Rest_Adapter($resource))->register(); (new \MammothWP\WpKit\Adapters\Cli_Adapter($resource))->register(); (new \MammothWP\WpKit\Adapters\Ability_Adapter($resource))->register(); } ``` ### Choosing surfaces ```php class Admin_Helpers_Resource extends Resource { /** No meaningful CLI for editor helpers: register REST and abilities only. */ public function surfaces() { return [Context::REST, Context::ABILITY]; } public function surfaces_reason() { return 'editor helpers have no command-line use'; } } ``` ## In these docs - [Hooks & filters](https://mammothwp.com/docs/wp-kit/hooks/) - [Use cases](https://mammothwp.com/docs/wp-kit/use-cases/) --- # wp-kit: hooks and filters Read from wp-kit at v2.0.3. ## Execution path (Operation::run()) Example (mmwp_kit_operation_capability): ```php add_filter( 'mmwp_kit_operation_capability', function ( $resolved, $op, $input, $ctx ) { // Change $resolved here. return $resolved; }, 10, 4 ); ``` ### mmwp_kit_operation_before_run Action, since 0.1.0. Anchor: https://mammothwp.com/docs/wp-kit/hooks/#mmwp_kit_operation_before_run Fires before an operation's service method runs. | Name | Type | Description | | --- | --- | --- | | $op | Operation | Operation. | | $input | array | Normalised input. | | $ctx | Context | Invocation context. | Source: https://github.com/mammothwp/wp-kit/blob/v2.0.3/src/Operation.php#L529 ### mmwp_kit_operation_capability Filter, since 0.1.0. Anchor: https://mammothwp.com/docs/wp-kit/hooks/#mmwp_kit_operation_capability Filter the capability required by an operation. | Name | Type | Description | | --- | --- | --- | | $resolved | bool\|string | Capability string, true (public) or false (denied). | | $op | Operation | Operation being authorised. | | $input | array | Normalised input. | | $ctx | Context | Invocation context. | Source: https://github.com/mammothwp/wp-kit/blob/v2.0.3/src/Capability.php#L51 ### mmwp_kit_operation_result Filter, since 0.1.0. Anchor: https://mammothwp.com/docs/wp-kit/hooks/#mmwp_kit_operation_result Filter an operation's result before it reaches the surface adapter. | Name | Type | Description | | --- | --- | --- | | $result | mixed | Result or WP_Error. | | $op | Operation | Operation. | | $input | array | Normalised input. | | $ctx | Context | Invocation context. | Source: https://github.com/mammothwp/wp-kit/blob/v2.0.3/src/Operation.php#L549 ## Lifecycle Example (mmwp_kit_booted): ```php add_action( 'mmwp_kit_booted', function ( $path, $version ) { // Your code. }, 10, 2 ); ``` ### mmwp_kit_booted Action, since 0.1.0. Anchor: https://mammothwp.com/docs/wp-kit/hooks/#mmwp_kit_booted Fires once the kit has chosen a copy and installed its autoloader. | Name | Type | Description | | --- | --- | --- | | $path | string | Directory of the booted copy. | | $version | string | Version of the booted copy. | Source: https://github.com/mammothwp/wp-kit/blob/v2.0.3/src/Kit.php#L141 ### mmwp_kit_registry_ready Action, since 0.1.0. Anchor: https://mammothwp.com/docs/wp-kit/hooks/#mmwp_kit_registry_ready Fires once every resource has been declared. | Name | Type | Description | | --- | --- | --- | | $registry | Registry | The registry. | Source: https://github.com/mammothwp/wp-kit/blob/v2.0.3/src/Registry.php#L92 ## Surfaces Example (mmwp_kit_ability_definitions): ```php add_filter( 'mmwp_kit_ability_definitions', function ( $definitions, $resource ) { // Change $definitions here. return $definitions; }, 10, 2 ); ``` ### mmwp_kit_ability_definitions Filter, since 0.1.0. Anchor: https://mammothwp.com/docs/wp-kit/hooks/#mmwp_kit_ability_definitions Filter the ability definitions of one resource before registration. | Name | Type | Description | | --- | --- | --- | | $definitions | array | Definitions keyed by ability name. | | $resource | Resource | Owning resource. | Source: https://github.com/mammothwp/wp-kit/blob/v2.0.3/src/Adapters/Ability_Adapter.php#L105 --- # wp-kit: use cases ## Declaring a resource ```php use MammothWP\WpKit\{Field, Operation, Resource}; final class Things_Resource extends Resource { public function name() { return 'things'; } public function service() { return Things_Service::instance(); } public function rest_namespace() { return 'my-plugin/v1'; } public function cli_base() { return 'my-plugin things'; } public function ability_category() { return 'my-plugin-things'; } public function operations() { return [ 'list' => Operation::read('list_items') ->label('List things') ->input([ Field::enum('status', ['enabled', 'disabled'])->multiple(), Field::int('limit')->default_value(50)->max(200)->describe('Rows to return.'), ]) ->rest('GET') ->cli('list', ['columns' => ['id', 'title', 'status']]) ->ability('list-things'), 'delete' => Operation::destructive('delete_items') ->input([Field::int_list('ids')->positional()->required()]) ->rest('DELETE', '/delete/') ->cli('delete', ['success' => 'Deleted %count% thing(s).']) ->ability('delete-things'), ]; } } ``` Then, inside `Kit::on_ready()`: ```php $registry = (new \MammothWP\WpKit\Registry())->add(new Things_Resource())->ready(); foreach ($registry->all() as $resource) { (new \MammothWP\WpKit\Adapters\Rest_Adapter($resource))->register(); (new \MammothWP\WpKit\Adapters\Cli_Adapter($resource))->register(); (new \MammothWP\WpKit\Adapters\Ability_Adapter($resource))->register(); } ``` Source: https://github.com/mammothwp/wp-kit/blob/v2.0.3/README.md ## Choosing surfaces Every operation is exposed on REST, WP-CLI and the Abilities API unless told otherwise. Leave a layer out per operation with `Operation::surfaces()`, or for the whole resource by overriding `Resource::surfaces()`; operations that call `surfaces()` themselves keep their own choice: ```php class Admin_Helpers_Resource extends Resource { /** No meaningful CLI for editor helpers: register REST and abilities only. */ public function surfaces() { return [Context::REST, Context::ABILITY]; } public function surfaces_reason() { return 'editor helpers have no command-line use'; } } ``` The reason travels into `Inventory::export()` so parity tests can tell a deliberate omission from a missing registration. Both accept a callable for decisions that depend on runtime state. ### MCP Every ability the kit registers carries `meta.mcp.public = true`, so any MCP server built on [`wordpress/mcp-adapter`](https://github.com/WordPress/mcp-adapter) (its default server, Agent Connector, …) lists it as a tool with the field schema and the read/write/destructive annotations. The kit ships no MCP server and no dependency; the tool call runs the ability, so capability and `confirm` checks apply unchanged. Keep a resource off MCP with: ```php public function mcp() { return false; } ``` Source: https://github.com/mammothwp/wp-kit/blob/v2.0.3/README.md ## What one field becomes | `Field::int('limit')->default_value(50)->max(200)` | Output | |---|---| | REST | `'limit' => ['type' => 'integer', 'default' => 50, 'maximum' => 200, 'description' => …, 'sanitize_callback' => …, 'validate_callback' => …]` | | CLI | `[--limit=]` in the synopsis and in `wp help` | | Ability | `'limit' => ['type' => 'integer', 'default' => 50, 'maximum' => 200, 'description' => …]` | Field types: `string`, `int`, `bool`, `enum`, `object`, `int_list`, `array`. Modifiers: `required()`, `default_value()`, `min()`, `max()`, `max_length()`, `describe()`, `validate_with()`, `sanitize_with()`, `multiple()`, `positional()` (CLI), `items()`, `properties()`. Source: https://github.com/mammothwp/wp-kit/blob/v2.0.3/README.md ## Execution path `Operation::run()` is the only path every surface uses: 1. normalise input against the fields and apply defaults 2. validate (`mmwp_invalid_param`, 400). Nested `properties()` of an object field are validated the same way (required, bounds, enum, `validate_with()`), and `additional_properties(false)` rejects undeclared keys. Only the kit enforces these: the surfaces advertise the schema but do not validate against it. 3. authorise (`mmwp_forbidden`, 403). A resolver is `true`, `false`, a capability string, or `fn(Operation, array $input, Context)` returning one of those. An unattended WP-CLI run (no `--user`) is trusted as an administrator; with `--user` the capability is enforced like any surface. 4. confirm destructive calls where required (`mmwp_confirm_required`, 400). Abilities pass `confirm: true`; the CLI passes `--yes`. 5. `do_action('mmwp_kit_operation_before_run', $op, $input, $ctx)` 6. call the service method: declared fields are matched to parameters by name; with `->allow_extra()` the undeclared keys go to the first array-typed parameter and never bind by name. Use `->args(callable)` to take over the mapping. A handler exception becomes `mmwp_operation_failed` (500) with a generic message; the exception text is logged and only attached as `detail` on the CLI or when `WP_DEBUG` is on. 7. `apply_filters('mmwp_kit_operation_result', $result, $op, $input, $ctx)` On REST, a `WP_Error` result keeps its own status. Any other result is sent with the status declared in `->rest($method, $path, $status)` (default 200), unless the service returns a `WP_REST_Response` with a status other than 200: that status and its headers are kept (0.3.3). This lets an operation wrap a legacy handler that answers `new WP_REST_Response($body, 503)` without changing what clients receive. Source: https://github.com/mammothwp/wp-kit/blob/v2.0.3/README.md ## Inventory `Inventory::export($registry)` returns one row per operation with the REST routes, CLI commands and ability names it is exposed as, plus the surfaces it deliberately excludes and why. Parity tests and generated test manifests read it instead of hand-maintained lists. Source: https://github.com/mammothwp/wp-kit/blob/v2.0.3/README.md --- # wp-release: docs Build, check and ship a plugin release: production zip, Plugin Check, wordpress.org SVN deploy. Generated from wp-release at release v1.0.1. ## Install ```bash composer require mammothwp/wp-release --dev ``` ## Usage snippets ### Install ```bash composer require --dev mammothwp/wp-release vendor/bin/mmwp-release --help ``` ### Configuration ```json { "slug": "my-plugin", "php_min": "7.4", "wp_version": "6.8", "strict": false, "allow": [], "excludes": ["*.psd", "docs/*"], "github_repo": "my-plugin" } ``` ### Local usage (DDEV or any local PHP) ```bash ddev composer require --dev mammothwp/wp-release ddev exec vendor/bin/mmwp-release build ddev exec vendor/bin/mmwp-release check # add --strict, --self-test, --keep ddev exec vendor/bin/mmwp-release status ddev exec vendor/bin/mmwp-release deploy-wporg --dry-run ``` ## In these docs - [Command line](https://mammothwp.com/docs/wp-release/cli/) - [Use cases](https://mammothwp.com/docs/wp-release/use-cases/) --- # wp-release: commands Read from wp-release at v1.0.1. ## mmwp-release all build → check → deploy-wporg → release-github, stopping at the first failure ``` mmwp-release all ``` | Argument | Description | | --- | --- | | --fail-if-unapproved | Exit 32 when the plugin has no wordpress.org SVN repository yet | | --dry-run | Do not publish anything (deploy-wporg stops before svn commit) | | --debug-mode | Verbose output (command output, table format) | | --force | Skip the passing-check gate | | --path= | Plugin root (default: current directory) | | --format= | Output format: table\|json | | --slug= | Plugin slug (default: MMWP_SLUG or main plugin file) | Source: https://github.com/mammothwp/wp-release/blob/v1.0.1/src/Command/AllCommand.php#L23 ## mmwp-release build Build dist// and .zip and validate them ``` mmwp-release build ``` | Argument | Description | | --- | --- | | --dry-run | Do not publish anything (deploy-wporg stops before svn commit) | | --debug-mode | Verbose output (command output, table format) | | --force | Skip the passing-check gate | | --path= | Plugin root (default: current directory) | | --format= | Output format: table\|json | | --slug= | Plugin slug (default: MMWP_SLUG or main plugin file) | Source: https://github.com/mammothwp/wp-release/blob/v1.0.1/src/Command/BuildCommand.php#L22 ## mmwp-release check Run Plugin Check against the built zip in a throwaway WordPress site ``` mmwp-release check ``` | Argument | Description | | --- | --- | | --strict | Fail on Plugin Check warnings too (MMWP_PCP_STRICT=true) | | --allow= | Allow Plugin Check WARNINGs matching this regex; repeatable (MMWP_PCP_ALLOW) | | --deps= | wordpress.org slugs (space/comma separated) installed and activated before the plugin; overrides MMWP_BROWSER_DEPS | | --self-test | Also run `wp mmwp self-test ` when provided | | --keep | Keep the temp WordPress site | | --db= | sqlite (default) or mysql (MMWP_DB_HOST/USER/PASS/NAME) | | --dry-run | Do not publish anything (deploy-wporg stops before svn commit) | | --debug-mode | Verbose output (command output, table format) | | --force | Skip the passing-check gate | | --path= | Plugin root (default: current directory) | | --format= | Output format: table\|json | | --slug= | Plugin slug (default: MMWP_SLUG or main plugin file) | Source: https://github.com/mammothwp/wp-release/blob/v1.0.1/src/Command/CheckCommand.php#L54 ## mmwp-release deploy-wporg Publish the built plugin to wordpress.org SVN (idempotent) ``` mmwp-release deploy-wporg ``` | Argument | Description | | --- | --- | | --fail-if-unapproved | Exit 32 when the plugin has no wordpress.org SVN repository yet | | --strict | Also require 0 warnings in plugin-check.json | | --allow= | Allow Plugin Check WARNINGs matching this regex when re-checking; repeatable (MMWP_PCP_ALLOW) | | --dry-run | Do not publish anything (deploy-wporg stops before svn commit) | | --debug-mode | Verbose output (command output, table format) | | --force | Skip the passing-check gate | | --path= | Plugin root (default: current directory) | | --format= | Output format: table\|json | | --slug= | Plugin slug (default: MMWP_SLUG or main plugin file) | Source: https://github.com/mammothwp/wp-release/blob/v1.0.1/src/Command/DeployWporgCommand.php#L37 ## mmwp-release release-github Create the GitHub Release for the header version (idempotent) ``` mmwp-release release-github ``` | Argument | Description | | --- | --- | | --dry-run | Do not publish anything (deploy-wporg stops before svn commit) | | --debug-mode | Verbose output (command output, table format) | | --force | Skip the passing-check gate | | --path= | Plugin root (default: current directory) | | --format= | Output format: table\|json | | --slug= | Plugin slug (default: MMWP_SLUG or main plugin file) | Source: https://github.com/mammothwp/wp-release/blob/v1.0.1/src/Command/ReleaseGithubCommand.php#L22 ## mmwp-release status Show versions, published state and what the deploy commands would do ``` mmwp-release status ``` | Argument | Description | | --- | --- | | --dry-run | Do not publish anything (deploy-wporg stops before svn commit) | | --debug-mode | Verbose output (command output, table format) | | --force | Skip the passing-check gate | | --path= | Plugin root (default: current directory) | | --format= | Output format: table\|json | | --slug= | Plugin slug (default: MMWP_SLUG or main plugin file) | Source: https://github.com/mammothwp/wp-release/blob/v1.0.1/src/Command/StatusCommand.php#L22 --- # wp-release: use cases ## Commands All commands accept the global flags below. Results go to stdout (table or JSON); logs, including every external command that is run, go to stderr. | Command | What it does | |---|---| | `build` | Fresh `composer install --no-dev --optimize-autoloader` in a temp copy (the working tree's `vendor/` is never touched), requires `.distignore`, rsyncs to `dist//`, zips to `.zip`, validates headers/readme/forbidden files, prints zip size and file count. | | `check` | Throwaway WordPress in a temp dir (SQLite drop-in, or MySQL), installs Plugin Check, unzips the **built** zip (builds first if the zip is missing or older than the sources), activates it, runs `wp plugin check`, writes `plugin-check.json` and `plugin-check.txt`, removes the site. Fails on any ERROR (and any WARNING with `--strict`). | | `deploy-wporg` | Needs a passing check. Skips (exit 0) if `tags/` already exists on `plugins.svn.wordpress.org`, or if the plugin has no SVN repo yet. Otherwise syncs trunk (+ `.wordpress-org/` → assets), `svn cp trunk tags/` and commits. | | `release-github` | Skips (exit 0) if release `v` exists in `mammothwp/`. Otherwise creates it with the matching `readme.txt` changelog section as notes and uploads `.zip`. | | `status` | Prints slug, header version, readme Stable tag, whether the version exists on wordpress.org and GitHub, and what each deploy command would do. | | `all` | `build` → `check` → `deploy-wporg` → `release-github`, stopping at the first failure. | ### Global flags | Flag | Meaning | |---|---| | `--dry-run` | Publish nothing. `deploy-wporg` prints the `svn status` diff and stops before commit; `release-github` prints the planned release. | | `--debug-mode` | Verbose: stream command output, also print the Plugin Check table. | | `--force` | Skip the "passing check required" gate of `deploy-wporg`. Never overwrites a published version. | | `--path=` | Plugin repository root (default: current directory). | | `--format=table\|json` | Result format on stdout (default `table`). | | `--slug=` | Plugin slug (see configuration). | Command specific: `check` / `all`: `--strict`, `--deps=` (see [Plugin dependencies](#plugin-dependencies)), `--self-test`, `--keep`, `--db=sqlite|mysql`; `deploy-wporg` / `all`: `--fail-if-unapproved`; `deploy-wporg`: `--strict`; `check` / `all` / `deploy-wporg`: `--allow=` (repeatable, see [Plugin Check allow-list](#plugin-check-allow-list)). Source: https://github.com/mammothwp/wp-release/blob/v1.0.1/README.md ## Configuration Precedence: CLI flag > `MMWP_*` environment > `mmwp-release.json` > auto-detection. **Slug:** `--slug`, `MMWP_SLUG`, `slug` in `mmwp-release.json`, else the single root `*.php` file with a `Plugin Name:` header (file name without `.php`). The main file must be `.php`. **`mmwp-release.json`** (optional, plugin root): ```json { "slug": "my-plugin", "php_min": "7.4", "wp_version": "6.8", "strict": false, "allow": [], "excludes": ["*.psd", "docs/*"], "github_repo": "my-plugin" } ``` `php_min`: the `Requires PHP` header must be at least this. `wp_version`: WordPress version of the check site (overridden by `WP_VERSION`). `strict`: same as `MMWP_PCP_STRICT`. `allow`: same as `MMWP_PCP_ALLOW` (array of regexes). `excludes`: extra forbidden-file patterns (`fnmatch`, matched against each path and file name in the zip). ### Plugin dependencies Plugins with a `Requires Plugins:` header cannot be activated alone. Set `MMWP_BROWSER_DEPS="woocommerce"` (or `--deps=woocommerce`, which overrides the variable) and `check` runs `wp plugin install --activate` for each slug before activating the plugin under test. Plugin Check still checks only the plugin under test. Unset/empty keeps the old behaviour; if the header is present and no dependencies are given, `check` fails (exit 2) naming `MMWP_BROWSER_DEPS`. ### Plugin Check allow-list Strict mode fails on any Plugin Check warning. A narrow allow-list lets known, reviewer-approved warnings through: `--allow=` (repeatable), `MMWP_PCP_ALLOW` (newline- or `||`-separated) and `allow` in `mmwp-release.json` are merged. Nothing is allowed by default. - A finding is allowed only if it is a **WARNING** and the regex matches the string `: `. **ERRORs can never be allowed.** - Regexes are bare PCRE bodies without delimiters, matched with the `u` flag; use inline flags such as `(?i)`. An invalid regex exits with code 2. - Allowed findings are listed as `ALLOWED: : (rule: )` in `plugin-check.txt` and on the console, the counts read `N error(s), M warning(s), K allowed`, and `plugin-check.json` gets `allowed` and `allowed_findings`. `warnings` excludes allowed findings, so the strict exit-20 decision and the `deploy-wporg` gate ignore them. Recommended rule for the "MammothWP" brand: wordpress.org reviewers approve "BrandWP" names, but Plugin Check warns `trademarked_term` for the "wp" in "MammothWP". This allows only that term, only when the quoted name/slug contains `mammothwp` (not `wordpress`, `woocommerce`, or other brands): ``` (?i)^trademarked_term: (?=.*(?.php` exists; headers `Text Domain` == slug, `Requires PHP`, `Requires at least`, `License` (GPL), `Version`. - `readme.txt` exists with `Stable tag` and `Tested up to`; header `Version` == readme `Stable tag`. - The zip contains no `.git`, `node_modules`, `tests`, `phpunit*`, `phpcs*`, `phpstan*`, `.gitlab-ci.yml`, `.wordpress-org`, `.env*`, `*.log`; `composer.lock` only if `vendor/` ships. Inside `vendor/` only the secret-like patterns apply (third-party packages legitimately ship tests). - Pre-flight (exit 11, offending paths listed, before Plugin Check): the zip contains no hidden file or directory at any level (dot-files, dot-dirs, including inside `vendor/`) and no `*.phar`. - `.distignore` (rsync exclude syntax) is required and is itself never shipped. - Runtime dependencies (exit 11): when `composer.json` `require`s anything besides `php`/`ext-*`/`lib-*`, the zip must contain `vendor/autoload.php`, `vendor/composer/autoload_real.php` and `autoload_classmap.php`, and a `vendor//` directory for every package in the plugin's `composer.lock` `packages` (non-dev; metapackages skipped). Typical cause: a bare `vendor` line in `.distignore`. Plugin Check does not notice a zip like that. - `.distignore` warning (fails the build with `--strict` / `MMWP_PCP_STRICT`): an unanchored entry (no leading `/`) named `tests`, `docs`, `bin`, `README.md`, `composer.lock` or `vendor` also strips those files inside `vendor/` packages; write `/tests` etc. Source: https://github.com/mammothwp/wp-release/blob/v1.0.1/README.md ## What build copies In a git work tree `build` ships only **tracked** files (`git ls-files`), then applies `.distignore`, then adds the fresh no-dev `vendor/`. Untracked CI leftovers (`.cache/composer`, a downloaded `wp-cli.phar`, a tools dir, stray files) can never reach the zip, and `.distignore` does not need to know about CI internals. Without git, the whole tree is copied minus every dot-file/dot-dir, `*.phar`, `.cache/`, `node_modules`, `dist`, the zip itself and the directory named by `MMWP_TOOLS_DIR` (when inside the plugin root). Source: https://github.com/mammothwp/wp-release/blob/v1.0.1/README.md ## Local usage (DDEV or any local PHP) ```bash ddev composer require --dev mammothwp/wp-release ddev exec vendor/bin/mmwp-release build ddev exec vendor/bin/mmwp-release check # add --strict, --self-test, --keep ddev exec vendor/bin/mmwp-release status ddev exec vendor/bin/mmwp-release deploy-wporg --dry-run ``` Use `--db=mysql` with `MMWP_DB_HOST=db MMWP_DB_USER=db MMWP_DB_PASS=db` to reuse a local database service such as DDEV's. Source: https://github.com/mammothwp/wp-release/blob/v1.0.1/README.md ## CI Plugins get these jobs through `mammothwp/ci-templates` (job and file names there may differ; the commands are what matters). The jobs boil down to: ```yaml stages: [build, check, release] mmwp-build: tags: [docker] stage: build script: [vendor/bin/mmwp-release build] artifacts: { paths: ["*.zip", "dist/"] } mmwp-check: tags: [docker] stage: check script: [vendor/bin/mmwp-release check] artifacts: { when: always, paths: [plugin-check.json, plugin-check.txt] } mmwp-release: tags: [docker] stage: release rules: [{ if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH }] script: - apk add --no-cache rsync zip unzip curl bash subversion - vendor/bin/mmwp-release deploy-wporg - vendor/bin/mmwp-release release-github ``` Required CI variables (masked, protected): `WPORG_USERNAME`, `WPORG_PASSWORD`, `GITHUB_TOKEN`; optional `MMWP_*` as above. Because every release command is idempotent, the release job can run on every push to `main`. See [docs/ci.md](docs/ci.md) and [docs/release-model.md](docs/release-model.md). Source: https://github.com/mammothwp/wp-release/blob/v1.0.1/README.md --- # wp-self-test: docs Manifest-driven self-test suite for plugin REST, WP-CLI and Abilities checks. Generated from wp-self-test at release v2.2.5. ## Install ```bash composer require mammothwp/wp-self-test ``` ## Usage snippets ### Install in a plugin ```bash composer require mammothwp/wp-self-test ``` ## In these docs - [Hooks & filters](https://mammothwp.com/docs/wp-self-test/hooks/) - [REST & Abilities](https://mammothwp.com/docs/wp-self-test/rest/) - [Command line](https://mammothwp.com/docs/wp-self-test/cli/) - [Use cases](https://mammothwp.com/docs/wp-self-test/use-cases/) --- # wp-self-test: hooks and filters Read from wp-self-test at v2.2.5. ## Browser layer Example (mmwp_self_test_admin_menu_exclusions): ```php add_filter( 'mmwp_self_test_admin_menu_exclusions', function ( $needles ) { // Change $needles here. return $needles; } ); ``` ### mmwp_self_test_admin_menu_exclusions Filter, since 2.1.0. Anchor: https://mammothwp.com/docs/wp-self-test/hooks/#mmwp_self_test_admin_menu_exclusions Filter the substrings that keep an admin URL out of the browser crawl (matched case-insensitively). Return the defaults plus your own, or remove defaults you accept to crawl. | Name | Type | Description | | --- | --- | --- | | $needles | string[] | Default: DEFAULT_EXCLUSIONS. | Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/src/Browser/Menu_Inventory.php#L125 ### mmwp_self_test_browser_command Filter, since 2.1.0. Anchor: https://mammothwp.com/docs/wp-self-test/hooks/#mmwp_self_test_browser_command Filter the command that starts the Node runner of the browser layer. | Name | Type | Description | | --- | --- | --- | | $command | string[] | Default: `node /e2e/bin/run.js`. | | $package | string | Directory of the installed e2e package. | | $definition | array | Case definition. | Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/src/Browser/Browser_Runner.php#L118 ## Gates and configuration Example (mmwp_self_test_abilities_available): ```php add_filter( 'mmwp_self_test_abilities_available', function ( $available ) { // Change $available here. return $available; } ); ``` ### mmwp_self_test_abilities_available Filter, since 0.1.0. Anchor: https://mammothwp.com/docs/wp-self-test/hooks/#mmwp_self_test_abilities_available Filter whether the Abilities API is usable for self-tests; a host plugin that gates its own abilities can report that here. | Name | Type | Description | | --- | --- | --- | | $available | bool | Default: `function_exists('wp_get_ability')`. | Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/src/Config.php#L143 ### mmwp_self_test_asset_url Filter, since 0.1.0. Anchor: https://mammothwp.com/docs/wp-self-test/hooks/#mmwp_self_test_asset_url Filter the URL of a package asset. | Name | Type | Description | | --- | --- | --- | | $url | string | Resolved URL. | | $relative | string | Path relative to the package root. | Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/src/Config.php#L172 ### mmwp_self_test_capability Filter, since 0.1.0. Anchor: https://mammothwp.com/docs/wp-self-test/hooks/#mmwp_self_test_capability Filter the capability required to run the self-tests. | Name | Type | Description | | --- | --- | --- | | $capability | string | Default 'manage_options'. | Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/src/Config.php#L39 ### mmwp_self_test_shim_enabled Filter, since 0.1.0. Anchor: https://mammothwp.com/docs/wp-self-test/hooks/#mmwp_self_test_shim_enabled Filter whether the WP_CLI shim is defined on web requests. | Name | Type | Description | | --- | --- | --- | | $enabled | bool | Default: the dashboard is enabled. | Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/src/Config.php#L123 ### mmwp_self_test_ui_enabled Filter, since 0.6.0. Anchor: https://mammothwp.com/docs/wp-self-test/hooks/#mmwp_self_test_ui_enabled Filter whether the self-test dashboard (and its REST routes) are registered. | Name | Type | Description | | --- | --- | --- | | $enabled | bool | Default: true unless the environment is `production`. | | $environment | string | `WP_ENV` or `wp_get_environment_type()`. | Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/src/Config.php#L103 ## Lifecycle Example (mmwp_self_test_enabled): ```php add_filter( 'mmwp_self_test_enabled', function ( $enabled, $environment ) { // Change $enabled here. return $enabled; }, 10, 2 ); ``` ### mmwp_self_test_booted Action, since 0.1.0. Anchor: https://mammothwp.com/docs/wp-self-test/hooks/#mmwp_self_test_booted Fires once the self-test package has booted. | Name | Type | Description | | --- | --- | --- | | $path | string | Directory of the booted copy. | | $version | string | Version of the booted copy. | Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/src/Loader.php#L128 ### mmwp_self_test_enabled Filter, since 0.7.0. Anchor: https://mammothwp.com/docs/wp-self-test/hooks/#mmwp_self_test_enabled Filter whether the self-test package boots on this request (surfaces, manifests, dashboard, commands and abilities). | Name | Type | Description | | --- | --- | --- | | $enabled | bool | Default: true unless the environment is `production`. | | $environment | string | `WP_ENV` or `wp_get_environment_type()`. | Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/src/Loader.php#L168 ### mmwp_self_test_manifest Filter, since 0.1.0. Anchor: https://mammothwp.com/docs/wp-self-test/hooks/#mmwp_self_test_manifest Filter one parsed manifest before its cases are built. | Name | Type | Description | | --- | --- | --- | | $manifest | array | Parsed manifest (`version`, `cases`). | | $path | string | Manifest file path. | | $plugin | string | Owning plugin slug. | Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/src/Manifest.php#L96 ### mmwp_self_test_register Action, since 0.1.0. Anchor: https://mammothwp.com/docs/wp-self-test/hooks/#mmwp_self_test_register Register self-test manifests. | Name | Type | Description | | --- | --- | --- | | $registry | Registry | Call `$registry->register($slug, $manifest_path, $opts)`. | Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/src/Self_Test.php#L125 ## Surfaces and runs Example (mmwp_self_test_ability_definitions): ```php add_filter( 'mmwp_self_test_ability_definitions', function ( $definitions ) { // Change $definitions here. return $definitions; } ); ``` ### mmwp_self_test_ability_definitions Filter, since 0.1.0. Anchor: https://mammothwp.com/docs/wp-self-test/hooks/#mmwp_self_test_ability_definitions Filter the self-test ability definitions before registration. | Name | Type | Description | | --- | --- | --- | | $definitions | array | Definitions keyed by ability name. | Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/src/Surfaces/Abilities.php#L77 ### mmwp_self_test_completed Action, since 0.1.0. Anchor: https://mammothwp.com/docs/wp-self-test/hooks/#mmwp_self_test_completed Fires after a self-test run completed. | Name | Type | Description | | --- | --- | --- | | $report | array | The report that was stored. | Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/src/Runner.php#L194 --- # wp-self-test: REST routes and abilities Read from wp-self-test at v2.2.5. ## REST routes ### GET /wp-json/mmwp/v1/self-test/cases Permission: Callback check_permission(). | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | ids | array | | [] | Case ids from the registered manifests. Empty for every case. | | plugin | string | | '' | Restrict to the cases of one registered plugin slug. | Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/src/Surfaces/Rest.php#L87 ### GET /wp-json/mmwp/v1/self-test/preview Permission: Callback check_permission(). | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | ids | array | | [] | Case ids from the registered manifests. Empty for every case. | | plugin | string | | '' | Restrict to the cases of one registered plugin slug. | Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/src/Surfaces/Rest.php#L94 ### POST /wp-json/mmwp/v1/self-test/run Permission: Callback check_permission(). | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | ids | array | | [] | Case ids from the registered manifests. Empty for every case. | | plugin | string | | '' | Restrict to the cases of one registered plugin slug. | | cleanup | boolean | | false | Remove the test data right after each case instead of keeping it for "Remove test data". | | layer | string | | '' | Run only the cases declaring this layer (rest, cli, ability, core, browser). | Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/src/Surfaces/Rest.php#L101 ### POST /wp-json/mmwp/v1/self-test/cleanup Permission: Callback check_permission(). | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | ids | array | | [] | Case ids from the registered manifests. Empty for every case. | | plugin | string | | '' | Restrict to the cases of one registered plugin slug. | Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/src/Surfaces/Rest.php#L125 ### GET /wp-json/mmwp/v1/self-test/report Permission: Callback check_permission(). Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/src/Surfaces/Rest.php#L132 ### GET /wp-json/mmwp/v1/self-test/admin-menu Permission: Callback check_permission(). Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/src/Surfaces/Rest.php#L138 ### POST /wp-json/mmwp/v1/self-test/browser-login Permission: Permission of the login exchange: the token is the credential. Callback check_login_token(). | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | token | string | yes | | Single-use login token issued by the runner for the browser layer. | Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/src/Surfaces/Rest.php#L144 ## Abilities ### mmwp/self-test-list-cases List self-test cases: The cases every registered plugin declares, with their plugin, category, the surfaces (rest/cli/ability) they cover, whether they can run on this site and whether an earlier run left test data behind. Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/src/Surfaces/Abilities.php#L98 ### mmwp/self-test-preview Preview self-test steps: The ordered steps each case would perform, without running anything. Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/src/Surfaces/Abilities.php#L106 ### mmwp/self-test-run Run self-tests: Run the given cases (default: all), validate every surface and store the report. Creates test data on this site unless cleanup is true; pass confirm: true. Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/src/Surfaces/Abilities.php#L114 ### mmwp/self-test-cleanup Remove self-test data: Delete the test data left by earlier runs (default: every case with pending data). Pass confirm: true. Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/src/Surfaces/Abilities.php#L122 ### mmwp/self-test-get-report Get the last self-test report: The report stored by the most recent run: summary counts and per-case checks. Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/src/Surfaces/Abilities.php#L131 --- # wp-self-test: commands Read from wp-self-test at v2.2.5. ## wp mmwp self-test Run a plugin's self-test cases, or another self-test action. ``` wp mmwp self-test [--format=] # run + validate one plugin's cases; exits 1 on failure. wp mmwp self-test # run every registered case. wp mmwp self-test list [] # cases from every registered manifest. wp mmwp self-test preview [] # the steps each case would run. wp mmwp self-test run [] [--cleanup] # same as the bare form. wp mmwp self-test cleanup [--cases=] # remove test data left by earlier runs. wp mmwp self-test report [--format=] # the last stored report. ``` | Argument | Description | | --- | --- | | [] | Plugin slug whose cases to run, or one of: list, preview, run, cleanup, report. Default: run every case. | | [] | Plugin slug for the list, preview and run actions. | | [--cases=] | Comma-separated case ids. Default: all. | | [--plugin=] | Only the cases registered by this plugin. | | [--layer=] | Only the cases declaring this layer (rest, cli, ability, core, browser). `browser` runs only the Playwright checks. | | [--cleanup] | Remove the test data right after each case (what pre-push and CI use). | | [--format=] | Output format (table, json, csv for list). Default table. | ``` wp mmwp self-test my-plugin --format=table wp mmwp self-test run --cleanup wp mmwp self-test list my-plugin ``` Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/src/Surfaces/Cli.php#L54 --- # wp-self-test: use cases ## Running it | From | Command | |---|---| | wp-admin | Tools › Self-test (`?page=mmwp-self-test`), non-production only | | WP-CLI | `wp mmwp self-test --format=table` runs one plugin's cases; without `` it runs every case. Actions: `list`, `preview`, `run [--cleanup]`, `cleanup`, `report`. Exits 1 when a case fails. | | REST | `GET mmwp/v1/self-test/cases`, `GET .../preview`, `POST .../run`, `POST .../cleanup`, `GET .../report` (cookie + `X-WP-Nonce`, `manage_options`) | | Abilities | `mmwp/self-test-list-cases`, `-preview`, `-run`, `-cleanup`, `-get-report`; run and cleanup need `confirm: true` | | Pre-push | `bin/pre-push` via `bin/install-hooks`, or `php bin/run.php` (see below) | `` is the plugin slug passed to `Registry::register()`. A plugin whose slug equals an action name (`list`, `run`, ...) has to use `--plugin=`. Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/README.md ## Register a manifest ```php add_action('mmwp_self_test_register', function (\MammothWP\SelfTest\Registry $registry) { $registry->register('my-plugin', __DIR__ . '/self-test/manifest.json', [ 'label' => 'My Plugin', 'requirements' => ['abilities', 'logger'], // optional: valid `requires` keys 'cli_loaders' => [['My_Plugin_Cli_Shim', 'load']], // optional, see "CLI wrappers" ]); }); ``` `self-test/manifest.json`: ```json { "version": 1, "cases": [ { "id": "content-crud", "label": "Custom content CRUD", "category": "content", "layers": ["rest", "cli", "ability"], "class": "My\\Plugin\\SelfTest\\Content_Crud_Case", "requires": [], "args": {}, "covers": { "rest": ["my-plugin/v1/items", "my-plugin/v1/items/{id}"], "cli": ["my-plugin items list"], "ability": ["my-plugin/list-items"] } } ] } ``` Case ids must be unique across every registered plugin. `covers` is documentation the package carries along (your own tests can assert every route, command and ability you register is claimed by a case). Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/README.md ## Write a case ```php use MammothWP\SelfTest\Case_Base; class Content_Crud_Case extends Case_Base { public function preview() { return ['Create an item over REST', 'Read it back over the CLI', '…']; } public function run() { $o = []; $o['create'] = $this->rest('POST', '/my-plugin/v1/items', ['title' => 'Self-test']); $o['cli'] = $this->cli(['My_Cli', 'get'], [$o['create']['data']['id']], []); $o['get'] = $this->ability('my-plugin/get-item', ['id' => $o['create']['data']['id']]); return ['observed' => $o, 'id' => $o['create']['data']['id']]; } public function validate(array $context) { $o = $context['observed']; return [ $this->check('rest', 'POST /items returns 201', $this->status($o, 'create') === 201, $this->detail($o, 'create')), $this->cli_check($o, 'cli', 'wp my-plugin items get prints the item'), $this->ability_check($o, 'get', 'my-plugin/get-item returns the item', fn($data) => !empty($data['id'])), ]; } public function cleanup(array $context) { delete_item((int) $context['id']); return ['Deleted the test item.']; } } ``` Four phases, each callable on its own: `preview()` (no side effects), `run()` (returns a JSON-serialisable context, may create data), `validate($context)` (turns observations into `check()` / `skip()` rows), `cleanup($context)` (removes what `run()` created; the runner stores the context so cleanup can happen in a later request). Helpers: `rest()`, `cli()`, `ability()`, `status()`, `detail()`, `cli_check()`, `cli_check_printed()`, `ability_check()`, `availability()`. Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/README.md ## CLI wrappers Cases call CLI command handlers in-process. In a real `wp` process that is WP-CLI itself; in PHPUnit, the dashboard and `bin/run.php` it is the shim in `src/Wp_Cli_Shim.php`, a minimal `WP_CLI` that records output and throws instead of exiting. Command classes that `return` early when `WP_CLI` is missing are declared again by the `cli_loaders` callables a plugin passes to `register()`; the runner calls them once before a run. Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/README.md ## Browser layer The `rest`, `cli`, `ability` and `core` layers never load wp-admin in a browser, so JavaScript errors, screens that fail to render and broken form flows go unnoticed. The `browser` layer runs [Playwright](https://playwright.dev) against the real site through the same manifest, runner and pre-push hook. It never runs on production and nothing of it loads on front-end requests. ### Add it to a plugin 1. Add the Node package straight from this git repo (no npm registry), plus Playwright, next to the plugin's `manifest.json` (the folder with the `node_modules` the runner finds is searched from the manifest upwards): ```json { "devDependencies": { "@mammothwp/wp-self-test-e2e": "git+https://github.com/mammothwp/wp-self-test.git#main", "@playwright/test": "^1.50.0" } } ``` Then `npm install` and `npx playwright install chromium`. The `package.json` sits at the repo root with a `files` whitelist (only `e2e/`), and every Node file is `export-ignore`d, so the Composer copy in `lib/` never contains it. 2. Declare browser checks on a case in `manifest.json`: ```json { "id": "admin-smoke", "label": "Admin smoke", "class": "MammothWP\\SelfTest\\Browser_Case", "category": "admin", "browser": { "crawl": true, "specs": ["self-test/e2e/coupon-flow.spec.js"], "allow": { "console": ["Failed to load resource.*gravatar"], "requests": ["/wp-json/some/v1/known-404"], "pages": ["^admin\\.php\\?page=legacy-screen$"] }, "timeout": 300 } } ``` | Key | Meaning | |---|---| | `crawl` | `true` opens the front page once and every page of the admin menu inventory (GET only) and requires HTTP 200, no `Fatal error`/`Warning`/`Notice`/`Deprecated`/`WordPress database error`/"There has been a critical error" markup, no console errors, no uncaught page errors (`pageerror`), no `>= 500` response on any request and no `>= 400` wp-json or admin-ajax response. | | `specs` | Playwright spec files, relative to the folder with `node_modules` (no `..`). | | `allow` | Per-plugin allow-lists of regular-expression sources: `console` (messages), `requests` (`"STATUS URL"`), `pages` (inventory paths to skip). | | `timeout` | Seconds the Node runner may take (default 300). | A case with `browser` automatically lists the `browser` layer (add `"layers": ["rest"]` as before for the other layers). Use `Browser_Case` for browser-only cases, or your own `Case_Base` subclass to combine PHP phases with browser checks. Put `crawl` in one case only; every case with `browser` starts its own Node run. 3. Write a flow test, e.g. [`docs/examples/coupon-flow.spec.js`](docs/examples/coupon-flow.spec.js). The browser is already logged in: ```js const { test, expect, openAdminPage, fillForm, submitForm, expectNotice, expectNoBrowserErrors } = require('@mammothwp/wp-self-test-e2e'); test('create a coupon', async ({ page, collector }) => { await openAdminPage(page, 'admin.php?page=ewp-coupons&action=new'); await fillForm(page, { code: 'E2E10', amount: '10' }); await submitForm(page, { button: 'Save' }); await expectNotice(page, { type: 'success' }); expectNoBrowserErrors(collector); }); ``` Helpers: `openAdminPage(page, path)` (200 and no PHP error markup), `fillForm(page, {name|id|label: value})`, `submitForm(page, {button})`, `expectNotice(page, {type, text})`, the `collector` fixture (console errors, page errors, wp-json/admin-ajax responses) and `expectNoBrowserErrors(collector, allow)`. Flow tests that create data must delete it themselves. ### Run it locally | What | Command | |---|---| | Browser layer only | `wp mmwp self-test run --layer=browser` (DDEV: `ddev wp mmwp self-test run --layer=browser`) | | One plugin | `wp mmwp self-test run --layer=browser --plugin=` | | Everything incl. browser | `wp mmwp self-test run` | | Without WP-CLI | `php bin/run.php --layer=browser --plugin=` | The runner shells out to Node only when Node and `@mammothwp/wp-self-test-e2e` are found (in DDEV, give the web container Node via `nodejs_version` and run `ddev exec npx playwright install --with-deps chromium`); otherwise the browser checks are reported as **skipped** with the reason, never as failures. `MMWP_E2E_WORKERS` (default 4) sets the parallel pages; `MMWP_E2E_STRICT_TLS=1` turns on TLS verification (off by default because DDEV's local CA is often not trusted by Chromium). Pages are crawled in parallel (4 workers by default) to keep a crawl of ~100 pages under about 2 minutes; raise `MMWP_E2E_WORKERS` if yours is slower. `bin/pre-push` runs the browser layer after the tests when `package.json` depends on the e2e package: through DDEV, or through `MMWP_SELF_TEST_BROWSER_CMD` (any command, e.g. `wp --path=/srv/site mmwp self-test run --layer=browser`). A stopped DDEV or missing Node prints a warning and skips; a failing browser check blocks the push; `MMWP_SELF_TEST_BROWSER=0` turns the step off. ### Standalone CLI (CI without the PHP runner) For the shared plugin CI (`test:browser` against a throwaway WordPress with the plugin activated) the crawler runs on its own, configured by flags or environment variables: ```bash npm install --no-save "@mammothwp/wp-self-test-e2e@git+https://github.com/mammothwp/wp-self-test.git#main" @playwright/test npx playwright install chromium MMWP_E2E_ALLOW_PASSWORD_LOGIN=1 \ npx mmwp-e2e crawl --url="$WP_SITE_URL" --user=admin --password="$WP_ADMIN_PASSWORD" \ --only-plugin="$PLUGIN_SLUG" --json=mmwp-e2e-results.json --junit=junit-browser.xml ``` | Flag | Environment | Meaning | |---|---|---| | `--url` | `MMWP_E2E_SITE_URL` | Site URL. | | `--token` | `MMWP_E2E_LOGIN_TOKEN` | Single-use login token (instead of a password). | | `--user`, `--password` | `MMWP_E2E_USER`, `MMWP_E2E_PASSWORD` | Admin credentials through `wp-login.php`. **Refused unless `MMWP_E2E_ALLOW_PASSWORD_LOGIN=1` is set**, which only a CI job on a throwaway site should do; never use it against a real site. | | `--only-plugin` | `MMWP_E2E_ONLY_PLUGIN` | Crawl only admin pages whose URL or parent menu slug contains the slug (`-`/`_` alike). The front page is always crawled. | | `--allow-console`, `--allow-requests`, `--allow-pages` | `MMWP_E2E_ALLOW_CONSOLE`, `_REQUESTS`, `_PAGES` (comma-separated) | Allow-lists of regular-expression sources; flags are repeatable. `--allow-console` is matched against the full reported string (` @ `, the URL being present for resource-load failures) and the message alone; a 4xx/5xx response for a non-document resource whose URL matches an `--allow-console` pattern is ignored too. | | `--user-agent` | `MMWP_E2E_USER_AGENT` | Browser user agent. Default: a desktop Chrome UA built from the bundled Chromium version (not Playwright's "HeadlessChrome", which plugins' bot/prefetch guards ignore). No `Sec-Purpose` header is sent. The UA is printed in the run header. | | `--spec` | | Also run a flow spec (relative to the working directory); repeatable. | | `--rest-url` | | Base of the `mmwp/v1/self-test/` REST routes. Default `/?rest_route=/mmwp/v1/self-test/`, which works with any permalink setting. A missing route falls back to scraping the admin menu. | | `--json`, `--junit` | `MMWP_E2E_RESULTS`, `MMWP_E2E_JUNIT` | Results JSON (default `mmwp-e2e-results.json`) and JUnit XML (omitted when unset). | | `--workers` | `MMWP_E2E_WORKERS` | Parallel pages, default 4. | The exit code is 0 when every check passed and 1 when one failed or the run broke. The page list comes from `admin-menu` when the site has this package active, otherwise it is scraped from the admin menu HTML with the same exclusions. `npx mmwp-e2e --help` prints the options. ### Listed pages mode (`mmwp-e2e pages`) `crawl` opens every admin menu page. When a plugin wants an explicit list of admin **and** frontend pages instead (so failures are about the plugin, not about WooCommerce's heavy screens), describe them in `browser-pages.json` and run: ```bash MMWP_E2E_ALLOW_PASSWORD_LOGIN=1 \ npx mmwp-e2e pages --url="$WP_SITE_URL" --file=browser-pages.json --vars=vars.json \ --user=admin --password="$WP_ADMIN_PASSWORD" --json=mmwp-e2e-results.json --junit=junit-browser.xml ``` ```json { "version": 1, "pages": [ { "area": "admin", "name": "Settings", "path": "/wp-admin/admin.php?page=my-plugin", "as": "admin", "expect": "#my-plugin-form" }, { "area": "admin", "name": "Product", "path": "/wp-admin/post.php?post=${PRODUCT_ID}&action=edit", "as": "admin", "expect_url": "action=edit" }, { "area": "frontend", "name": "Shop", "path": "/shop/", "as": "guest", "expect": ".products" }, { "area": "frontend", "name": "Old URL", "path": "/old/", "as": "guest", "status": 200, "expect_url": "/new/" } ], "setup": [], "about": "ignored by the crawler" } ``` | Key | Meaning | |---|---| | `path` (required) | Starts with `/`. `${VAR}` is replaced from `--vars`; an unknown variable fails that page with a clear message. | | `as` (required) | `admin` reuses the logged-in session, `guest` has no cookies. Every page gets a fresh browser context, so carts and sessions never leak between pages. | | `area` | `admin` or `frontend` (default: `admin` for paths under `/wp-admin`). Reporting only. | | `name` | Label in the report (default: the path). | | `expect` | CSS selector that must exist on the page. | | `expect_url` | Substring the final URL (after redirects) must contain. | | `expect_text` | Plain substring (after `${VAR}` substitution) that must appear in the page's visible text (`document.body.innerText`); the failure message shows the expected text. | | `status` | Expected HTTP status of the final document, default 200. A page with e.g. `"status": 410` passes when the document returns 410; that response is not also reported as a failed request, and Chrome's "Failed to load resource ... status of 410" console message for that same document URL is ignored too, while PHP errors, console errors and other responses >= 500 still fail the page. | | `note`, `setup`, `about` | Ignored by the crawler (`setup` is run by ci-templates). | Unknown keys print a warning; a missing/invalid `path` or `as` aborts before any browser starts (exit 1). Each page gets the same checks as `crawl` (HTTP status, PHP Fatal/Parse/Warning/Notice/Deprecated markup, critical-error and database-error pages, console and page errors, responses >= 500, REST/admin-ajax >= 400, honouring `--allow-console`, matched as in `crawl`) plus `expect` / `expect_url`. Flags: `--url`, `--file`, `--vars` (JSON object file), `--user`, `--password`, `--workers` (default 4), `--timeout-ms` (default 30000), `--user-agent` (see `crawl`), `--allow-console` and `--allow-requests` (repeatable, same semantics as `crawl`: regex matched against "STATUS URL"), `--json`, `--junit`. The JSON has `checks` (one per page, `/: `) and `rows` (name, area, as, path, final URL, `duration_ms`, result, problems). Exit code 1 when any page fails. ### Admin menu inventory `GET mmwp/v1/self-test/admin-menu` (cookie + `X-WP-Nonce`, same capability and `mmwp_self_test_ui_enabled` gate as the dashboard routes) returns `{admin_url, pages: [{title, url, path, parent}], excluded, source}` for the current user. Many plugins register their pages only when `is_admin()` is true, so the menu is snapshotted on `admin_menu` in real admin requests (`source: snapshot`) and the crawler loads one admin page first; without a snapshot the route falls back to the live globals. URLs containing `action=`, `nonce`, `customize.php`, `update.php`, `update-core.php`, `plugin-install.php`, `theme-install.php`, `post-new.php` (it creates an auto-draft) or `logout` are excluded; change the list with the `mmwp_self_test_admin_menu_exclusions` filter. ### Security model - The runner issues a random 256-bit token for the acting user (`Login_Token::issue()`), stored only as a SHA-256 hash in the `mmwp_self_test_login_tokens` option, valid for 120 seconds and redeemable once. - It reaches the Node process through an environment variable (not argv, so not in `ps`), and Node exchanges it with `POST mmwp/v1/self-test/browser-login` for the normal auth cookies. Spent, expired, unknown or wrong tokens get a 403. - After the run, `Login_Token::revoke_all()` destroys every session the exchange created and deletes all records, also when the run fails. - Never issued or redeemed when the environment is `production` (`WP_ENV`, else `wp_get_environment_type()`), whatever `mmwp_self_test_enabled` / `mmwp_self_test_ui_enabled` return; the routes are also only registered when the dashboard gate is on, and the user must still hold the self-test capability. - Tokens exist only while a browser run is in progress; there is no standing back door and no password handling. Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/README.md ## The pre-push hook in a consumer ```json { "scripts": { "test": "bash tests/run-tests.sh", "post-install-cmd": ["@php vendor/mammothwp/wp-self-test/bin/install-hooks"] } } ``` `install-hooks` writes `.githooks/pre-push` (a two-line shim that execs the package's `bin/pre-push`), makes it executable and sets `core.hooksPath`. On `git push` the hook skips tag-only and delete pushes, `php -l`s every tracked PHP file, then runs `composer run-script test` (or `tests/pre-push.sh`). A push that passes the hook is the gate; the package ships no CI template (`git push --no-verify` bypasses the hook, so treat that as a deliberate decision). `bin/run.php` boots WordPress through your PHPUnit bootstrap (`--bootstrap=tests/bootstrap.php`, `--autoload=tests/vendor/autoload.php`) and runs every manifest with cleanup, exiting 1 on failure. Plugins without a bootstrap can point `phpunit.xml` and `bin/run.php` at `phpunit/bootstrap.php` with `MMWP_PLUGIN_FILES=/path/to/plugin.php`. Source: https://github.com/mammothwp/wp-self-test/blob/v2.2.5/README.md --- # Articles - [How we record install videos with Playwright](https://mammothwp.com/articles/how-we-record-install-videos-with-playwright/): One script gives us the install video, WebVTT captions, a transcript and the wordpress.org screenshots. It runs on every merge request and re-records only when something changed. --- # How we record install videos with Playwright 26 October 2026 Docs videos go stale the day after you record them. A button moves, a label changes, and the video shows a screen that no longer exists. We stopped recording by hand. ## One script, four outputs Each plugin has two specs: `docs/video/install.spec.ts` and `docs/video/usage.spec.ts`. Playwright drives a real wp-admin. From a single run we get: - the video of the session, - WebVTT captions, - a transcript, - the screenshots we upload to wordpress.org. The captions and the transcript come from the same step list that drives the browser, so they cannot disagree with what you see. ## Rules for the specs Every spec stays under 90 seconds and runs at 1280x720, with a visible cursor so viewers can follow it. A video that needs more than 90 seconds is a sign the screen asks too much of the user. ## An E2E test on every merge request The specs also run headless on every merge request, as an end-to-end test. If a change breaks the install flow or the usage flow, the pipeline fails before anyone ships it. ## Re-record only when it matters Recording is not free, and a new video that looks identical is noise. We hash the specs together with the admin UI files. The videos are re-recorded only when that hash changes. ## What you get The video stays current without anyone remembering to update it, and it ships with captions and a transcript. --- # Guides - [Declare a WordPress feature once: REST route, WP-CLI command and Ability with wp-kit](https://mammothwp.com/guides/declare-once-rest-wp-cli-ability-wp-kit/): Use mammothwp/wp-kit to declare a plugin operation once and register it as a REST route, a WP-CLI command and a WordPress Ability from one definition. - [Run your plugin's checks on a real WordPress site with wp-self-test](https://mammothwp.com/guides/wordpress-plugin-self-test-wp-self-test/): Use mammothwp/wp-self-test to ship a manifest of checks with your plugin and run them on a real site from wp-admin, WP-CLI, REST or the Abilities API. - [Add a support screen to your WordPress plugin with mammothwp/support](https://mammothwp.com/guides/wordpress-plugin-support-screen-mammothwp-support/): Use mammothwp/support to give your plugin a shared wp-admin support screen that sends tickets with wp_mail, with an opt-in site report the user can preview. - [Build, check and ship a WordPress plugin release with wp-release](https://mammothwp.com/guides/build-check-deploy-wordpress-plugin-wp-release/): Use mammothwp/wp-release to build a plugin zip, run Plugin Check on it in a throwaway site and deploy it to wordpress.org SVN, the same way locally and in CI. - [Video: add a Support screen to a WordPress plugin in about a minute](https://mammothwp.com/guides/video-support-screen-walkthrough/): A screen recording of mammothwp/support: composer require, one register_plugin() call, Tools > Support, the opt-in site report, and a disabled Send button when no email is set. --- # Declare a WordPress feature once: REST route, WP-CLI command and Ability with wp-kit A plugin feature often needs more than one entry point. The admin screen calls a REST route, deploy scripts call a WP-CLI command, and with the Abilities API the same feature can also be registered as an Ability. Written by hand, each copy gets its own parameter list, permission check and validation, and over time they stop matching. [wp-kit](https://github.com/mammothwp/wp-kit) takes a different approach. You declare the operation once, with its fields, capability and output, and three adapters register it on REST, WP-CLI and the Abilities API from that single definition. ## Install ```bash composer require mammothwp/wp-kit ``` wp-kit is a runtime dependency, because the adapters register routes, commands and abilities inside WordPress, so ship `vendor/` with your plugin. It requires PHP 7.4 or newer. Composer's `autoload.files` loads `bootstrap.php`. Several active plugins can each include their own copy, and the newest copy boots on `plugins_loaded`. ## A minimal example The service is plain PHP. wp-kit calls its methods and passes declared fields to parameters with the same name. ```php final class Things_Service { private static $instance; public static function instance() { return self::$instance ?: (self::$instance = new self()); } public function list_items(int $limit = 50) { $posts = get_posts(['numberposts' => $limit]); return ['items' => array_map(function ($post) { return ['id' => $post->ID, 'title' => $post->post_title, 'status' => $post->post_status]; }, $posts)]; } } ``` The resource is the one definition. Put it in its own file, `includes/Things_Resource.php`, and load it with a Composer classmap in your plugin's `composer.json`, then run `composer dump-autoload`: ```json "autoload": { "classmap": ["includes/"] } ``` The kit's classes exist only after `plugins_loaded`, so a class that extends `Resource` cannot be declared while your main plugin file loads. The classmap loads it on first use, inside `Kit::on_ready()`. ```php use MammothWP\WpKit\{Field, Operation, Resource}; final class Things_Resource extends Resource { public function name() { return 'things'; } public function service() { return Things_Service::instance(); } public function rest_namespace() { return 'my-plugin/v1'; } public function cli_base() { return 'my-plugin things'; } public function ability_category() { return 'my-plugin-things'; } public function ability_category_args() { return ['label' => 'Things', 'description' => 'Abilities for things.']; } public function operations() { return [ 'list' => Operation::read('list_items') ->label('List things') ->description('List things.') ->input([ Field::int('limit')->default_value(50)->max(200)->describe('Rows to return.'), ]) ->rest('GET') ->cli('list', ['columns' => ['id', 'title', 'status']]) ->ability('list-things'), ]; } } ``` Register the adapters inside `Kit::on_ready()`: ```php \MammothWP\WpKit\Kit::on_ready(function () { $registry = (new \MammothWP\WpKit\Registry())->add(new Things_Resource())->ready(); foreach ($registry->all() as $resource) { (new \MammothWP\WpKit\Adapters\Rest_Adapter($resource))->register(); (new \MammothWP\WpKit\Adapters\Cli_Adapter($resource))->register(); (new \MammothWP\WpKit\Adapters\Ability_Adapter($resource))->register(); } }); ``` WordPress requires a description on every ability and on its category, which is why the example sets both. From that one declaration, the `limit` field becomes: - a REST argument of type integer with `default` 50 and `maximum` 200, - a `[--limit=]` flag in the WP-CLI synopsis and in `wp help`, - the same constraints in the Ability input schema. The Ability adapter only registers when the Abilities API is available, and the CLI adapter only when WP-CLI is loaded. The capability defaults to `manage_options`. Override `capability()` on the resource, or call `->capability()` on one operation. ## One execution path Every surface calls `Operation::run()`, which: 1. normalises the input against the fields and applies defaults, 2. validates it (`mmwp_invalid_param`, 400), 3. checks the capability (`mmwp_forbidden`, 403), 4. asks for confirmation on destructive calls where required, 5. calls your service method. Operations come in three kinds: `Operation::read()`, `Operation::write()` and `Operation::destructive()`. By default, a destructive operation needs `confirm: true` when it is called as an Ability. Every ability the kit registers is marked `meta.mcp.public = true`, so an MCP server built on `wordpress/mcp-adapter` can list it as a tool, and the call still goes through the same checks. To keep a resource off MCP, return `false` from `mcp()` on the resource. If an operation should not exist on one surface, say so with `Operation::surfaces()` or by overriding `Resource::surfaces()`, and give a reason. `Inventory::export()` carries that reason, so a test can tell a deliberate omission from a missing registration. ## In a real plugin [MammothWP Acceptance Logs](https://wordpress.org/plugins/mammoth-acceptance-logs/) on wordpress.org ships wp-kit in its `vendor/` directory. ## When to use it - Your plugin exposes the same feature over REST and WP-CLI, or you plan to add Abilities. - You want parameters, permission checks and validation defined in one place. - You want a test to check that every operation is registered on each surface it should be. ## When not to - The feature only needs a single REST route or a single command, and you have no plans for other surfaces. - Your route or command needs request handling that does not fit an operation with declared fields. - You cannot ship Composer dependencies inside your plugin. ## Links - GitHub: https://github.com/mammothwp/wp-kit - Packagist: https://packagist.org/packages/mammothwp/wp-kit - License: GPLv2 or later --- # Run your plugin's checks on a real WordPress site with wp-self-test Unit tests tell you that a function returns the right value. They don't tell you whether your REST route is registered on this particular site, whether the WP-CLI command still parses its flags after a deploy, or whether your Ability still answers. [wp-self-test](https://github.com/mammothwp/wp-self-test) runs checks like these inside a real WordPress install. A plugin ships a `self-test/manifest.json` and a few case classes. The same cases then run from wp-admin, WP-CLI, REST, the Abilities API or a git pre-push hook. ## Install ```bash composer require mammothwp/wp-self-test ``` It is a runtime dependency, because the dashboard, the command and the abilities run inside WordPress. It requires PHP 7.4 or newer. When the environment is `production`, the package boots nothing: no commands, abilities, dashboard or manifests, because a run creates data on the site. The environment comes from `WP_ENV` if it is defined, otherwise from `wp_get_environment_type()`. The `mmwp_self_test_enabled` filter overrides this per request. Every surface requires `manage_options`, which you can change with the `mmwp_self_test_capability` filter. ## Register a manifest In your plugin's main file: ```php add_action('mmwp_self_test_register', function (\MammothWP\SelfTest\Registry $registry) { require __DIR__ . '/self-test/Things_Read_Case.php'; $registry->register('my-plugin', __DIR__ . '/self-test/manifest.json', [ 'label' => 'My Plugin', ]); }); ``` The hook runs at `init`, after the package's classes are loaded, so require the case files inside it. A `require` at the top of the main file would run too early. `self-test/manifest.json`: ```json { "version": 1, "cases": [ { "id": "my-plugin-things-read", "label": "Things: list over REST and the Ability", "category": "content", "layers": ["rest", "ability"], "class": "Things_Read_Case", "requires": [], "args": {}, "covers": { "rest": ["my-plugin/v1/things"], "ability": ["my-plugin-things/list-things"] } } ] } ``` Case ids must be unique across every registered plugin. `covers` is documentation the package carries along. Your own tests can use it to check that every route, command and ability you register is claimed by a case. ## Write a case Every case has four phases, and each can be called on its own: - `preview()` describes what the case will do, with no side effects. - `run()` performs the calls and returns a JSON-serialisable context. It may create data. - `validate($context)` turns what was observed into `check()` or `skip()` rows. - `cleanup($context)` removes what `run()` created. The runner stores the context, so cleanup can happen in a later request. This case only reads, so it keeps the default `cleanup()`, which removes nothing: ```php use MammothWP\SelfTest\Case_Base; class Things_Read_Case extends Case_Base { public function preview() { return [ 'List things over REST (expects 200)', 'List things through the list-things ability', ]; } public function run() { $o = []; $o['rest'] = $this->rest('GET', '/my-plugin/v1/things', ['limit' => 5]); $o['list'] = $this->ability('my-plugin-things/list-things', ['limit' => 5]); return ['observed' => $o]; } public function validate(array $context) { $o = $context['observed']; return [ $this->check('rest', 'GET /things returns 200', $this->status($o, 'rest') === 200, $this->detail($o, 'rest')), $this->ability_check($o, 'list', 'list-things returns an items list', function ($data) { return isset($data['items']) && is_array($data['items']); }), ]; } } ``` `rest()` dispatches the request in the same process through `rest_get_server()` and returns the status and data. `ability()` calls `wp_get_ability($name)->execute($input)` and returns `found`, `ok`, `data` and `error`. When the Abilities API is not available on the site, `ability_check()` reports a skip instead of a failure. The route and ability in this example are the ones from the wp-kit guide, but the case works the same for any plugin. ## Run it ```bash wp mmwp self-test my-plugin --format=table ``` That runs one plugin's cases. Without a slug, it runs every registered case. The actions are `list`, `preview`, `run [--cleanup]`, `cleanup` and `report`. The command exits with code 1 when a case fails, so a script can stop on it. The same cases are available from: - **wp-admin:** Tools > Self-test, on non-production sites only. - **REST:** `mmwp/v1/self-test/cases`, `preview`, `run`, `cleanup` and `report`, using cookie authentication with `X-WP-Nonce`. - **Abilities:** `mmwp/self-test-list-cases`, `-preview`, `-run`, `-cleanup` and `-get-report`. Run and cleanup need `confirm: true`. ## Before every push Add this to your plugin's `composer.json`: ```json "scripts": { "test": "bash tests/run-tests.sh", "post-install-cmd": ["@php vendor/mammothwp/wp-self-test/bin/install-hooks"] } ``` `install-hooks` writes `.githooks/pre-push` and sets `core.hooksPath`. On `git push`, the hook runs `php -l` on every tracked PHP file and then `composer run-script test`. ## When to use it - Your plugin registers REST routes, WP-CLI commands or Abilities, and you want to confirm they work on a real install. - You want one set of checks that can run from the admin screen, the command line or a hook. - You need test runs that clean up the data they create. ## When not to - You only need unit tests for pure PHP logic. - The site you want to check is production and you are not prepared to create test data there. The package stays off on production unless you turn it on. ## Links - GitHub: https://github.com/mammothwp/wp-self-test - Packagist: https://packagist.org/packages/mammothwp/wp-self-test - License: GPLv2 or later --- # Add a support screen to your WordPress plugin with mammothwp/support When a user needs help with a plugin, the first reply is often a list of questions: which WordPress version, which PHP version, which other plugins are active. Each plugin that builds its own contact form also adds another screen to wp-admin. [mammothwp/support](https://github.com/mammothwp/support) gives every plugin that bundles it one shared **Support** screen. The user fills in a form in wp-admin, and the site sends one email with `wp_mail()`. A site report is added to that email only if the user ticks a box. The box is unticked by default, and **Preview what will be sent** shows the exact text before anything goes out.

Screen recording of composer require, register_plugin(), and the Support form. No voiceover; captions are burned in.

## Install ```bash composer require mammothwp/support ``` The package requires PHP 8.1 or newer and pulls in `mammothwp/wp-kit` `^2.0` as a dependency. No `@dev` flag or `minimum-stability` change is needed. ## Register your plugin In your main plugin file, after the Composer autoloader is loaded: ```php \MammothWP\Support\Support::register_plugin( __FILE__, [ 'email' => 'support@example.com', // where tickets for this plugin go ] ); ``` That is the whole integration. The name, version, vendor and slug come from the plugin header and folder name. `email` is required. Without a valid address the plugin is still listed, but its Send button is disabled and `_doing_it_wrong()` is raised. There is no fallback address. If you prefer not to put the address in PHP, add `mammothwp-support.json` to the plugin root and call `register_plugin( __FILE__ )`: ```json { "email": "support@example.com", "docs_url": "https://example.com/docs" } ``` Keys in the PHP array override the JSON file. [MammothWP Acceptance Logs](https://wordpress.org/plugins/mammoth-acceptance-logs/) on wordpress.org uses this approach: it ships a `mammothwp-support.json` file and calls `register_plugin()` with only the plugin file. ## Where the screen appears There is one screen, and plugins can add shortcuts to it but cannot move it. By default it sits under Tools > Support. When a registered plugin has the vendor "Mammoth WP", it gets its own top-level menu instead. A plugin can add a shortcut under its own menu: ```php \MammothWP\Support\Support::register_plugin( __FILE__, [ 'email' => 'support@example.com', 'menu' => [ 'parent' => 'my-plugin-settings', 'title' => 'Support' ], ] ); ``` The shortcut links to the shared screen with your plugin preselected. Every registered plugin also gets a "Support" action link on the Plugins screen. Site owners can change the location with the `MMWP_SUPPORT_MENU` constant or the `mmwp_support_menu` filter, and the recipient with `MMWP_SUPPORT_EMAIL_` or the `mmwp_support_email` filter. Agencies can rebrand the screen with `MMWP_SUPPORT_WHITELABEL` or the `mmwp_support_branding` filter. ## Check it from WP-CLI The package registers its operations through wp-kit, so the same actions are available over REST (`mmwp/v1`), WP-CLI and the Abilities API. To see which plugins are registered and where their tickets go: ```bash wp mmwp support plugins ``` To see the exact email a ticket would produce without sending it: ```bash wp mmwp support send --plugin=my-plugin --message="Test ticket" --reply-to=you@example.com --dry-run ``` A dry run prints the recipient, subject, headers, body and attachment name, and sends nothing. `--include-report` and `--include-log` are off unless you pass them. ## Add your own report section If your plugin has settings that help with support, add a section to the site report: ```php add_filter( 'mmwp_support_report_sections', function ( array $sections ): array { $sections[] = new My_Plugin_Report_Section(); // implements \MammothWP\Support\Report\Section_Interface return $sections; } ); ``` `Section_Interface` has three methods: `key()`, `label()` and `collect()`. The contract is that a section returns no secrets: no keys, tokens, passwords or customer data. ## When several plugins bundle it Each bundled copy registers its version in a shared registry, and on `plugins_loaded` only the highest version boots. The user sees one Support screen, whichever plugins they have installed. ## When to use it - You want a support form inside wp-admin that emails your support address. - You want users to decide whether a site report is attached, and to see it first. - You ship several plugins and want them to share one support screen. ## When not to - Your support runs through a help desk API rather than email. The only built-in transport is `wp_mail()`. - Your plugin must run on PHP versions older than 8.1. - You cannot ship Composer dependencies inside your plugin. ## Links - GitHub: https://github.com/mammothwp/support - Packagist: https://packagist.org/packages/mammothwp/support - License: GPLv2 or later --- # Build, check and ship a WordPress plugin release with wp-release Releasing a plugin usually means a list of manual steps: install production dependencies, copy the right files, zip them, run Plugin Check, then copy everything into the wordpress.org SVN trunk and tag it. Miss one step and the zip contains a test folder, or the readme's Stable tag no longer matches the plugin header. [wp-release](https://github.com/mammothwp/wp-release) puts those steps behind one PHP command, `mmwp-release`, which behaves the same on your machine and in CI. ## Install ```bash composer require --dev mammothwp/wp-release vendor/bin/mmwp-release --help ``` It is a development dependency and requires PHP 8.1 or newer, plus these tools on `PATH`: `composer`, `rsync`, `zip` and `unzip` for `build`; `wp` (WP-CLI) and `curl` for `check`; and `svn` for `deploy-wporg`. With the default SQLite database, `check` also needs the `pdo_sqlite` PHP extension. ## Prepare the plugin wp-release expects the main file to be `.php` in the plugin root. The slug is detected from the single root PHP file with a `Plugin Name:` header, or set with `--slug`, `MMWP_SLUG` or `mmwp-release.json`. A `.distignore` file (rsync exclude syntax) is required in the plugin root: ``` /.gitignore /tests /docs /node_modules ``` List every tracked hidden file, such as `.gitignore`, because the build rejects hidden files in the zip. Anchor entries with a leading `/`. An unanchored entry such as `tests` also strips those folders inside `vendor/` packages, and `build` warns about it. An optional `mmwp-release.json` sets defaults: ```json { "slug": "my-plugin", "php_min": "7.4", "wp_version": "6.8", "strict": false, "allow": [], "excludes": ["*.psd", "docs/*"], "github_repo": "my-plugin" } ``` ## Build ```bash vendor/bin/mmwp-release build ``` `build` runs a fresh `composer install --no-dev --optimize-autoloader` in a temporary copy, so your working `vendor/` is never touched. In a git work tree it ships only tracked files, applies `.distignore`, writes `dist//` and `.zip`, and prints the zip size and file count. It also validates the result. The plugin header needs `Text Domain` equal to the slug, plus `Requires PHP`, `Requires at least`, a GPL `License` and `Version`. `readme.txt` needs `Stable tag` and `Tested up to`, and `Version` must equal `Stable tag`. The zip may not contain files such as `.git`, `node_modules`, `tests`, `.env*` or `*.log`, any hidden file or `*.phar`. If your plugin requires runtime packages, the zip must contain their `vendor/` directories. ## Check ```bash vendor/bin/mmwp-release check ``` `check` creates a throwaway WordPress site in a temporary directory, installs Plugin Check, unzips the built zip, activates the plugin and runs `wp plugin check`. It writes `plugin-check.json` and `plugin-check.txt`, then removes the site. It fails on any Plugin Check error, and on warnings too with `--strict`. Other options include `--self-test` (also runs `wp mmwp self-test `), `--keep` (keeps the temporary site), `--deps=` for plugins that need other plugins active first, and `--db=mysql`. With `--strict`, you can let specific, known warnings through with `--allow=`. Errors can never be allowed. ## Status and deploy ```bash vendor/bin/mmwp-release status vendor/bin/mmwp-release deploy-wporg --dry-run ``` `status` prints the slug, the header version, the readme Stable tag, whether that version already exists on wordpress.org and GitHub, and what each deploy command would do. `deploy-wporg` needs a passing check. If `tags/` already exists on `plugins.svn.wordpress.org`, or the plugin has no SVN repository yet, it skips and exits 0. Otherwise it syncs trunk, copies `.wordpress-org/` into assets, runs `svn cp trunk tags/` and commits. With `--dry-run`, it prints the `svn status` diff and stops before the commit. SVN credentials come from `WPORG_USERNAME` and `WPORG_PASSWORD`. `release-github` creates a GitHub release `v` with the matching `readme.txt` changelog section as notes and uploads the zip. The owner defaults to `mammothwp`, so set `MMWP_GITHUB_OWNER` (and `MMWP_GITHUB_REPO` if needed) for your own repositories. It needs `GITHUB_TOKEN`. `all` runs `build`, `check`, `deploy-wporg` and `release-github` in that order and stops at the first failure. ## Release model A version bump is the release. Every deploy command compares the plugin's `Version` header with what is already published and does nothing when that version is already out, so the release job can run on every push to your main branch. Exit codes are specific: for example 11 for a validation failure, 20 for Plugin Check errors and 30 when `deploy-wporg` has no passing check. ## When to use it - You publish a plugin on wordpress.org and want build, Plugin Check and SVN deploy as one repeatable process. - You want the same commands locally and in CI. - Your plugin ships Composer packages in `vendor/` and you want the zip validated before it goes out. ## When not to - Your plugin is not distributed through wordpress.org or GitHub releases. - Your release process uses git tags as the trigger. wp-release uses the version header instead. - Your build environment cannot run PHP 8.1, rsync, WP-CLI or svn. ## Links - GitHub: https://github.com/mammothwp/wp-release - Packagist: https://packagist.org/packages/mammothwp/wp-release - License: GPLv2 or later --- # Video: add a Support screen to a WordPress plugin in about a minute This is a screen recording of the setup in the [mammothwp/support guide](https://mammothwp.com/guides/wordpress-plugin-support-screen-mammothwp-support/). It shows the install, the `register_plugin()` call, and the Support screen in wp-admin. There is no voiceover; captions are burned in. The recording was made on a local test site (WordPress 7.1.4, mammothwp/support v1.1.1).

Screen recording of composer require, register_plugin(), and the Support form. No voiceover; captions are burned in.

## What the video shows The chapters follow the integration in order. **Install the package (0:03).** The recording starts with `composer require mammothwp/support` in the plugin directory. The guide states that the package requires PHP 8.1 or newer and pulls in `mammothwp/wp-kit` `^2.0`. **Register the plugin (0:10).** Next is one `register_plugin()` call in the main plugin file, after the Composer autoloader is loaded, passing the address that should receive tickets for that plugin. Name, version and slug come from the plugin header. **Open Tools > Support (0:20).** In this recording the form lives under Tools > Support, with no new top-level menu. The user fills in the form in wp-admin, and the site sends one email with `wp_mail()`. **The site report (0:33).** The box that includes the site report is unticked by default. **Preview what will be sent** shows the exact report text. The report is attached to the email only if the box is ticked. **No email, Send disabled (0:50).** The recording then removes the email from `register_plugin()`. The plugin is still listed, but sending is disabled. The guide is specific here: without a valid address the Send button is disabled, `_doing_it_wrong()` is raised, and there is no fallback address. **The guide link (1:05).** The walkthrough ends on the written guide, which covers the call, JSON configuration, menu placement, a WP-CLI dry run, custom report sections, and when the package is not the right fit. For the code, the options and the edge cases, use the full guide: [Add a support screen to your WordPress plugin with mammothwp/support](https://mammothwp.com/guides/wordpress-plugin-support-screen-mammothwp-support/) --- # FAQ ## Privacy and data ### Do the plugins track me? No. The plugins do not track you and make no remote calls of their own. Data goes out only when you click send. ### Does this website use analytics? Cloudflare Web Analytics, which is cookieless and collects no personal data. Google Analytics loads only if you click Accept in the cookie banner. See the privacy page. ## Support ### Where do I get help with a plugin? On the plugin's wordpress.org support forum, or by email from the Mammoth WP › Support screen inside wp-admin. ## For developers ### Where is the source? On GitHub, at github.com/mammothwp. ### Can my AI agent read the docs? Yes. Start at /llms.txt. Every page also has a Markdown twin, and product data is at /api/products.json. ### How do I report a security issue? Follow the security policy at /security/ and write to info@mammothwp.com. --- # Support Use the plugin support forum on wordpress.org. You can also open the Mammoth WP › Support screen in wp-admin, or write to info@mammothwp.com. - [Read the FAQ](https://mammothwp.com/faq/) --- # About Mammoth WP makes WordPress plugins, add-ons and developer tools, with real docs and a real support path. --- # Security Write to info@mammothwp.com with the details. Our machine-readable contact is at /.well-known/security.txt. --- # Privacy This page covers the mammothwp.com website. Last updated 1 November 2026. ## Cloudflare Web Analytics We use Cloudflare Web Analytics to count visits. It sets no cookies and collects no personal data. We see aggregate numbers such as page views, referrers, countries and browser types. ## Google Analytics Google Analytics loads only after you click Accept in the cookie banner. Until then your browser makes no request to Google and no Google cookie is set. If you accept, Google Analytics sets the cookies _ga and _ga_ followed by our property ID, and records which pages you view and which links to wordpress.org and GitHub you click. We use it to see which plugins people read about. There are no advertising cookies. Advertising storage and personalisation are denied, and Google signals are switched off. If you click Decline, or ignore the banner, Google Analytics is never loaded. ## Change your choice Use the Cookie settings link in the footer of any page, or the button below, to accept or decline again. Declining after accepting stops Google Analytics and removes its cookies from this site. ## Plugins This page covers the website only. The plugins do not track you and send no telemetry. What each plugin stores and sends is listed under Privacy & data on its page. ## Contact Questions about this page or your data: info@mammothwp.com. --- # Licensing The code is GPL-2.0-or-later.