Guidance for working on this repository.
perxel-example - a public WordPress plugin (repo
github.com/perxel/wp-example, WordPress.org slug perxel-example,
published under the phucbm .org account, branded Perxel).
It was scaffolded from
perxel/wp-plugin-starter. If
this file still says "perxel-example" / "PXEX" / "Example", the
template tokens have not been replaced yet - see the starter README.
perxel-example.php Main file: header, constants, autoloader, UI-kit loader, boot
uninstall.php Deletes the option (and any custom tables) on delete
includes/*.php One PSR-4-ish class per concern, namespace Perxel_Example\
includes/views/*.php Dumb admin templates, fed vars by the screen classes
assets/css, assets/js Admin-only CSS/JS (plugin-specific; layout comes from the kit)
vendor/perxel-ui/ Shared admin-UI kit - vendored, see below
languages/ .pot template
readme.txt WordPress.org listing (keep in sync with README.md + version)
.wordpress-org/ Listing assets (icon, banner, screenshots) - not shipped
.github/workflows/ lint.yml (PHPCS + Plugin Check), release.yml
includes/ is loaded by the spl_autoload_register in the main file (not
Composer). Plugin::instance()->boot() runs on plugins_loaded and wires
Admin.
Plugin- singleton.boot()wires the admin surface;activate()is the activation hook (seed options, create tables).Admin- owns the menu (oneTools ->screen), the shared layout args, asset loading, and the plain-form /admin-posthandlers. Each screen is arender_*()method + a view underincludes/views/; heavier per-screen logic goes in its own class.Settings- the one option (PXEX_OPTION_KEY), read through typed accessors, written throughupdate()/sanitize(). Never callget_option()for it directly elsewhere.
The template ships none. When you add them:
-
One
includes/Db.phpfor schema (dbDeltaonPlugin::activate(), a storedpxex_db_versionoption,Db::maybe_upgrade()oninit), and one repository class that is the only code touching the tables. -
Bind the table name with the
%iplaceholder - never concatenate it.%ineeds WP 6.2+ (the template's floor is already 6.5).// Right: $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE run_id = %d', Db::items(), $run_id ), ARRAY_A ); // Wrong - WordPress.DB.PreparedSQL.NotPrepared (error-level, blocks .org): $wpdb->get_results( "SELECT * FROM {$table} WHERE run_id = {$run_id}" );
-
A
SELECTwith only the table (no other args) still goes throughprepare():$wpdb->prepare( 'SELECT COUNT(*) FROM %i', Db::runs() ). -
DROP TABLE:$wpdb->prepare( 'DROP TABLE IF EXISTS %i', $table ). -
Direct
$wpdbon your own table is expected; annotate the call:// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- <reason>.dbDelta()CREATE and uninstall DROP addWordPress.DB.DirectDatabaseQuery.SchemaChange. -
A dynamic
IN (...)list is the one case with no clean placeholder: build it withimplode( ', ', array_fill( 0, count( $ids ), '%d' ) )and wrap that one statement in// phpcs:disable ... WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber/// phpcs:enable. -
Uncomment the
WordPress.DB.DirectDatabaseQueryblock inphpcs.xml.dist. -
Don't put a bare SQL keyword like
'create'as a column value inside a$wpdb->insert()/update()call - PHPCS reads it as DDL. Assign it to a variable first.
- Namespace
Perxel_Example\- the slug (perxel-example) inUcfirst_Snakeform, soWordPress.NamingConventions.PrefixAllGlobalsaccepts it as the plugin prefix (Plugin Check does not readphpcs.xml.dist, so aVendor\Package-style namespace would be flagged there). Sub-namespaces are fine (Perxel_Example\Admin\Foo->includes/Admin/Foo.php). Hooks, option keys and CSS classes staypxex_/pxex-; constantsPXEX_. Product name is the constantPXEX_NAME(no rebrand option). - Text domain
perxel-example(= the slug). JS i18n viawp.i18n(wp_set_script_translations); script deps includewp-i18n. - Escape at output. Views set
// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscapedbecause the kit escapes structure - every dynamic value is still escaped inline. - Admin screens render inside
Perxel_UI_Layout::open()/close()viaAdmin::screen(), which falls back to a plain notice if the kit is not vendored. Use the kit components (rows(),notice(),toggle(),code(),meter(),progress_bar(),checkbox_group(),card()) rather than hand-rolled markup. A bare<input type="checkbox">renders as a square box; the iOS switch isPerxel_UI::toggle()/ the.pxui-toggleclass. Figures (counts, totals) are arows()group - label left, value ascontentright,subfor the qualifier,tonefor good/warn/bad. - Forms that can lose unsaved edits carry
data-pxui-dirty-guard(kit >= 0.20.0).
Standalone repo perxel/wp-plugin-ui,
vendored via bin/update-ui.sh <version> (curl a tagged tarball into
vendor/perxel-ui/, Action Scheduler style - no Composer). Committed;
.gitignore keeps it out of the general vendor/ ignore, .distignore strips
only its dev-only showcase/. Overwriting it can never change plugin behaviour
- the
loader.php"highest version wins" negotiation picks the newest copy across every active plugin, and a second copy is inert.
The version passed to Perxel_UI_Loader::register() in the main file and the
vendor/perxel-ui/ contents must match the tag you vendored. Update both when
you run bin/update-ui.sh.
We host the kit's component showcase as a hidden maintainer-only screen
(PERXEL_UI_SHOWCASE_HOSTED + Admin::can_see_showcase()), so its own Tools
page is suppressed.
php -l <changed files>
composer run lint # phpcs - must stay green
composer run build # bin/build-zip.sh - installable zip in dist/
bin/plugin-check.sh # official Plugin Check, same as CI (needs wp-cli +
# `wp package install wordpress/plugin-check-cli`)phpcs.xml.dist curates the base WordPress standard: a terse-docblock house
style, and PrefixAllGlobals is told about both the plugin prefix and the
kit's (perxel_ui / PERXEL_UI / Perxel_UI / pxui). CI also runs the
official Plugin Check action against the built zip (not the raw checkout).
There are no automated tests and no WP in the lint environment - phpcs and
php -l verify syntax and style only. Behaviour must be smoke-tested on a real
WordPress site.
Rules that are not obvious and cost real time when re-derived per plugin:
| Rule | Why |
|---|---|
Namespace root = slug in Ucfirst_Snake (Perxel_Example) |
PrefixAllGlobals accepts it as the prefix; a Vendor\Package namespace is flagged (NonPrefixedNamespaceFound) and Plugin Check ignores the phpcs.xml.dist prefix list |
Custom-table names via %i, never string-concatenated |
WordPress.DB.PreparedSQL.NotPrepared is error-level and blocks .org (see "Custom tables") |
No load_plugin_textdomain() |
.org auto-loads translations (slug == text domain); calling it on plugins_loaded is "too early" on WP 6.7+ |
Prefix any variable you assign in a view ($pxex_url); vars passed in via extract() are fine |
NonPrefixedVariableFound fires on template-scope assignments |
set_time_limit() etc.: function_exists() guard + inline // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- <reason> |
discouraged-function warning |
Calling another plugin's hooks (WPML wpml_*, WooCommerce): scope a phpcs.xml.dist exclude to the wrapper file and add the code to lint.yml -> ignore-codes |
NonPrefixedHooknameFound; the two tools don't share config |
'suppress_filters' => true in a query: same dual-suppression, code WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters |
deliberate but flagged |
The split that bites: Plugin Check runs its own ruleset, not phpcs.xml.dist.
Any suppression for a documented false positive goes in both places -
phpcs.xml.dist (for composer run lint) and lint.yml -> ignore-codes
(mirrored by bin/plugin-check.sh).
- Bump the version in
perxel-example.php(header +PXEX_VERSION) andreadme.txt(Stable tag); add a changelog entry to bothreadme.txtandCHANGELOG.md. The tag must equal theVersion:header orrelease.ymlfails. - Create a GitHub Release with that tag.
release.yml'szipjob attachesperxel-example.zip; thedeploy/assetsjobs push to WordPress.org SVN but only when the repo variableDEPLOY_TO_WPORGistrue(set it once the first manual .org review is approved, alongside theSVN_USERNAME/SVN_PASSWORDsecrets). Until then a Release just builds the zip and stays green.
Build artifacts (dist/) are never committed.