Skip to content
System nominalUTC --:--:--Articles 080
MODULE 06 Lesson 17 / 18 40 MIN

Building an extension

Settings, Capabilities and Licensing

Where an extension's settings belong, which capability guards them, and what a licence key can honestly enforce inside GPL code.

Three complaints arrive in the same week. A store manager cannot save the extension's settings because the page never appears in WooCommerce's settings tabs. A shop assistant who should only edit courier details can in fact edit every payment gateway on the site. And one store is stuck on version 1.0.0 while the same extension updates happily on its neighbour's site, because their licence server had a bad afternoon and the extension treated that as "you own nothing".

These are one problem wearing three coats: who may change what, and what happens when the answer comes from outside the site. Get the settings registration right and the tab appears. Get the capability right and the assistant cannot reach the gateway screen. Get the failure path right and an unreachable licence server costs a support email, not a store. Everything below was run on WooCommerce 11.2.1 with WordPress 7.0.7 on PHP 8.5.6, and the outputs quoted are from that run.

WC_Settings_Page exists so your settings live where the store owner looks

WooCommerce's own settings screens are all subclasses of WC_Settings_Page, and the extension route into that list is the woocommerce_get_settings_pages filter. The base class constructor attaches four hooks for you: woocommerce_settings_tabs_array to add the tab, woocommerce_sections_$id for sections, woocommerce_settings_$id for the form, and woocommerce_settings_save_$id to save it. You write field definitions instead of HTML, and the store owner finds your screen under WooCommerce > Settings next to Tax and Shipping.

<?php
add_filter(
	'woocommerce_get_settings_pages',
	function ( $pages ) {
		require_once __DIR__ . '/class-sifero-settings-page.php';

		$pages[] = new Sifero_Settings_Page();

		return $pages;
	}
);

Two details are load-bearing. The require_once is there because WC_Settings_Page is not loaded when your main plugin file runs: WC_Admin_Settings::get_settings_pages() includes it immediately before the filter fires, so a class that extends it at file scope fatals on activation. And the filter is cached. On my test store it ran exactly once per request, during rest_api_init, and a second call returned the cached array without firing it again. Register from your main plugin file, or from a late admin_init the way WooCommerce does at priority 999.

The method name selects the section: get_settings_for_default_section() for the default one, get_settings_for_advanced_section() for a section with the id advanced. A section with no matching method returns an empty array. The older get_settings( $section_id ) still works and is deprecated since 5.4.0, which matters because most tutorials still show it.

<?php
class Sifero_Settings_Page extends WC_Settings_Page {

	protected $id = 'sifero';

	public function __construct() {
		$this->label = 'Sifero';
		parent::__construct();
	}

	public function get_sections() {
		return array(
			''         => 'General',
			'advanced' => 'Advanced',
		);
	}

	protected function get_settings_for_default_section() {
		return array(
			array( 'title' => 'Sifero', 'type' => 'title', 'id' => 'sifero_options' ),
			array(
				'title'   => 'Licence key',
				'type'    => 'text',
				'id'      => 'sifero_licence_key',
				'default' => '',
			),
			array(
				'title'   => 'Debounce window',
				'type'    => 'number',
				'id'      => 'sifero_debounce_seconds',
				'default' => '30',
			),
			array( 'type' => 'sectionend', 'id' => 'sifero_options' ),
		);
	}
}

On the test store, that class put sifero into the settings page list and the tabs array, and both sections returned their fields, starting title:sifero_options text:sifero_licence_key number:sifero_debounce_seconds for the default section and title:sifero_advanced number:sifero_poll_interval for the advanced one.

The WordPress Settings API is the right tool for settings that are not WooCommerce's

A setting that has nothing to do with selling, or one that has to be readable through wp/v2/settings, belongs to register_setting(). The two APIs are not rivals, and the deciding question is whose screen the field appears on: under WooCommerce > Settings, use WC_Settings_Page; under Settings > General, use register_setting().

What the WordPress API buys you is a validation path. register_setting() stores a sanitize_callback, and the admin form flow runs sanitize_option() before the value reaches the database. I confirmed that: a setting registered with sanitize_text_field turned <b>hello</b> into hello. The trap is assuming the same of a direct update_option() call. I stored the same string that way and read back <b>hello</b> intact, so any code path outside the settings form has to sanitise for itself.

