Skip to content
Plugin PantryPlugins

How we build

This is the whole standard, the same copy the plugins are built to. It is published so you can hold us to it.

Every Plugin Pantry plugin is built to this document. It is public: the site publishes it at /standard. If a plugin cannot meet a rule, the rule wins and the feature is cut.

The promise

A Pantry plugin does one job, does it completely, and stays that size. The free plugin is the whole plugin for its scope. It is never a trial, never crippled, and never nags. Pro is a separate add-on that extends scope; it never unlocks things the free plugin pretends to have.

Scope contract

Each plugin has an entry in registry/plugins.json with three lists: does, does_not, and pro. Those lists are the contract.

  • A feature request that is not in does is either rejected, added to pro, or becomes a new plugin. It never goes into the free plugin.
  • does_not is published on the plugin page. It is a feature, not an apology.
  • The main settings screen is one page. If a plugin needs a second screen, it is because it has a list table (redirects, entries), never because it has more options.

Hard limits

Rule Limit
PHP 8.1 or newer, declared in the header and enforced at boot with a friendly notice
WordPress 6.4 or newer
Settings screens One, plus at most one list-table screen
Options One option array per plugin, named pantry_<slug>_settings, plus at most two housekeeping options (version, first-run)
Custom tables Only where an option or a custom post type would be wrong; declared in FACTS.json
Admin assets One CSS file and at most one JS file, enqueued only on the plugin's own screens
Front-end assets None, unless the feature is the asset. If so, enqueued only where used and declared in FACTS.json
External requests None, unless the feature is the request (SMTP is the request; a licence check is not a feature)
jQuery Not used. Vanilla JavaScript only
Build step None at runtime. No Composer autoloader shipped, no node_modules, no bundler output
Admin notices One dismissible notice on first activation at most. Never a sales notice
Redirects on activation Never
Tracking Never. No telemetry, no phone-home, no anonymous stats
Lines of PHP Aim under 1,500 per plugin including the vendored core. Over 2,000 needs a written reason in the plugin README

Code rules

  • Namespace Pantry\<Name> (see namespace in the registry). Prefix every option, hook, transient, meta key, table, shortcode, block and handle with pantry_<slug>_ or pantry-<slug>-.
  • Every write path has a capability check and a nonce. Every AJAX or REST handler checks both.
  • Sanitise on input with the narrowest function that fits. Escape on output, always, at the last moment.
  • Every $wpdb call with a variable uses $wpdb->prepare(). Table names come from a class constant built from $wpdb->prefix.
  • No extract(), no eval(), no create_function(), no base64 tricks, no @ error suppression.
  • uninstall.php removes every option, transient, cron event, post meta, custom table and custom post type the plugin created. Deactivation removes cron events only.
  • Text domain equals the plugin slug. Every user-facing string is wrapped and translatable.
  • Hooks are registered in one place (Plugin::register()), not in constructors.
  • No emoji anywhere: not in code, not in UI strings, not in readme files.
  • Readable over clever. If a function needs a comment to explain what it does, split it.

File layout

plugins/pantry-<slug>/
  pantry-<slug>.php        header, constants, autoloader, boot guard, boot
  uninstall.php            removes everything
  readme.txt               generated by tools/gen-readme.mjs from the registry, do not hand edit
  README.md                human docs: what it does, how to use it, filters and hooks
  CHANGELOG.md             Keep a Changelog format
  FACTS.json               measured footprint, written after the plugin is built (schema below)
  src/                     plugin classes, one class per file, class-<name>.php
    class-plugin.php       registers hooks, owns instances
    class-settings.php     extends Core\Settings_Page
  includes/core/           vendored copy of packages/pantry-core, written by tools/sync-core.sh, do not edit here
  assets/
    admin.css              only if the core CSS is not enough
    admin.js               only if the screen needs behaviour
  languages/               .pot generated at release

The main file, verbatim pattern:

<?php
/**
 * Plugin Name:       Pantry Duplicate
 * Plugin URI:        https://thepluginpantry.com/plugins/duplicate
 * Description:       Copy any post, page or custom post type to a new draft.
 * Version:           1.0.0
 * Requires at least: 6.4
 * Requires PHP:      8.1
 * Author:            Plugin Pantry
 * Author URI:        https://thepluginpantry.com
 * License:           GPLv2 or later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       pantry-duplicate
 */

declare( strict_types=1 );

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

