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.
Wire settings, a capability and a licence check into one small extension, then break the licence server on purpose.
- Register a settings page with two sections and three field types, loading the
class inside the
woocommerce_get_settings_pagescallback. Print the page ids and the fields of each section. - Save the page with posted values, then read them back with
get_option(). Post a non-numeric value into anumberfield and write down what was stored. - Add a capability named after your extension and grant it to
shop_manageron activation. Printcurrent_user_can()for an administrator, a shop manager and a subscriber, before and after granting. - Add an update check with
pre_set_site_transient_update_pluginsand aplugins_apicallback. Fake a licence server with thepre_http_requestfilter, then printnew_versionandpackagefrom the filtered transient. - Fake the server as a
500, then as a timeout. Print the transient after each. - 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.