WooCommerce's own saves work the same way, through WC_Admin_Settings::save_fields(). It converts by field type, then runs the woocommerce_admin_settings_sanitize_option_$option_name filter, so your page hands it an array of field definitions and the values land in the options table. My run posted SIF-1234-ABCD into sifero_licence_key and 45 into sifero_debounce_seconds and read both back. One warning before you trust a field type as validation: a number field is not enforced server-side. I posted not-a-number and it saved, because the field type special-cases checkbox, select and a handful of others. If a setting has to be numeric, check it in your own sanitize filter.

Saving settings is guarded by manage_woocommerce, which is usually too much

WC_Admin_Settings::save() opens with current_user_can( 'manage_woocommerce' ) and dies with a 403 when that fails. manage_woocommerce is the store manager's master key: it gates settings, shipping methods, payment gateways and webhooks in WooCommerce's own REST namespace, which you can read off the map in wc_rest_check_manager_permissions(). On the test store, administrators and shop managers both held it, and subscribers held neither it nor edit_products.

That is fine for a settings screen, which is store-wide configuration by definition. It is the wrong key for an action that belongs to one product. A button that confirms a delivery should not require the ability to edit payment gateways, and if you reuse manage_woocommerce for it, your button is the reason a shop assistant cannot be given a narrower role.

Give the action its own capability. WooCommerce's own post types show the pattern: a capability type such as shop_order expands into edit_shop_order, read_shop_order and the rest, installed into roles at install time. For one product-specific action a single capability is enough, and what matters is that your code checks a capability name rather than assuming a role name.

<?php
// Reference the capability, never a role.
if ( ! current_user_can( 'sifero_confirm_delivery' ) ) {
	return new WP_Error( 'sifero_forbidden', 'Not allowed.', array( 'status' => 403 ) );
}

// Grant it on activation, to roles you choose.
function sifero_add_capabilities() {
	foreach ( array( 'administrator', 'shop_manager' ) as $role_name ) {
		$role = get_role( $role_name );

		if ( $role ) {
			$role->add_cap( 'sifero_confirm_delivery' );
		}
	}
}
register_activation_hook( __FILE__, 'sifero_add_capabilities' );

Two habits keep this honest. Check the capability and let role membership be somebody else's decision, because a role is a bag of capabilities the site owner can edit. And never test for a user id or an email domain; both break the day a person leaves.

A licence key cannot stop anybody using GPL code

The code is GPL, and the licence you ship it under grants every recipient the right to run it, study it, modify it and redistribute it. No amount of code changes that. A check that refuses to run without a key is not enforcement, it is a bug report waiting to happen, and the person who hits it is a paying customer behind a firewall or on a staging site.

What a licence key does control is everything on your side of the wire: the update server, support, and the download for the next version. That is a real business and it needs no obfuscation. Inside the code: check the key, cache the answer, and never let it change what the installed version does. The only thing that stops when the server is unreachable is the update.

Updates arrive through two filters, and both have to tolerate silence

WordPress asks two questions when it renders the plugins screen. Are there newer versions, which is answered by filling in the update_plugins transient before it is written, and what is this plugin, which is answered by plugins_api(). Extensions hook pre_set_site_transient_update_plugins and plugins_api. WooCommerce's own helper does exactly this, in includes/admin/helper/class-wc-helper-updater.php and class-wc-plugin-api-updater.php.

<?php
add_filter( 'pre_set_site_transient_update_plugins', 'sifero_check_for_update' );

function sifero_check_for_update( $transient ) {
	if ( empty( $transient->checked ) ) {
		return $transient;
	}

	$remote = sifero_remote( 'version' );

	if ( is_wp_error( $remote ) || empty( $remote->version ) ) {
		return $transient;
	}

	$file = 'sifero-extension/sifero-extension.php';

	if ( version_compare( $remote->version, $transient->checked[ $file ], '>' ) ) {
		$transient->response[ $file ] = (object) array(
			'slug'        => 'sifero-extension',
			'plugin'      => $file,
			'new_version' => $remote->version,
			'package'     => $remote->package,
		);

		unset( $transient->no_update[ $file ] );
	} else {
		$transient->no_update[ $file ] = (object) array(
			'slug'        => 'sifero-extension',
			'plugin'      => $file,
			'new_version' => $remote->version,
		);

		unset( $transient->response[ $file ] );
	}

	return $transient;
}