define( 'PANTRY_DUPLICATE_VERSION', '1.0.0' );
define( 'PANTRY_DUPLICATE_FILE', __FILE__ );
define( 'PANTRY_DUPLICATE_DIR', plugin_dir_path( __FILE__ ) );
define( 'PANTRY_DUPLICATE_URL', plugin_dir_url( __FILE__ ) );

spl_autoload_register(
	static function ( string $class_name ): void {
		$prefix = 'Pantry\\Duplicate\\';
		if ( 0 !== strncmp( $class_name, $prefix, strlen( $prefix ) ) ) {
			return;
		}
		$relative = substr( $class_name, strlen( $prefix ) );
		$base     = PANTRY_DUPLICATE_DIR . 'src/';
		if ( 0 === strncmp( $relative, 'Core\\', 5 ) ) {
			$relative = substr( $relative, 5 );
			$base     = PANTRY_DUPLICATE_DIR . 'includes/core/';
		}
		$parts = explode( '\\', $relative );
		$name  = array_pop( $parts );
		$path  = $base . ( $parts ? strtolower( implode( '/', $parts ) ) . '/' : '' );
		$file  = $path . 'class-' . strtolower( str_replace( '_', '-', $name ) ) . '.php';
		if ( is_readable( $file ) ) {
			require $file;
		}
	}
);

if ( version_compare( PHP_VERSION, '8.1', '<' ) ) {
	add_action(
		'admin_notices',
		static function (): void {
			echo '<div class="notice notice-error"><p>' . esc_html__( 'Pantry Duplicate needs PHP 8.1 or newer. The plugin is installed but not running.', 'pantry-duplicate' ) . '</p></div>';
		}
	);
	return;
}

register_activation_hook( __FILE__, array( Pantry\Duplicate\Plugin::class, 'activate' ) );
register_deactivation_hook( __FILE__, array( Pantry\Duplicate\Plugin::class, 'deactivate' ) );

add_action( 'init', array( Pantry\Duplicate\Plugin::class, 'boot' ), 0 );

Plugin::boot() runs on init at priority 0, creates the instances and calls register() on each. It runs on init rather than plugins_loaded because the screen titles passed to the settings class are translated, and WordPress 6.7+ logs a notice when a text domain is loaded before init. Priority 0 means hooks the plugin adds to init itself still run. Plugin::activate() sets defaults and creates tables. Nothing runs at file-include time except constants and the autoloader. Read settings inside hook callbacks rather than in register() wherever practical.

Settings screens

Extend Core\Settings_Page. Declare fields as data in sections(). The base class handles the Settings API registration, sanitisation by type, rendering, the plugin-row Settings link, and the label header. Use render_after() for anything beyond fields (a test button, a list). Use native WordPress controls; the core CSS only sets the label header and spacing.

Settings live under Settings for single-screen plugins. A plugin with a list table lives under Tools, or top-level only when it owns a post type.

FACTS.json

Written by the developer after building, measured, never guessed. The site prints it on the plugin page.

{
  "zip_kb": 0,
  "php_lines": 0,
  "options": ["pantry_duplicate_settings"],
  "post_meta": [],
  "tables": [],
  "post_types": [],
  "cron_events": [],
  "transients": [],
  "admin_assets": ["assets/admin.css on the settings screen only"],
  "frontend_assets": [],
  "external_requests": [],
  "measured_on": "2026-09-12"
}

Release checklist

Every item, every release. tools/lint.sh <slug> runs the first four.

  • php -l clean on PHP 8.1, 8.2 and 8.3
  • PHPCS clean on WordPress-Extra with the project ruleset
  • PHPStan level 5 clean with WordPress stubs
  • Plugin Check clean (the wordpress.org reviewer tool)
  • Activates, works, deactivates and uninstalls on a fresh install with WP_DEBUG on and no notices in debug.log
  • Settings save, sanitise and round-trip
  • Works with the default theme and with a block theme
  • FACTS.json measured on the release zip
  • CHANGELOG.md updated, version bumped in header, constant and readme stable tag
  • readme.txt regenerated

The commercial rules

  • The free plugin keeps working forever. Nothing expires.
  • Pro is a separate plugin. Buying Pro never changes free behaviour.
  • A lapsed Pro subscription keeps working. Renewal buys updates and support.
  • No lifetime licences, no fake countdowns, no "limited" anything.
  • No affiliate links, no partner plugin recommendations, no dashboard widgets that sell.