juniorenato / wp-builder
A developer-oriented builder for WordPress custom post types, taxonomies, meta boxes, admin pages, navigation menus, and server-rendered Gutenberg blocks.
Requires
- php: >=8.4
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
WordPress Builder is a PHP library created for developers. It is not an admin plugin and does not add a graphical interface to WordPress.
Use it in a theme or plugin to register custom post types, taxonomies, navigation menus, meta boxes, admin pages, form fields, and server-rendered Gutenberg blocks, and to set response headers, with a small fluent API instead of repeating WordPress boilerplate.
Contents
- Requirements
- Installation
- Directory tree
- Bootstrap —
Builder,Notices - Custom post types —
PostType,CustomPostType,EditPostType,RemovePostType - Taxonomies —
Taxonomy - Menus —
Menu - Meta boxes —
MetaBox - Admin pages —
AdminPage - Form fields —
AdminForm,Forms\Repeater - Gutenberg blocks —
Block,Blocks\Repeater,FieldValue - Comments —
Comment - Performance —
Performance,PageCache,CacheScreen - Security —
Security - HTTP headers —
Header,Csp - Rate limit —
RateLimit - License
Requirements
- PHP 8.4 or later
- WordPress (PHP-only Gutenberg blocks require WordPress 7.0 or later)
Installation
Composer
From your theme or plugin directory:
composer require juniorenato/wp-builder
Load Composer's autoloader from wp-config.php, before the line that loads wp-settings.php:
require_once '/absolute/path/to/vendor/autoload.php';
Requiring it from the theme or plugin works too. Construct Builder from the theme or plugin file, not from wp-config.php.
Manual
Copy this package into your project and map the WPB\ namespace to lib/ (PSR-4), or require vendor/autoload.php if you still run Composer locally.
Directory tree
wp-builder/
├── composer.json
├── composer.lock
├── LICENSE
├── README.md
├── assets/
│ ├── css/
│ │ ├── admin-image.css
│ │ ├── admin-repeater.css
│ │ └── block-editor.css
│ └── js/
│ ├── admin-image.js
│ ├── admin-regenerate-images.js
│ ├── admin-repeater.js
│ └── block-editor.js
├── lang/
│ ├── wpb-en_US.mo
│ ├── wpb-en_US.po
│ ├── wpb-pt_BR.mo
│ └── wpb-pt_BR.po
├── lib/
│ ├── Builder.php
│ ├── Notices.php
│ ├── Blocks/
│ │ ├── Block.php
│ │ ├── FieldValue.php
│ │ └── Repeater.php
│ ├── Comments/
│ │ └── Comment.php
│ ├── Performance/
│ │ ├── CacheScreen.php
│ │ ├── PageCache.php
│ │ └── Performance.php
│ ├── Security/
│ │ ├── RateLimit.php
│ │ └── Security.php
│ ├── Forms/
│ │ ├── AdminForm.php
│ │ ├── PostTypeMetaBox.php
│ │ ├── Repeater.php
│ │ └── TaxonomyCustomField.php
│ ├── Http/
│ │ ├── Csp.php
│ │ └── Header.php
│ ├── Menus/
│ │ └── Menu.php
│ ├── MetaBoxes/
│ │ └── MetaBox.php
│ ├── Pages/
│ │ └── AdminPage.php
│ ├── PostTypes/
│ │ ├── CustomPostType.php
│ │ ├── EditPostType.php
│ │ ├── PostType.php
│ │ └── RemovePostType.php
│ └── Taxonomies/
│ └── Taxonomy.php
└── views/
├── page.php
├── performance/
│ ├── cache.php
│ ├── dashboard.php
│ └── images.php
├── blocks/
│ ├── repeater.php
│ └── partials/
│ ├── boolean.php
│ ├── enum.php
│ ├── image.php
│ ├── integer.php
│ └── string.php
└── form/
├── get-field.php
└── partials/
├── checkbox.php
├── control.php
├── description.php
├── file.php
├── hidden.php
├── image.php
├── label.php
├── number.php
├── radio.php
├── rich-text.php
├── repeater-item.php
├── repeater.php
├── select.php
├── text.php
└── textarea.php
lib/ is the public PHP API (WPB\). views/ holds admin form, block, and settings page templates. assets/js/block-editor.js is the shared inspector script for repeaters and post meta fields. lang/ ships English and Brazilian Portuguese translations.
Bootstrap
Instantiate WPB\Builder once from your theme or plugin. Composer’s autoload can be required from wp-config.php. Builder still belongs in the theme or plugin bootstrap. It loads lang/wpb-{locale}.mo on init and does not copy that file into wp-content/languages.
Theme (functions.php):
use WPB\Builder; new Builder(Builder::THEME);
Plugin:
use WPB\Builder; new Builder(Builder::PLUGIN);
Constructors in this library are safe to call from a bootstrap file. Blocks, post types, taxonomies, menus, and meta boxes are created on init. A call made while init is running still creates them inside that hook. A call made after init has finished waits for the next request.
Builder::THEME is theme and Builder::PLUGIN is plugin. isTheme() and isPlugin() read the environment stored by the constructor. url() turns an absolute path inside this install into a public URL, or returns an empty string when the path is outside it. Builder::PATH points at the packaged directories: FORM (views/form/), LANG (lang/), PAGE (views/), and BLOCK (views/blocks/). PATH['BLOCK'] is the library repeater view. It is not the public block PHP directory.
blockAssets() sets the theme directory for block CSS and JavaScript. The default is assets/public/blocks. blockTemplates() sets the theme directory for public block PHP. The default is template-parts/blocks. Call either method with no argument to read the directory in effect. The path is relative to the child theme, then the parent. An empty path, or a path that contains .., is ignored and reported in the admin.
Builder::isTheme(); Builder::url(get_stylesheet_directory() . '/assets/cta.css'); Builder::blockAssets('assets/dist/blocks'); Builder::blockTemplates('parts/blocks');
Notices
Invalid fluent registration is reported with Notices and the request keeps running. error() queues a message once. render() prints the queue in the WordPress admin, as an error notice, to users who can manage_options.
use WPB\Notices; Notices::error('Field name is already in use.');
Header and Csp throw InvalidArgumentException when a name or a value cannot be sent.
Custom post types
PostType stores labels, rewrite rules, supports, taxonomies, and register_post_type() arguments. CustomPostType and EditPostType extend it, and RemovePostType extends EditPostType. Call these methods on the subclass:
| Method | Use |
|---|---|
setPostType($postType) |
Post type key |
setLabels($singular, $plural, $male = true) |
Generated singular and plural labels |
labels($labels, $val = null, $ucfirst = true) |
One label, or a map of labels |
icon($icon) |
Dashicon or image URL for the menu |
position($position) |
Menu position |
supports($supports, $val = null) |
Feature list, or one feature and its value |
taxonomies($taxonomies) |
Taxonomy slugs attached to the type |
rewrite($rewrite, $val = null) |
Rewrite slug or rewrite array |
args($config, $val = null) |
Any register_post_type() argument |
metaBox() comes from the PostTypeMetaBox trait. Pass one MetaBox or a list. The field methods come from the AdminForm trait.
CustomPostType
Quick registration
Pass the post type key, singular label, and plural label to the constructor. WordPress receives register_post_type() on init.
use WPB\PostTypes\CustomPostType; new CustomPostType('book', 'book', 'books');
The fourth argument controls masculine or feminine generated labels (true by default):
new CustomPostType('review', 'review', 'reviews', false);
Fluent registration
use WPB\PostTypes\CustomPostType; $books = new CustomPostType(); $books->setPostType('book') ->setLabels('book', 'books') ->icon('dashicons-book') ->position('20') ->supports('thumbnail', true) ->supports('excerpt', true) ->taxonomies(['genre']) ->rewrite('slug', 'books') ->args([ 'public' => true, 'has_archive' => true, 'show_in_rest' => true, ]); $books->register();
Default supports values are title and editor. Use args() for any argument accepted by register_post_type().
Fields on the post type
Fields added on the post type are stored as post meta and rendered in a meta box:
use WPB\PostTypes\CustomPostType; $books = new CustomPostType(); $books->setPostType('book'); $books->setLabels('book', 'books'); $books->addTextField('isbn', 'ISBN', 'International Standard Book Number'); $books->addNumberField('pages', 'Pages'); $books->addSelectField('format', 'Format', [ 'hardcover' => 'Hardcover', 'paperback' => 'Paperback', 'ebook' => 'E-book', ]); $books->register();
EditPostType
Edit an existing post type
Merge arguments into post, page, or any registered type:
use WPB\PostTypes\EditPostType; new EditPostType('post', [ 'has_archive' => true, 'menu_icon' => 'dashicons-welcome-write-blog', ]);
Or configure first, then apply:
use WPB\PostTypes\EditPostType; $posts = new EditPostType(); $posts->setPostType('page'); $posts->args('show_in_rest', true); $posts->edit();
RemovePostType
Hide a post type
This does not unregister the type. It hides it from menus, search, and public queries:
use WPB\PostTypes\RemovePostType; new RemovePostType('attachment');
Taxonomies
Taxonomy registers a taxonomy with register_taxonomy() on init. taxonomy(), postTypes(), setLabels(), labels(), rewrite(), capabilities(), and args() set that registration. register() sends it to WordPress.
Quick registration
use WPB\Taxonomies\Taxonomy; new Taxonomy('genre', 'book', 'genre', 'genres');
The second argument can be one post type or a list of slugs.
Fluent registration
use WPB\Taxonomies\Taxonomy; $genre = new Taxonomy(); $genre->taxonomy('genre'); $genre->postTypes(['book']); $genre->setLabels('genre', 'genres'); $genre->rewrite('slug', 'genres'); $genre->args([ 'hierarchical' => true, 'show_admin_column' => true, 'show_in_rest' => true, ]); $genre->register();
Term fields
Custom fields appear on the add-term and edit-term screens and are saved as term meta. Taxonomy uses the TaxonomyCustomField trait for those screens. The trait is not constructed on its own:
use WPB\Taxonomies\Taxonomy; $genre = new Taxonomy(); $genre->taxonomy('genre'); $genre->postTypes('book'); $genre->setLabels('genre', 'genres'); $genre->addTextField('accent_color', 'Accent color'); $genre->addTextareaField('intro', 'Archive intro'); $genre->register();
Menus
Menu registers theme locations with register_nav_menu() on init and can store custom fields on each menu item.
Quick registration
Pass the location slug and the description shown under Appearance → Menus. WordPress receives register_nav_menu() on init.
use WPB\Menus\Menu; new Menu('primary', 'Primary menu');
The slug passes through sanitize_key().
Fluent registration
use WPB\Menus\Menu; $menus = new Menu(); $menus->location('primary', 'Primary menu'); $menus->location('footer', 'Footer menu'); $menus->register();
Read the assigned menu with get_nav_menu_locations(). Print it with wp_nav_menu().
Fields on the menu item
Custom fields appear on every item in the menu editor and are saved as post meta on that item. WordPress edits items on the menu, so the fields are not limited to one theme location.
use WPB\Menus\Menu; $menus = new Menu(); $menus->location('primary', 'Primary menu'); $menus->addTextField('icon', 'Icon', 'Dashicon class or a short label.'); $menus->addCheckboxField('highlight', 'Highlight', [ '1' => 'Show as highlighted', ]); $menus->register();
wp_setup_nav_menu_item copies each value onto the item. A walker, or wp_get_nav_menu_items(), can read $item->icon. The same value is get_post_meta($item->ID, 'icon', true).
Field names must contain only letters, numbers, hyphens, and underscores. A name WordPress already uses on the item, such as url, title, or classes, is ignored and reported in the admin. The same name cannot be registered twice.
The menu editor submits every item in one form. The library keeps the meta key equal to the field name and posts that item under its own input.
Meta boxes
MetaBox renders fields on a post screen and saves them as post meta. Use it when you want an explicit title, screen, context, or priority. Attach it to a post type with metaBox():
use WPB\MetaBoxes\MetaBox; use WPB\PostTypes\CustomPostType; $details = new MetaBox(); $details->title('Book details') ->context('side') ->priority('high'); $details->addTextField('isbn', 'ISBN'); $details->addNumberField('pages', 'Pages'); $books = new CustomPostType(); $books->setPostType('book'); $books->setLabels('book', 'books'); $books->metaBox($details); $books->register();
You can also register a meta box on its own against any screen:
use WPB\MetaBoxes\MetaBox; $seo = new MetaBox(); $seo->metaBoxId('page_seo'); $seo->title('SEO') ->screen('page') ->context('normal'); $seo->addTextField('meta_title', 'Meta title'); $seo->addTextareaField('meta_description', 'Meta description'); $seo->register();
Admin pages
AdminPage adds a top-level menu or a submenu. With no render callback it prints a Settings API page and registers each field with register_setting(). parent(), title(), slug(), icon(), and position() are the short names. parentSlug(), pageTitle(), menuTitle(), capability(), menuSlug(), and iconUrl() set the same WordPress arguments directly. callback() replaces the settings form.
Settings page with fields
If you do not pass a render callback, the library renders a Settings API page (options.php) and registers each field with register_setting().
Top-level menu:
use WPB\Pages\AdminPage; $settings = new AdminPage(); $settings->title('Theme settings') ->icon('dashicons-admin-generic') ->position('59'); $settings->addTextField('company_name', 'Company name', 'Shown in the footer.'); $settings->addTextareaField('footer_note', 'Footer note'); $settings->register();
Submenu (parent slugs such as themes become themes.php):
use WPB\Pages\AdminPage; $footer = new AdminPage(); $footer->title('Footer settings'); $footer->parent('themes'); $footer->addTextField('footer_text', 'Footer text'); $footer->register();
Custom render callback
use WPB\Pages\AdminPage; new AdminPage('Reports', function (): void { echo '<div class="wrap"><h1>Reports</h1></div>'; });
Values are stored as options. Read them with get_option('company_name').
Form fields
CustomPostType, Taxonomy, Menu, MetaBox, and AdminPage share the AdminForm trait. Call the helpers on those objects.
| Method | Field type |
|---|---|
addTextField($name, $label, $description = '', $class = '', $attrs = []) |
Text |
addTextareaField($name, $label, $description = '', $class = '', $attrs = []) |
Textarea |
addNumberField($name, $label, $description = '', $class = '', $attrs = []) |
Number |
addSelectField($name, $label, $options, $multiple = false, ...) |
Select |
addRadioField($name, $label, $options, ...) |
Radio |
addCheckboxField($name, $label, $options = [], $multiple = true, ...) |
Checkbox |
addImageField($name, $label, $description = '', $class = '', $attrs = [], $multiple = false) |
Media library image |
addFileField($name, $label, $description = '', $class = '', $attrs = [], $multiple = false) |
Media library file |
addRichTextField($name, $label, $editorSettings = [], ...) |
wp_editor() |
formField($type, $name, $label, $attributes, $options, $description) |
Any allowed scalar type |
repeater($name, $label, $description = '') |
Repeater |
Storage depends on the parent object:
- post type / meta box: post meta
- taxonomy: term meta
- menu: post meta on the menu item
- admin page: options
Submitted values are sanitized (sanitize_text_field(), sanitize_textarea_field(), wp_kses_post(), and related helpers). One image or file is stored as the attachment ID, and an empty value is an empty string. multiple: true stores a list of attachment IDs. An empty list is saved when every item is removed. An image field discards a value that is not an image. A file field discards a value that is not an attachment. Duplicate IDs are stored once, in the order they were chosen.
addImageField() opens the WordPress media modal (wp.media), limited to images. addFileField() opens the same modal for any file. With multiple: true, the modal accepts several items and each later choice is added to the list. Both methods work on a post type, a meta box, a taxonomy, a menu item, an admin page, and inside a repeater.
->addImageField('photo', 'Photo') ->addImageField('gallery', 'Gallery', multiple: true) ->addFileField('download', 'Download') ->addFileField('documents', 'Documents', multiple: true)
Forms\Repeater
repeater() returns WPB\Forms\Repeater and stores a list under one meta key or option. It works on a post type, a meta box, a taxonomy, a menu item, and an admin page. Subfields use the same scalar methods as the form. end() returns to the parent repeater, or to the form at the root. min(), max(), and name() read the limits and the field name.
$details->repeater('authors', 'Authors') ->min(1) ->max(12) ->addTextField('name', 'Name') ->addSelectField('role', 'Role', [ 'author' => 'Author', 'editor' => 'Editor', ]) ->end();
get_post_meta($post_id, 'authors', true); // [ // ['name' => 'Ada', 'role' => 'author'], // ]
A taxonomy reads that list with get_term_meta(). An admin page reads it with get_option().
Nested repeaters use the same call. Inside a repeater, the scalar types are text, textarea, number, select, radio, checkbox, image, and file. Rich text stays on the parent form, because wp_editor() cannot be cloned into a new row.
min() shows empty rows until the form reaches that count. Saving fewer rows is allowed; the next edit screen fills the gap. max() disables Add and discards extra submitted rows. max(0) has no ceiling.
The screen shows Add, Remove, Move up, and Move down. Clearing every row still saves an empty list.
Names at one level must be unique. A name may contain only letters, numbers, hyphens, and underscores.
Gutenberg blocks
Block registers a server-rendered block. Requires WordPress 7.0+ (supports.autoRegister): no block.json and no build step.
On older WordPress versions, register() returns false and the rest of the library keeps working.
FieldValue normalizes each field before it is stored. Block calls it. A missing key uses the field default, and an unknown key is dropped.
use WPB\Blocks\FieldValue; $stored = FieldValue::normalizeBag([ [ 'name' => 'heading', 'type' => 'string', 'default' => 'Hello', ], ], $submitted);
Field values are stored in post meta. The meta key is the block name with / replaced by _. Each insertion has its own entry in that value, keyed by wpbId. The id is saved in the block comment, so the same block can be placed twice on one page with different data. get_post_meta() returns that map. The render callback receives the fields for that insertion only.
Call saveAsContent() to store each insertion's array in its own block comment instead, with no post meta.
use WPB\Blocks\Block; $cta = new Block(); $cta->name('my-theme/cta') ->title('Call to Action') ->description('Banner with title and button') ->category('widgets') ->icon('megaphone') ->keywords(['cta', 'banner']) ->addStringAttribute('heading', 'Heading', 'Get started') ->addBooleanAttribute('featured', 'Featured', false) ->addIntegerAttribute('max_items', 'Max items', 3) ->addEnumAttribute('size', 'Size', ['Small', 'Medium', 'Large'], 'Medium') ->supports([ 'align' => ['wide', 'full'], 'color' => ['text' => true, 'background' => true], 'spacing' => ['padding' => true, 'margin' => true], ]) ->stylesheet(get_theme_file_uri('assets/cta.css'), 'my-theme-cta') ->render(function (array $attributes): string { $wrapper = get_block_wrapper_attributes(['class' => 'my-cta']); return sprintf( '<div %1$s><h2>%2$s</h2></div>', $wrapper, esc_html($attributes['heading'] ?? '') ); }); $cta->register();
get_post_meta($post_id, 'my-theme_cta', true); // [ // '6f1c1b2a-3d4e-4f5a-8b6c-7d8e9f0a1b2c' => [ // 'heading' => 'Get started', // 'featured' => false, // 'max_items' => 3, // 'size' => 'Medium', // ], // ]
<!-- wp:my-theme/cta {"wpbId":"6f1c1b2a-3d4e-4f5a-8b6c-7d8e9f0a1b2c"} /-->
A post saved before this split stores one flat field array. The next edit in the block editor copies those values onto each insertion already on the page. A later insertion starts empty. Duplicating a block on the same page copies its values into a new id. Pasting it into another post starts from the field defaults, because the values live in the original post's meta.
Constructor shortcut when name, title, and render callback are already known:
new Block('my-theme/cta', 'Call to Action', $renderCallback);
Use get_block_wrapper_attributes() in the render callback so color, spacing, and border supports print on the wrapper. stylesheet() loads CSS on the front end and in the editor.
Block assets
assets() enqueues that block's CSS and JavaScript when the block renders. A page that does not render the block does not load the files. Calling assets() does not enqueue anything by itself. assets('style') loads only the stylesheet. assets('script') loads only the script. assets() and assets('both') load both.
With no Builder::blockAssets() call, my-theme/team looks for these files in the child theme, then the parent:
assets/public/blocks/my-theme-team.cssassets/public/blocks/my-theme-team.js
A missing file is skipped. The other file still loads. Both use the handle wpb-my-theme-team. The script prints in the footer. The style prints in the footer when the block renders after wp_head. WordPress omits assets enqueued during a render that prints nothing.
use WPB\Blocks\Block; use WPB\Builder; Builder::blockAssets('assets/dist/blocks'); $cta = new Block(); $cta->name('my-theme/cta') ->title('Call to Action') ->assets() ->render(function (array $attributes): string { $wrapper = get_block_wrapper_attributes(['class' => 'my-cta']); return sprintf( '<div %1$s><h2>%2$s</h2></div>', $wrapper, esc_html($attributes['heading'] ?? '') ); }) ->register();
That block then loads assets/dist/blocks/my-theme-cta.css and assets/dist/blocks/my-theme-cta.js.
stylesheet() is separate. It registers a URL you pass and still loads that stylesheet on the front end and in the editor.
Public HTML
render() runs when it was set. The callback receives normalized fields, and an image is the attachment ID. Its return value is the block HTML.
Otherwise the theme template is the block HTML:
template('template-parts/blocks/hero')loadstemplate-parts/blocks/hero.phpfrom the child theme, then the parent. A trailing.phpis removed.template('')is ignored, reported in the admin, and the block falls back to the convention below. This slug is the full path.blockTemplates()does not prefix it.- A
template()slug that does not exist is reported in the admin. The block then prints the mount point. - With no
template()call,my-theme/teamloadstemplate-parts/blocks/my-theme-team.phpwhen that file exists. A missing convention file is silent.Builder::blockTemplates('parts/blocks')changes that directory, so the same block loadsparts/blocks/my-theme-team.php.
get_template_part() receives $args with attributes, content, block, and name. attributes matches data-wpb-props. Call get_block_wrapper_attributes() inside the template when the markup needs alignment, color, or spacing.
The mount point is a single element:
<div class="wpb-block" data-wpb-block="my-theme/team" data-wpb-props="{...}"></div>
get_block_wrapper_attributes() supplies the wrapper attributes, including wpb-block. data-wpb-props is the field JSON. Image fields are objects {id, url, alt, width, height, srcset} using the large size. There is no data-wpb-media attribute. On the block renderer REST route the mount also contains <p class="wpb-block-placeholder">Title</p>. That paragraph is absent on the public page.
A block with a name and a title registers even when it has no render() callback and no repeater.
Blocks\Repeater
Block::repeater() returns WPB\Blocks\Repeater. A repeater is a list of fields. It can contain another repeater. The editor always shows an Add button. min() and max() limit the list. end() returns the parent repeater, or the Block at the root. renderItems() includes views/blocks/repeater.php for a list of normalized items. That file is not the public block HTML.
$team = new Block(); $team->name('my-theme/team') ->title('Team') ->repeater('sections', 'Sections') ->min(1) ->addStringAttribute('title', 'Title') ->repeater('members', 'Members') ->addImageAttribute('photo', 'Photo') ->addStringAttribute('name', 'Name') ->end() ->end() ->register();
get_post_meta($post_id, 'my-theme_team', true); // [ // '6f1c1b2a-3d4e-4f5a-8b6c-7d8e9f0a1b2c' => [ // 'sections' => [ // [ // 'title' => 'Leadership', // 'members' => [ // ['photo' => 123, 'name' => 'Ada'], // ], // ], // ], // ], // ]
photo is an attachment ID. 0 means no image. min() and max() limit the list in the editor. max(0) has no ceiling. On the front, a shorter list still renders.
The stored value and the render() callback keep that ID. The theme template and data-wpb-props replace the field in place with {id, url, alt, width, height, srcset} at size large. 0, or an attachment with no URL, becomes null.
Item fields: addStringAttribute, addIntegerAttribute, addBooleanAttribute, addEnumAttribute, addImageAttribute. A true boolean adds wpb-repeater__item--{name} to the item. Field names must be unique among siblings.
saveAsContent() keeps the same array in the block comment:
$team->saveAsContent();
<!-- wp:my-theme/team {"sections":[{"title":"Leadership","members":[{"photo":123,"name":"Ada"}]}]} /-->
A block that defines render() receives the normalized array and prints its own HTML. An image in that array is still the attachment ID.
Without render(), the block loads a theme template when one exists and otherwise prints a React mount point. views/blocks/repeater.php is not the public render. Repeater::renderItems() still includes that file.
Blocks saved in post meta, and any block with a repeater, load assets/js/block-editor.js in the editor. Scalar blocks that call saveAsContent() and have no repeater stay PHP-only.
Core blocks
new Block() attaches a render_block filter. Call Block::hookCoreTemplates() when the theme replaces core blocks and does not construct a Block. A theme file replaces the public HTML of a core block when the file exists. The directory is Builder::blockTemplates(), which defaults to template-parts/blocks. core/paragraph uses template-parts/blocks/core-paragraph.php. core/image, core/group, and core/navigation follow the same file name: template-parts/blocks/core-{slug}.php. Builder::blockTemplates('parts/blocks') moves those files to parts/blocks/core-{slug}.php.
use WPB\Blocks\Block; Block::hookCoreTemplates();
No file means the WordPress HTML is unchanged, and the admin shows no notice. The template output replaces the block. A template that prints nothing leaves the block empty.
$args contains:
attributes: the attributes WordPress stored, without rewriting imagescontent: the HTML WordPress rendered, including inner blocksblock: theWP_Blockinstancename: the block name, such ascore/paragraph
Blocks registered through WPB\Blocks\Block are not passed through this replacement. Core blocks do not get a React mount point. Their editor remains the Gutenberg editor. The template applies wherever WordPress runs render_block, including the server preview of a dynamic block.
Block limits
Content-mode inspector controls are generated for string, integer, number, boolean, and string plus enum. Meta-mode controls, repeaters, and image fields are drawn by the shared editor script. There are no inner blocks, no static save, and no textarea or rich-text fields.
Attribute names reserved by block supports are rejected: style, className, align, anchor, textColor, backgroundColor, fontSize, fontFamily.
Comments
Comment disables comments, pings, the comments admin screen, and comment support on post types:
use WPB\Comments\Comment; (new Comment())->disable();
disable() hooks admin(), status(), and menu(). admin() removes comment and trackback support, removes the recent-comments dashboard widget, and redirects edit-comments.php to the dashboard. status() returns false for comments_open and pings_open. menu() removes the Comments admin menu.
Performance
Performance is opt-in front-end cleanup. An empty constructor does nothing. A list entry calls the method with its default. Map a name to an integer when the method takes one. false skips that entry.
use WPB\Performance\Performance; new Performance([ 'disableEmoji', 'cleanHead', 'disableEmbeds', 'heartbeat' => 120, 'revisions' => 5, 'emptyTrash' => 7, 'images', 'enableCache' => 3600, ]);
Calling a method directly is the same thing: (new Performance())->disableEmoji(). enableAll() turns on every feature with its default. Put a custom number before enableAll and that number stays.
new Performance([ 'heartbeat' => 120, 'enableAll', ]); (new Performance())->enableAll();
disableEmoji()removes the emoji script, inline styles, feed and mail rewriting, the TinyMCE emoji plugin, and emoji CDN hints. Others.w.orghints stay.cleanHead()removes the RSD, Windows Live Writer, generator, shortlink, and adjacent-post tags fromwp_head.disableEmbeds()removes oEmbed discovery links and deregisterswp-embed.heartbeat(int $seconds = 60)sets the Heartbeat interval. WordPress accepts 1 to 3600 seconds. Anything outside that range uses 60.revisions(int $limit = 5)caps revisions throughwp_revisions_to_keep. Post types without revision support stay at zero. AWP_POST_REVISIONSvalue of0orfalsestays off. Older revisions are trimmed the next time that post is saved.0turns revisions off.emptyTrash(int $days = 7)replaces the daily trash cleanup.EMPTY_TRASH_DAYSis already defined by the time the theme loads, so the class cannot change that constant. Auto-draft cleanup is unchanged.0deletes trash on the next run.images()turns on every image method below. A dimension, quality, or LCP size set earlier on the same instance stays. The defaults are a 2048-pixel edge, quality 75, and thelargesize.limitImageDimension(int $pixels = 2048)caps the scaled full image throughbig_image_size_threshold. The original file stays available for editing. A value below 1 uses 2048.stripImageMetadata()rotates a new JPEG from its EXIF orientation, then removes embedded metadata. Imagick also converts that JPEG to sRGB before stripping the profile. WebP and AVIF are stripped only when Imagick is available.modernImageFormat()saves new JPEG and PNG sizes as AVIF when the editor supports it, otherwise WebP. An upload that is already AVIF or WebP is not encoded again. A JPEG or PNG within the size threshold also gets one full-size modern file beside the original.imageQuality(int $quality = 75)sets that quality for AVIF and WebP. JPEG and PNG keep the editor default. A value outside 1–100 uses 75.disableExtraImageSizes()removesmedium_large, 1536, and 2048. Thumbnail, medium, and large stay.capImageDensity()drops srcset candidates wider than twice the requested width. The smallest candidate stays when every source is wider than that.preferModernImage()points front-endsrcandsrcsetURLs at an existing AVIF sibling, or WebP when only WebP exists.responsiveImageSizes()fills a missingsizesattribute from the image width:(max-width: {width}px) 100vw, {width}px. An existingsizesvalue stays.prioritizeLcpImage(string $size = 'large')preloads the queried post thumbnail and marks that imagefetchpriority="high"withdecoding="sync". An empty size useslarge.lazyLoadImages()addsloading="lazy"anddecoding="async"only when WordPress left them off, the image has width and height, and it is notfetchpriority="high".images()also adds an Image files box to the dashboard. The button regenerates existing JPEG, PNG, WebP, and AVIF uploads in small batches. The original file stays. Generated files that the new metadata does not use are removed.enableCache(int $seconds = 3600)is the call that starts page cache. Without it, nothing is stored or served. It caches anonymousGETHTML after WordPress boots. A fresh file is sent attemplate_redirectand the template does not render again. Query strings, logged-in users, admin, AJAX, cron, REST, previews, feeds, and URLs with a file extension other than.htmlare skipped. A file is stored only when the response status is 200 and the content type is HTML. Each page editor has a sidebar checkbox, Disable cache, which skips that page. Each HTML file is named with the MD5 of its URL, for example0ac7ec625f71e888fe9db6f53d140195.html. The readable URL stays in the matching.jsonfile. Files live inwp-content/cache/wpb/until$secondspasses.
enableCache() adds Tools → Cache through AdminPage, as a submenu of Tools. While the cache is on, the dashboard lists the cached URLs. The status checkbox and one multiple select per public post type are AdminPage fields stored as options. Each post type uses the option wpb_page_cache_pages_{post_type}. The cached URL table is rendered below that form and clears files directly. Saving a public post clears that post URL, the post type archive and its /page/N/ URLs, and the pages chosen for that post type. A post type without an archive has no archive URL to clear; link the listing page on this screen. The site root only clears / and /page/N/.
PageCache
enableCache() constructs PageCache and calls register(). Use the class directly when the cache is the only performance feature you want:
use WPB\Performance\PageCache; $cache = new PageCache(3600); $cache->register(); $cache->ttl(1800);
The constructor and enableCache() treat a lifetime below 1 as 3600 seconds. ttl() changes the lifetime used for later reads and ignores a value below 1. register() creates wp-content/cache/wpb/, serves a fresh file at template_redirect, stores the response on shutdown, and clears files when a public post is saved, renamed, or deleted.
OPTION_ENABLED is wpb_page_cache_enabled. OPTION_PAGES_PREFIX is wpb_page_cache_pages_. pagesOption($postType) appends the sanitized post type to that prefix. META_DISABLED is the post meta key wpb_page_cache_disabled. MENU_SLUG is wpb-page-cache.
entries() lists the cached URLs. fresh($url) reports whether that file is still inside the lifetime. flush() deletes every stored file. forgetUrl($url) deletes one URL.
CacheScreen
PageCache::register() constructs CacheScreen, which extends AdminPage. It sets the title to Cache, the slug to wpb-page-cache, and the parent to Tools, then adds the status checkbox and one page select per public post type. cache() returns the PageCache attached to the screen. buildPage() prints views/performance/cache.php for a user who can manage_options.
Security
Security is opt-in request hardening. Same constructor rules as performance.
use WPB\Security\Security; new Security([ 'disableXmlRpc', 'disableUserEnumeration', 'disableFileEdit', ]); (new Security())->enableAll();
disableXmlRpc()returns 403 fromxmlrpc.php, removes XML-RPC methods, drops theX-Pingbackheader, and removes the RSD link.disableUserEnumeration()sends anonymous/?author=1requests to the home URL before the canonical redirect can reveal the user slug. Author archives that already use the slug stay public. Guests receive 401 for/wp/v2/users. Logged-in users keep that route for the editor./wp/v2/users/meis left to WordPress.disableFileEdit()removes the theme file editor and the plugin file editor by denyingedit_themes,edit_plugins, andedit_files. Installing and updating plugins and themes stays available.
HTTP headers
Header
Header collects response headers and applies them on wp_headers. Call set(), setIfAbsent(), and remove() in any order, then register(). The last operation for a name wins. Matching ignores case. This filter runs on the public response. Admin, REST, and admin-ajax do not pass through it.
use WPB\Http\Header; $header = new Header(); $header->remove('X-Pingback'); $header->set('X-Frame-Options', 'SAMEORIGIN'); $header->setIfAbsent('Cache-Control', 'public, max-age=600, stale-while-revalidate=3600'); $header->register();
set() replaces a header already on the response. setIfAbsent() sends the value only when that name is missing, in any capitalization. An empty value is kept. register() hooks wp_headers once. Operations added after that call still apply when WordPress sends the headers. apply() is that filter callback.
A name must not be empty and must not contain a colon or a line break. A value must not contain a line break. Those calls throw InvalidArgumentException.
Csp
Csp builds one Content-Security-Policy and sends it through Header. Call add() for each directive, then register(). Sources for the same directive are merged in first-seen order. A repeated source is kept once. Directive names match without case. An empty source list does not create the directive.
use WPB\Http\Csp; $csp = new Csp(); $csp->add('default-src', ['self']); $csp->add('script-src', ['self', 'https://cdn.example.com']); $csp->add('style-src', ['self', 'unsafe-inline']); $csp->register();
policy() returns that value, with directives separated by ; . Keywords are stored in single quotes: self, none, unsafe-inline, unsafe-eval, strict-dynamic, wasm-unsafe-eval, unsafe-hashes, and report-sample. A nonce (nonce-…) or a hash (sha256-, sha384-, sha512-) is quoted the same way. Passing 'self' is the same as passing self.
register() sets Content-Security-Policy on one Header. A later add() is sent after register() runs again. The second call updates that header and does not add another filter. An empty policy does not register a header.
A directive may contain only letters, numbers, and hyphens. A source must not be empty and must not contain whitespace, a semicolon, or a comma. Those calls throw InvalidArgumentException.
Rate limit
RateLimit counts login attempts, comments, anonymous REST, and admin-ajax. The same object can count any key you choose. Nothing runs until you call a policy or allow().
use WPB\Security\RateLimit; (new RateLimit()) ->login() ->comments() ->rest();
Put the proxy list in wp-config.php. An empty or missing constant trusts no proxy, so the client is REMOTE_ADDR.
define('WPB_TRUSTED_PROXIES', 'private_ranges');
private_ranges is the loopback, link-local, and private network list Symfony uses. A comma-separated list of addresses and CIDR ranges works too. REMOTE_ADDR trusts the peer that opened the connection. trustedProxies() on the instance replaces the constant for that object.
(new RateLimit()) ->trustedProxies(['203.0.113.10']) ->login();
Only X-Forwarded-For is read, and only when REMOTE_ADDR is in the trusted list. The client is the rightmost address in that chain that is not itself a trusted proxy. A forged value to the left of the proxy's entry does not become the client. private_ranges also treats a visitor on a private address as a proxy, so list the proxy address itself when visitors share that network.
Counters live in WordPress transients for one fixed window. The window starts at the first hit and later hits do not extend it. A transient write is not atomic.
login(int $maxAttempts = 5, int $interval = 60)counts failedwp-login.phpattempts. The same username and IP can fail$maxAttemptstimes in the window. That IP can fail5 * $maxAttemptstimes across usernames. A successful login clears both. Password reset allows$maxAttemptsrequests per 15 minutes for that IP. A blocked login stays on the form as aWP_Error. The first call tologin(),comments(), orrest()in the process wins.comments(int $limit = 5, int $window = 600)counts comment submits per IP, including logged-in authors. The submit past the limit ends with 429 andRetry-After.rest(int $limit = 60, int $window = 60)counts anonymous REST andadmin-ajaxrequests on one shared bucket per IP. Logged-in requests are not counted. REST over the limit is aWP_Errorwith status 429 andretry_after.admin-ajaxends the request with 429. Both sendRetry-After.form(string $name, int $limit = 5, int $window = 600)counts one named public form for the current client and returns false when the window is full. Call it before accepting the POST.allow(string $key, int $limit, int $window)counts an exact key and returns false when the window is full.clear(string $key)deletes that bucket. The stored name is an HMAC of the key.
$limit = new RateLimit(); if (!$limit->form('contact', 5, 600)) { // Ask the visitor to wait. }
License
Further notes and older examples live in the project wiki.