That object's shape matters. plugin is the plugin file relative to the plugins directory, new_version is what the update row displays, and package is the URL WordPress downloads and installs. Get package wrong and the update button downloads nothing. Returning $transient untouched is always a valid answer, which is what makes failure handling easy to get right.

On the test store, with a mocked licence server reporting 1.1.0 and an installed version of 1.0.0, the filtered transient gained one response entry with new_version=1.1.0 and the expected package URL. With the installed version newer than the server, the entry moved to no_update instead, which is what stops WordPress from offering a downgrade. plugins_api answered only for the extension's own slug and returned false for anything else, so another plugin's details modal still came from wordpress.org.

The licence server going down must cost an update, not a site

Three failure paths, same code. A 500 left the transient with no response entries and the site carried on. A WP_Error timeout left it untouched, so WordPress retried on the next check instead of caching the failure. A version lower than the installed one went to no_update. None of the three threw or changed anything the installed plugin does at runtime.

The remaining risk is the request itself. It runs on the update check, a cron job, but a slow server with a 30-second timeout adds 30 seconds to that job. Cap the timeout low and cache the answer. I cached a success for six hours and a failure for five minutes: two calls in one request made one HTTP request, and two calls against a failing endpoint made one request, then served the cached error. The asymmetry is the point. A failure is worth retrying soon; a success is not worth re-asking.

What this does not solve is a server that is up but wrong. A version that does not exist, or a package URL that 404s, gives the customer an update they cannot install. Validate the version before it reaches the transient.

Lab 45 minutes

Wire settings, a capability and a licence check into one small extension, then break the licence server on purpose.

  1. Register a settings page with two sections and three field types, loading the class inside the woocommerce_get_settings_pages callback. Print the page ids and the fields of each section.
  2. Save the page with posted values, then read them back with get_option(). Post a non-numeric value into a number field and write down what was stored.
  3. Add a capability named after your extension and grant it to shop_manager on activation. Print current_user_can() for an administrator, a shop manager and a subscriber, before and after granting.
  4. Add an update check with pre_set_site_transient_update_plugins and a plugins_api callback. Fake a licence server with the pre_http_request filter, then print new_version and package from the filtered transient.
  5. Fake the server as a 500, then as a timeout. Print the transient after each.
  6. Cache the answer for six hours on success and five minutes on failure, then count the HTTP requests two page loads make in each case.

Then answer in one sentence each: why the number field accepted a non-numeric value, and which capability guards a courier delivery.

Show the solution and the reasoning

The settings page owns its own validation, because a field type does not. sifero_debounce_seconds stored not-a-number happily, and the reason is in WC_Admin_Settings::save_fields(): the switch special-cases a few types and everything else falls to wc_clean(), which sanitises rather than validates. A number field is a hint to the browser, so check the value and fall back to the default when it is not numeric.

<?php
add_filter(
	'woocommerce_admin_settings_sanitize_option_sifero_debounce_seconds',
	function ( $value ) {
		return is_numeric( $value ) ? (int) $value : 30;
	}
);

The second question has a narrower answer than it looks. A delivery confirmation is not a settings change, so manage_woocommerce is the wrong key even though it would work. My check printed administrator: true, shop_manager: true, subscriber: false for manage_woocommerce, which says nothing about deliveries. Granting sifero_confirm_delivery to the two admin roles and checking that name is what lets a store owner hand the button to a dispatcher without handing over the payment settings.

The licence half is where the discipline shows. With the server reporting 1.1.0 the filtered transient gained one entry with new_version=1.1.0 and a package URL; with a 500 or a WP_Error it came back unchanged; a version behind the installed one landed in no_update. Every path returns $transient, and that one convention is what makes a licence server outage a non-event.

Two limits. Caching the licence answer means a customer who buys a licence sees updates up to six hours later, which is worth stating in the readme. And none of this is licence enforcement: a determined user can delete the check. The key gates the update server and support, so build the extension to behave that way.

Official reference

The WordPress developer reference for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.