WordPress Block Development Review Skill
Overview
Systematic block development review for WordPress 6.x+ block editor (Gutenberg). Core principle: WordPress blocks follow a dual-architecture pattern—React components for the editor (edit function) and either static HTML (save function) or server-side PHP (render_callback/render file) for the frontend. block.json is the single source of truth. Review validates block.json schema, editor patterns (React/JSX), server-side rendering (PHP), attribute handling, deprecation management, and Interactivity API usage. Report findings grouped by file (PHP and JS/JSX files intermixed by actual path) with line numbers, severity labels (CRITICAL/WARNING/INFO), and BAD/GOOD code pairs.
Note: This skill reviews BOTH PHP and JavaScript/React code. PHP follows WordPress PHP Coding Standards (spaces in parentheses, array() not [], Yoda conditions). JavaScript/JSX follows WordPress JS coding standards (tab indentation, JSDoc comments, camelCase for variables/functions, PascalCase for components).
When to Use
Use when:
- Block plugin code review (single block, multi-block, or block library)
- block.json schema validation and field verification
- Editor component review (edit/save functions, React/JSX patterns)
- Render callback or render file audit (server-side PHP)
- InnerBlocks pattern review (nested blocks, template, templateLock)
- Block deprecation check (save function migrations)
- Interactivity API directive review (WP 6.5+ frontend interactions)
- Block attribute schema validation (type, source, selector, default)
- @wordpress/scripts build configuration check
- Block validation error investigation
- useBlockProps, RichText, InspectorControls, BlockControls usage
Don't use for:
- theme.json configuration (use wp-theme-development when available)
- General React application review (this is block-editor-specific)
- WooCommerce block extensions (use wp-woocommerce-dev when available)
- Security-only audits (use wp-security-review for comprehensive security analysis)
- Plugin architecture audits (use wp-plugin-development for plugin structure)
- Performance-only audits (use wp-performance-review)
Code Review Workflow
Follow this seven-step workflow for systematic block reviews:
Identify block type and context
- Single block plugin → One block.json, simple structure
- Multi-block plugin → Multiple blocks in src/block-one/, src/block-two/
- Block library/collection → Published as package, namespacing critical
- Theme blocks → Registered in functions.php, different loading context
Validate block.json schema (BLK-02, BLK-06, BLK-08, BLK-10)
- apiVersion must be 3 (WP 6.3+). Flag 1 or 2 as WARNING with upgrade guidance.
- name must be "namespace/block-name" format (lowercase, letters, numbers, dashes)
- title, category required
- attributes: validate type, source, selector, default values. Flag type mismatches.
- supports: color, spacing, typography, align, anchor, html
- editorScript, script, viewScript, viewScriptModule: validate "file:./path" format
- style, editorStyle: validate paths
- render: "file:./render.php" for dynamic blocks
- $schema field recommended for validation
Check edit function (BLK-05, BLK-20)
- useBlockProps() MUST be called and spread on wrapper element
- Import pattern: @wordpress/* packages (GOOD) vs window.wp.* (BAD - legacy)
- InspectorControls for sidebar settings, BlockControls for toolbar
- RichText proper usage (tagName, value, onChange, allowedFormats)
- InnerBlocks with allowedBlocks and template props
- useSelect/useDispatch from @wordpress/data (combine selectors in single useSelect call)
- i18n: all user-facing strings wrapped in __() from @wordpress/i18n with text domain
Check save function or render callback
- Static blocks: useBlockProps.save() MUST be called, RichText.Content for rich text, InnerBlocks.Content for nested blocks
- Dynamic blocks: save returns null (or InnerBlocks.Content if nested blocks used - BLK-14 CRITICAL)
- Save function must be DETERMINISTIC - no random values, no Date.now(), no side effects
- Render callback/render.php: get_block_wrapper_attributes() for wrapper, escape all output (esc_html, esc_attr, esc_url, wp_kses_post but NOT on $content from InnerBlocks - BLK-21), defined( 'ABSPATH' ) || exit; at top
Scan for CRITICAL patterns
- Missing apiVersion or using 1/2 instead of 3
- window.wp.* instead of @wordpress/* imports in JS/JSX
- save function without useBlockProps.save() (apiVersion 3)
- render callback using wp_kses_post() on $content parameter (breaks embeds)
- Dynamic block with InnerBlocks but save returns null (no InnerBlocks.Content)
- Attribute with source: 'meta' (deprecated - use useEntityProp)
- Invalid attribute type/source combination
- Missing register_block_type() call in PHP
Check WARNING patterns
- block.json missing supports field
- edit function without useBlockProps()
- Hardcoded strings not wrapped in __() in JS/JSX or PHP
- render.php without get_block_wrapper_attributes()
- PHP render files without defined( 'ABSPATH' ) check
- save function with side effects (Math.random, Date.now)
- Missing block.json $schema field
- useSelect with multiple separate calls (performance anti-pattern)
Note INFO improvements
- Using render_callback in PHP instead of render file in block.json
- Missing viewScript/viewScriptModule in block.json (no frontend JS)
- apiVersion 2 (suggest upgrade to 3)
- Missing keywords in block.json
- Not using block hooks for auto-insertion opportunities
Report using output format below. If security concerns found (unescaped render output, user input in Interactivity API state), add note: "Security issues detected. Run /wp-sec-review for comprehensive security analysis." If plugin architecture issues found (init hook registration, ABSPATH check missing), add note: "Plugin architecture issues detected. Run /wp-plugin-review for comprehensive plugin review."
Source vs Build Review: Review src/ files for code patterns (developer intent). Flag if build/ directory is missing or stale (check index.asset.php timestamp vs src/ modification times). Do NOT review build/ files for code quality - they are compiled output.
File-Type Specific Checks
block.json (BLK-02, BLK-06, BLK-08, BLK-10)
apiVersion field:
- CRITICAL: Missing apiVersion → Block won't register correctly
- WARNING: apiVersion 1 or 2 → Upgrade to 3 for iframe isolation (WP 6.3+)
- Pattern:
"apiVersion": 3
name field:
- CRITICAL: Missing name → Block won't register
- CRITICAL: Invalid format (not "namespace/block-name") → Registration fails
- Pattern:
"name": "my-plugin/my-block"(lowercase, dashes, letters, numbers)
attributes field:
- CRITICAL: Invalid type (not string/number/boolean/object/array/integer/null) → Validation fails
- CRITICAL: source:'meta' → Deprecated, use useEntityProp hook instead
- WARNING: type doesn't match source data type → Attribute won't populate correctly
- Pattern: Validate type, source, selector, default combinations
- Sources: attribute, text, html, query (meta deprecated)
supports field:
- WARNING: Missing supports → Users can't customize color/spacing/typography
- INFO: Could add common supports (color, spacing, typography, align, anchor)
- Pattern: Object with nested configuration for each support type
editorScript/script/viewScript/viewScriptModule fields:
- WARNING: Invalid path format → Assets won't load
- Pattern:
"file:./index.js"relative to block.json location - Note: viewScriptModule for Interactivity API (WP 6.5+)
render field:
- INFO: Dynamic blocks can use render file instead of render_callback
- Pattern:
"file:./render.php"relative to block.json location
$schema field:
- INFO: Missing $schema → Can't validate schema in IDE
- Pattern:
"$schema": "https://schemas.wp.org/trunk/block.json"
Edit function / edit.js (BLK-05, BLK-20)
useBlockProps usage:
- CRITICAL: useBlockProps() not called → Block won't render properly in editor
- CRITICAL: useBlockProps result not spread on wrapper → Missing block classes/attributes
- Pattern:
const blockProps = useBlockProps();then<div { ...blockProps }>
Import patterns:
- WARNING: window.wp.* global access → Legacy pattern, breaks modern builds
- Pattern: GOOD:
import { useBlockProps } from '@wordpress/block-editor';BAD:const { useBlockProps } = window.wp.blockEditor;
InspectorControls and BlockControls:
- INFO: Could add settings sidebar with InspectorControls
- INFO: Could add toolbar controls with BlockControls
- Pattern: InspectorControls for PanelBody/ToggleControl/SelectControl, BlockControls for AlignmentToolbar/ToolbarGroup
RichText usage:
- WARNING: RichText without tagName → May render incorrectly
- WARNING: RichText without value/onChange → Not controlled component
- Pattern:
<RichText tagName="p" value={ attributes.content } ( content ) => setAttributes( { content } ) } />
InnerBlocks usage:
- INFO: Consider allowedBlocks to restrict nesting
- INFO: Consider template for default block structure
- Pattern:
<InnerBlocks allowedBlocks={ [ 'core/paragraph' ] } template={ [ [ 'core/heading' ] ] } />
useSelect/useDispatch performance:
- WARNING: Multiple separate useSelect calls → Performance degradation with many blocks
- Pattern: Combine selectors in single useSelect when reading multiple values
Internationalization:
- WARNING: Hardcoded strings without __() → Not translatable
- Pattern:
__( 'Text', 'text-domain' )for all user-facing strings
Save function / save.js (BLK-05, BLK-07)
Static blocks (save returns JSX):
- CRITICAL: Missing useBlockProps.save() → Block validation error (apiVersion 3)
- WARNING: RichText without RichText.Content → Won't save rich text correctly
- WARNING: InnerBlocks without InnerBlocks.Content → Nested blocks won't save
- Pattern:
const blockProps = useBlockProps.save();then<div { ...blockProps }>
Dynamic blocks (save returns null or InnerBlocks.Content):
- CRITICAL: Dynamic block with InnerBlocks but save returns null → Nested blocks lost (BLK-14)
- Pattern: If block uses InnerBlocks, save MUST return
<InnerBlocks.Content />even for dynamic blocks
Deterministic save:
- WARNING: Math.random() or Date.now() in save → Block validation errors on re-save
- WARNING: Side effects in save → Unpredictable behavior
- Pattern: Save must return identical markup for identical attributes
Render callback / render.php (BLK-11)
ABSPATH check:
- WARNING: Missing defined( 'ABSPATH' ) || exit; → Direct file access possible
- Pattern: First line after <?php should be ABSPATH check
- Cross-reference: See wp-security-review for security depth
get_block_wrapper_attributes():
- WARNING: Manual class concatenation instead of get_block_wrapper_attributes() → Missing block supports
- Pattern:
$wrapper_attributes = get_block_wrapper_attributes();then<div <?php echo $wrapper_attributes; ?>>
Output escaping:
- CRITICAL: Unescaped output → XSS vulnerability (CWE-79)
- CRITICAL: wp_kses_post() on $content from InnerBlocks → Breaks embeds (BLK-21)
- Pattern: esc_html() for text, esc_attr() for attributes, esc_url() for URLs, wp_kses_post() for trusted HTML (but NOT InnerBlocks $content)
- Cross-reference: See wp-security-review for comprehensive escaping patterns
Function parameters:
- $attributes: Array of block attributes
- $content: InnerBlocks HTML (if InnerBlocks used)
- $block: WP_Block instance with context and other metadata
- Pattern: Use all three parameters for full dynamic block functionality
Plugin main file / plugin.php
register_block_type():
- CRITICAL: Missing register_block_type() → Block won't register
- WARNING: Not hooked to 'init' → May register too early
- Pattern:
add_action( 'init', 'callback' );thenregister_block_type( __DIR__ . '/build/block-name' );
Multi-block registration:
- WARNING: Single register_block_type() for multi-block plugin → Other blocks won't register
- Pattern: Call register_block_type() for each block's build directory
Plugin header and ABSPATH:
- Cross-reference: See wp-plugin-development for plugin header requirements and ABSPATH check
Block deprecation (BLK-09)
deprecated array:
- CRITICAL: Save function changed without deprecation → Block validation errors for existing content
- WARNING: Missing apiVersion in deprecation entry → Deprecation may not match
- Pattern: deprecated array with save + attributes for each version
migrate function:
- INFO: Could add migrate function for attribute schema changes
- Pattern:
migrate( attributes ) { return { ...attributes, newField: 'default' }; }
Ordering:
- WARNING: Deprecations ordered oldest first → WordPress checks newest first, performance issue
- Pattern: Newest deprecation listed FIRST in array
Interactivity API files (BLK-12)
WP 6.5+ version marker:
- INFO: Interactivity API requires WordPress 6.5+ → Add version check or plugin requirement
- Pattern: Check
Requires at least: 6.5in plugin header
render.php directives:
- data-wp-interactive="namespace" → REQUIRED for Interactivity API scope
- wp_interactivity_state() → Server-side state initialization
- wp_interactivity_config() → Server-side configuration
- data-wp-context → Context provider for nested elements
- Directives: data-wp-bind, data-wp-on, data-wp-class, data-wp-style, data-wp-text, data-wp-watch, data-wp-init, data-wp-each
view.js (frontend store):
- WARNING: Import from window.wp.interactivity → Legacy pattern, use @wordpress/interactivity
- Pattern:
import { store } from '@wordpress/interactivity';thenstore( 'namespace', { actions, state } );
Key distinction:
- Interactivity API = FRONTEND ONLY (view.js, render.php directives)
- Editor still uses React (edit.js)
- Don't confuse frontend state management with editor component state
Security crossover:
- WARNING: User input in wp_interactivity_state() without sanitization → XSS risk
- Cross-reference: See wp-security-review for input sanitization patterns
Block context (BLK-16)
providesContext (parent block):
- Pattern: In parent block.json:
"providesContext": { "myPlugin/keyName": "attributeName" } - Namespace context keys: Use "myPlugin/keyName" format
usesContext (child block):
- Pattern: In child block.json:
"usesContext": [ "myPlugin/keyName" ] - Access in edit:
function Edit( { context } ) { const value = context['myPlugin/keyName']; }
Surface-level checks
Block transforms (BLK-13 partial):
- INFO: Could add transforms for conversion from/to other blocks
- Pattern: from/to array in block registration
Block variations (BLK-13):
- INFO: Could add variations for preset configurations
- Pattern: variation picker, isDefault, scope properties
Block styles:
- INFO: Could register style variations in block.json or PHP
- Pattern: styles array in block.json or register_block_style() in PHP
Block patterns (BLK-18):
- Pattern: register_block_pattern() structure, categories, keywords
- Note: Patterns are multi-block layouts, different from variations (single block presets)
Block hooks (BLK-19):
- INFO: Could use blockHooks for auto-insertion (WP 6.4+)
- CRITICAL: Block hooks only work with dynamic blocks (save returns null)
- Pattern: blockHooks property in block.json
Block Bindings API (BLK-17):
- INFO: Could use bindings for dynamic attribute connections (WP 6.5+)
- Pattern: Surface-level detection only
@wordpress/scripts build config:
- WARNING: Missing @wordpress/scripts → No build toolchain
- WARNING: package.json missing build/start scripts → Can't compile blocks
- Pattern: Check package.json for "scripts": { "build": "wp-scripts build", "start": "wp-scripts start" }
Template lock:
- INFO: Could use templateLock on InnerBlocks for fixed layouts
- Pattern:
<InnerBlocks templateLock="all" />prevents block addition/removal/reordering
Search Patterns for Quick Detection (BLK-22)
Use these rg commands for quick block scanning. Organized by severity. Cover BOTH PHP and JS/JSX files.
CRITICAL Patterns
# block.json without apiVersion 3
rg -l --iglob 'block.json' . | xargs -I{} sh -c "rg -q '\"apiVersion\"\\s*:\\s*3' '{}' || echo '{}'"
# JS/JSX files with window.wp.* instead of @wordpress/* imports
rg -n "window\.wp\." . -g '*.{js,jsx}'
# save function without useBlockProps.save() in JSX files (manual candidate list)
rg -n "function save|const save\s*=|save:\s*\(" . -g '*.{js,jsx}'
# render callback using wp_kses_post on $content parameter (breaks embeds)
rg -n "wp_kses_post\s*\(\s*\$content\s*\)" . -g '*.php'
# Dynamic block with InnerBlocks but save returns null
# Manual check: if edit.js uses InnerBlocks, save.js should include InnerBlocks.Content.
rg -n "InnerBlocks" . -g '*.{js,jsx}'
rg -n "return\s+null" . -g '*.{js,jsx}'
# Attribute with source: 'meta' (deprecated)
rg -n "\"source\"\s*:\s*\"meta\"" . -g 'block.json'
# Missing register_block_type() call in plugin bootstrap files
rg -n "register_block_type\s*\(" . -g '*.php'
WARNING Patterns
# block.json missing supports field
rg -l --iglob 'block.json' . | xargs -I{} sh -c "rg -q '\"supports\"' '{}' || echo '{}'"
# edit function without useBlockProps() in JS/JSX files (manual candidate list)
rg -n "function Edit|export default function Edit|const Edit\s*=" . -g '*.{js,jsx}'
# Hardcoded strings not wrapped in __() in JS/JSX files
rg -n "title\s*:\s*['\"][A-Z]" . -g '*.{js,jsx}'
# render.php without get_block_wrapper_attributes()
rg -l --iglob 'render.php' . | xargs -I{} sh -c "rg -q 'get_block_wrapper_attributes' '{}' || echo '{}'"
# PHP render files without defined( 'ABSPATH' ) check
rg -l --iglob 'render.php' . | xargs -I{} sh -c "rg -q 'defined.*ABSPATH' '{}' || echo '{}'"
# save function with side effects (Math.random, Date.now)
rg -n "Math\.random|Date\.now|new Date\(" . -g 'save.js'
# Missing block.json $schema field
rg -l --iglob 'block.json' . | xargs -I{} sh -c "rg -q '\"\\$schema\"' '{}' || echo '{}'"
# useSelect with multiple separate calls (performance anti-pattern)
# Manual check: inspect files with repeated useSelect() calls.
rg -n "useSelect\s*\(" . -g '*.{js,jsx}'
INFO Patterns
# Using render_callback in PHP instead of render file in block.json
grep -rn "render_callback" --include="*.php" .
# Missing viewScript/viewScriptModule in block.json (no frontend JS)
grep -L "viewScript\|viewScriptModule" --include="block.json" .
# apiVersion 2 (suggest upgrade to 3)
grep -rn "\"apiVersion\": 2" --include="block.json" .
# Missing keywords in block.json
grep -L "\"keywords\"" --include="block.json" .
# Not using block hooks for auto-insertion opportunities
# (Manual check: Review block purpose for auto-insertion potential)
Note: Grep patterns for JavaScript require different regex than PHP patterns. Use --include="*.js" --include="*.jsx" for JS files, --include="*.php" for PHP files.
Block Context Detection
Context-aware review notes based on block distribution and loading context:
Single block plugin
Structure: One block.json at root or src/, single build output Review adjustments: Standard review, no special considerations Pattern: Plugin folder contains single src/ directory with index.js, edit.js, save.js
Multi-block plugin
Structure: Multiple blocks in src/block-one/, src/block-two/, each with block.json Review adjustments:
- Check shared components in src/shared/ or src/components/
- Verify consistent naming conventions across all blocks
- Ensure build config handles all blocks (multiple entry points or wildcard)
- Verify register_block_type() called for each block's build directory Pattern: Plugin folder contains multiple src/*/block.json files
Block library/collection
Structure: Published as npm package or WordPress.org plugin with multiple blocks Review adjustments:
- Namespacing CRITICAL (all blocks must use consistent namespace)
- Check for consistent naming: "namespace/block-one", "namespace/block-two"
- Verify all blocks in same namespace
- Package.json should have "main" field pointing to build entry Pattern: package.json has "name" field, published to npm or WordPress.org
Theme blocks
Structure: Registered in functions.php, block files in /blocks/ or /inc/blocks/ Review adjustments:
- Different loading context (theme activation vs plugin activation)
- Block may depend on theme.json settings (colors, spacing, typography)
- Brief note on theme.json integration, defer depth to Phase 4 (Theme Development)
- Verify blocks registered on 'init' or 'after_setup_theme' hook Pattern: register_block_type() in functions.php, blocks in theme subdirectory
Quick Reference: Block Development Patterns (BLK-23)
Common block patterns organized by concern. All examples use WordPress coding standards (PHP: spaces in parentheses, array() not [], Yoda conditions; JS: tab indentation, camelCase, PascalCase for components).
block.json Schema
Complete schema with all fields annotated (apiVersion 3):
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "my-plugin/my-block",
"title": "My Block",
"category": "widgets",
"icon": "smiley",
"description": "A custom block example",
"keywords": [ "custom", "example" ],
"version": "1.0.0",
"textdomain": "my-plugin",
"attributes": {
"content": {
"type": "string",
"source": "html",
"selector": "p",
"default": ""
},
"showImage": {
"type": "boolean",
"default": false
}
},
"supports": {
"html": false,
"color": {
"background": true,
"text": true
},
"spacing": {
"margin": true,
"padding": true
},
"typography": {
"fontSize": true,
"lineHeight": true
},
"align": [ "wide", "full" ],
"anchor": true
},
"editorScript": "file:./index.js",
"editorStyle": "file:./index.css",
"style": "file:./style-index.css",
"viewScriptModule": "file:./view.js"
}
Block Registration
PHP + JS registration pattern:
❌ BAD: Missing ABSPATH check, not hooked to init
<?php
function my_plugin_register_block() {
register_block_type( __DIR__ . '/build/my-block' );
}
my_plugin_register_block(); // Runs immediately, not on init
✅ GOOD: Complete registration with ABSPATH and init hook
<?php
defined( 'ABSPATH' ) || exit;
function my_plugin_register_blocks() {
register_block_type( __DIR__ . '/build/my-block' );
}
add_action( 'init', 'my_plugin_register_blocks' );
✅ GOOD: Multi-block registration
<?php
defined( 'ABSPATH' ) || exit;
function my_plugin_register_blocks() {
register_block_type( __DIR__ . '/build/block-one' );
register_block_type( __DIR__ . '/build/block-two' );
register_block_type( __DIR__ . '/build/block-three' );
}
add_action( 'init', 'my_plugin_register_blocks' );
Static Block Pattern
Edit with useBlockProps, save with useBlockProps.save, RichText + RichText.Content:
❌ BAD: Missing useBlockProps in edit and save
// edit.js
export default function Edit( { attributes, setAttributes } ) {
return (
<div className="my-block">
<input
value={ attributes.content }
( e ) => setAttributes( { content: e.target.value } ) }
/>
</div>
);
}
// save.js
export default function save( { attributes } ) {
return (
<div className="my-block">
<p>{ attributes.content }</p>
</div>
);
}
✅ GOOD: Complete static block with useBlockProps and RichText
// edit.js
import { useBlockProps, RichText } from '@wordpress/block-editor';
export default function Edit( { attributes, setAttributes } ) {
const blockProps = useBlockProps();
return (
<div { ...blockProps }>
<RichText
tagName="p"
value={ attributes.content }
( content ) => setAttributes( { content } ) }
placeholder="Enter content..."
/>
</div>
);
}
// save.js
import { useBlockProps, RichText } from '@wordpress/block-editor';
export default function save( { attributes } ) {
const blockProps = useBlockProps.save();
return (
<div { ...blockProps }>
<RichText.Content tagName="p" value={ attributes.content } />
</div>
);
}
Dynamic Block Pattern
Edit with useBlockProps, save returns null (or InnerBlocks.Content), render.php with get_block_wrapper_attributes and escaping:
❌ BAD: render callback without wrapper attributes, unescaped output
<?php
function my_plugin_render_callback( $attributes ) {
return '<div><p>' . $attributes['content'] . '</p></div>';
}
✅ GOOD: Complete dynamic block with proper escaping
<?php
// render.php
defined( 'ABSPATH' ) || exit;
$wrapper_attributes = get_block_wrapper_attributes();
?>
<div <?php echo $wrapper_attributes; ?>>
<p><?php echo esc_html( $attributes['content'] ); ?></p>
</div>
// save.js - Dynamic blocks return null
export default function save() {
return null;
}
✅ GOOD: Dynamic block registered with render file
<?php
// plugin.php
defined( 'ABSPATH' ) || exit;
function my_plugin_register_blocks() {
register_block_type( __DIR__ . '/build/my-block' );
// render file specified in block.json "render": "file:./render.php"
}
add_action( 'init', 'my_plugin_register_blocks' );
InnerBlocks Pattern
allowedBlocks, template, templateLock, InnerBlocks.Content in save (even for dynamic blocks):
❌ BAD: Dynamic block with InnerBlocks but save returns null
// edit.js
import { useBlockProps, InnerBlocks } from '@wordpress/block-editor';
export default function Edit() {
const blockProps = useBlockProps();
return (
<div { ...blockProps }>
<InnerBlocks />
</div>
);
}
// save.js - CRITICAL: Nested blocks will be lost!
export default function save() {
return null;
}
✅ GOOD: Dynamic block with InnerBlocks saves nested content
// edit.js
import { useBlockProps, InnerBlocks } from '@wordpress/block-editor';
export default function Edit() {
const blockProps = useBlockProps();
const ALLOWED_BLOCKS = [ 'core/paragraph', 'core/image', 'core/heading' ];
const TEMPLATE = [
[ 'core/heading', { placeholder: 'Card Title' } ],
[ 'core/paragraph', { placeholder: 'Card content...' } ],
];
return (
<div { ...blockProps }>
<InnerBlocks
allowedBlocks={ ALLOWED_BLOCKS }
template={ TEMPLATE }
templateLock={ false }
/>
</div>
);
}
// save.js - MUST save InnerBlocks.Content even for dynamic blocks
import { InnerBlocks } from '@wordpress/block-editor';
export default function save() {
return <InnerBlocks.Content />;
}
✅ GOOD: render.php uses $content parameter for InnerBlocks HTML
<?php
// render.php
defined( 'ABSPATH' ) || exit;
$wrapper_attributes = get_block_wrapper_attributes();
?>
<div <?php echo $wrapper_attributes; ?>>
<h3><?php echo esc_html( $attributes['title'] ); ?></h3>
<?php echo $content; // InnerBlocks HTML - DO NOT use wp_kses_post() ?>
</div>
useBlockProps
edit (useBlockProps()) and save (useBlockProps.save()), merging custom props:
❌ BAD: Missing useBlockProps
export default function Edit() {
return <div className="my-block">Content</div>;
}
function save() {
return <div className="my-block">Content</div>;
}
✅ GOOD: useBlockProps with custom props merged
import { useBlockProps } from '@wordpress/block-editor';
export default function Edit( { attributes } ) {
const blockProps = useBlockProps( {
className: 'my-custom-class',
style: { backgroundColor: attributes.bgColor },
} );
return <div { ...blockProps }>Content</div>;
}
function save( { attributes } ) {
const blockProps = useBlockProps.save( {
className: 'my-custom-class',
style: { backgroundColor: attributes.bgColor },
} );
return <div { ...blockProps }>Content</div>;
}
Attributes
type/source/selector/default patterns, attribute type matching:
❌ BAD: Type mismatch, no default, deprecated source
{
"attributes": {
"count": {
"type": "number",
"source": "text",
"selector": ".count"
},
"metaValue": {
"type": "string",
"source": "meta",
"meta": "my_meta_key"
}
}
}
✅ GOOD: Correct type/source matching, defaults, modern patterns
{
"attributes": {
"content": {
"type": "string",
"source": "html",
"selector": "p",
"default": ""
},
"count": {
"type": "number",
"source": "attribute",
"selector": ".count",
"attribute": "data-count",
"default": 0
},
"url": {
"type": "string",
"source": "attribute",
"selector": "a",
"attribute": "href",
"default": ""
},
"items": {
"type": "array",
"source": "query",
"selector": "li",
"query": {
"text": {
"type": "string",
"source": "text"
}
},
"default": []
}
}
}
✅ GOOD: Meta attribute using useEntityProp (not deprecated source)
import { useEntityProp } from '@wordpress/core-data';
export default function Edit() {
const [ meta, setMeta ] = useEntityProp( 'postType', 'post', 'meta' );
const value = meta.my_meta_key;
const updateMeta = ( newValue ) => {
setMeta( { ...meta, my_meta_key: newValue } );
};
// Use value and updateMeta in component
}
Block Controls
InspectorControls for sidebar, BlockControls for toolbar, PanelBody, ToggleControl, SelectControl:
❌ BAD: Settings hardcoded in block markup, no sidebar controls
export default function Edit( { attributes, setAttributes } ) {
return (
<div>
<label>
Show Image:
<input
type="checkbox"
checked={ attributes.showImage }
( e ) => setAttributes( { showImage: e.target.checked } ) }
/>
</label>
</div>
);
}
✅ GOOD: InspectorControls for sidebar, BlockControls for toolbar
import { useBlockProps, InspectorControls, BlockControls, AlignmentToolbar } from '@wordpress/block-editor';
import { PanelBody, ToggleControl, SelectControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
export default function Edit( { attributes, setAttributes } ) {
const blockProps = useBlockProps();
return (
<>
<BlockControls>
<AlignmentToolbar
value={ attributes.align }
( align ) => setAttributes( { align } ) }
/>
</BlockControls>
<InspectorControls>
<PanelBody title={ __( 'Settings', 'my-plugin' ) }>
<ToggleControl
label={ __( 'Show featured image', 'my-plugin' ) }
checked={ attributes.showImage }
( showImage ) => setAttributes( { showImage } ) }
/>
<SelectControl
label={ __( 'Display style', 'my-plugin' ) }
value={ attributes.style }
options={ [
{ label: 'Default', value: 'default' },
{ label: 'Card', value: 'card' },
{ label: 'List', value: 'list' },
] }
( style ) => setAttributes( { style } ) }
/>
</PanelBody>
</InspectorControls>
<div { ...blockProps }>
{/* Block content */}
</div>
</>
);
}
Block Deprecation
deprecated array, save + attributes + migrate, apiVersion in entries, newest first:
❌ BAD: Save function changed without deprecation
// Changed save markup - existing blocks will show validation error
function save( { attributes } ) {
const blockProps = useBlockProps.save();
return <div { ...blockProps }>{ attributes.content }</div>;
}
✅ GOOD: Deprecation entry for old save function
import { useBlockProps } from '@wordpress/block-editor';
const deprecated = [
{
// Version 2: Added useBlockProps (newest deprecation first)
apiVersion: 2,
attributes: {
content: { type: 'string' },
},
save( { attributes } ) {
return <div className="my-block">{ attributes.content }</div>;
},
},
{
// Version 1: Original block
apiVersion: 2,
attributes: {
content: { type: 'string' },
},
save( { attributes } ) {
return <p>{ attributes.content }</p>;
},
migrate( attributes ) {
// Transform old attributes to new schema
return {
...attributes,
newField: 'default',
};
},
},
];
// Current version
function save( { attributes } ) {
const blockProps = useBlockProps.save();
return <div { ...blockProps }>{ attributes.content }</div>;
}
export default {
edit: Edit,
save,
deprecated,
};
Interactivity API
data-wp-interactive, data-wp-bind, data-wp-on, store(), wp_interactivity_state(), frontend-only emphasis:
❌ BAD: Custom JavaScript for frontend interaction
<?php
// render.php
?>
<div class="my-block">
<button id="toggle-button">Toggle</button>
<div id="content" style="display: none;">Hidden content</div>
</div>
<script>
document.getElementById('toggle-button').addEventListener('click', function() {
var content = document.getElementById('content');
content.style.display = content.style.display === 'none' ? 'block' : 'none';
});
</script>
✅ GOOD: Interactivity API with directives (WP 6.5+)
<?php
// render.php
defined( 'ABSPATH' ) || exit;
wp_interactivity_state( 'myPlugin', array(
'isOpen' => false,
) );
$wrapper_attributes = get_block_wrapper_attributes();
?>
<div
<?php echo $wrapper_attributes; ?>
data-wp-interactive="myPlugin"
data-wp-context='{ "id": "<?php echo esc_attr( uniqid() ); ?>" }'
>
<button data-wp-on--click="actions.toggle">
<?php esc_html_e( 'Toggle', 'my-plugin' ); ?>
</button>
<div data-wp-bind--hidden="!state.isOpen">
<p><?php esc_html_e( 'Hidden content', 'my-plugin' ); ?></p>
</div>
</div>
// view.js - Frontend store (NOT editor code)
import { store } from '@wordpress/interactivity';
store( 'myPlugin', {
state: {
isOpen: false,
},
actions: {
toggle: ( { state } ) => {
state.isOpen = ! state.isOpen;
},
},
} );
block.json configuration:
{
"viewScriptModule": "file:./view.js"
}
Note: Interactivity API is for FRONTEND only. Editor still uses React (edit.js).
Import Patterns
@wordpress/* package imports vs window.wp.* legacy access:
❌ BAD: window.wp. global access (legacy)*
const { registerBlockType } = window.wp.blocks;
const { useBlockProps } = window.wp.blockEditor;
const { __ } = window.wp.i18n;
✅ GOOD: @wordpress/ package imports (modern)*
import { registerBlockType } from '@wordpress/blocks';
import { useBlockProps } from '@wordpress/block-editor';
import { __ } from '@wordpress/i18n';
Block Supports
color, spacing, typography, align, anchor in block.json:
❌ BAD: Missing supports field
{
"name": "my-plugin/my-block",
"title": "My Block"
}
✅ GOOD: Complete supports configuration
{
"name": "my-plugin/my-block",
"title": "My Block",
"supports": {
"html": false,
"color": {
"background": true,
"text": true,
"gradients": true,
"link": true
},
"spacing": {
"margin": true,
"padding": true,
"blockGap": true
},
"typography": {
"fontSize": true,
"lineHeight": true,
"fontFamily": true,
"fontWeight": true
},
"align": [ "wide", "full" ],
"anchor": true
}
}
Block Variations
variation objects with attributes and innerBlocks:
import { registerBlockVariation } from '@wordpress/blocks';
registerBlockVariation( 'core/columns', {
name: 'three-columns-equal',
title: 'Three Columns (Equal)',
description: 'Three columns with equal width',
icon: 'columns',
isDefault: false,
scope: [ 'block' ],
attributes: {
columns: 3,
},
innerBlocks: [
[ 'core/column' ],
[ 'core/column' ],
[ 'core/column' ],
],
} );
Block Transforms
from/to patterns:
import { createBlock } from '@wordpress/blocks';
export default {
edit: Edit,
save,
transforms: {
from: [
{
type: 'block',
blocks: [ 'core/paragraph' ],
transform: ( { content } ) => {
return createBlock( 'my-plugin/my-block', {
content,
} );
},
},
],
to: [
{
type: 'block',
blocks: [ 'core/paragraph' ],
transform: ( { content } ) => {
return createBlock( 'core/paragraph', {
content,
} );
},
},
],
},
};
Block Context
providesContext/usesContext:
// Parent block.json
{
"providesContext": {
"myPlugin/userId": "userId",
"myPlugin/userName": "userName"
},
"attributes": {
"userId": { "type": "number" },
"userName": { "type": "string" }
}
}
// Child block.json
{
"usesContext": [ "myPlugin/userId", "myPlugin/userName" ]
}
// Child edit.js
export default function Edit( { context } ) {
const userId = context['myPlugin/userId'];
const userName = context['myPlugin/userName'];
return (
<div>
<p>User: { userName } ({ userId })</p>
</div>
);
}
Block Hooks
blockHooks with dynamic blocks only:
❌ BAD: Block hooks with static block
{
"blockHooks": {
"core/post-content": "after"
}
}
// save.js - Static save = block hooks won't work
function save() {
return <div>Content</div>;
}
✅ GOOD: Block hooks with dynamic block
{
"blockHooks": {
"core/post-content": "after"
},
"render": "file:./render.php"
}
// save.js - Dynamic block (null save) = block hooks work
function save() {
return null;
}
@wordpress/scripts
package.json scripts configuration:
❌ BAD: Missing build scripts
{
"scripts": {
"test": "echo \"No build toolchain configured\""
}
}
✅ GOOD: Complete @wordpress/scripts configuration
{
"scripts": {
"build": "wp-scripts build",
"start": "wp-scripts start",
"lint:js": "wp-scripts lint-js",
"format": "wp-scripts format",
"packages-update": "wp-scripts packages-update"
},
"devDependencies": {
"@wordpress/scripts": "^27.0.0"
}
}
Severity Definitions (BLK-24)
| Severity | Definition | Examples |
|---|---|---|
| CRITICAL | Block won't render OR crashes editor OR causes block validation error | Missing apiVersion or using apiVersion 1/2 without migration path, invalid attribute type causing save validation error, save function mismatch without deprecation entry, missing useBlockProps.save() in apiVersion 3 block, InnerBlocks in edit but save returns null (not InnerBlocks.Content), wp_kses_post() on InnerBlocks $content parameter, window.wp.* access breaking modern build, missing register_block_type() call, deprecated source:'meta' attribute pattern |
| WARNING | Block works but has quality/compatibility issues OR non-standard patterns | Deprecated API usage (apiVersion 1/2 without critical issues), missing block supports (color, spacing, typography), no deprecation handler for changed save function, hardcoded strings without i18n in JS/JSX or PHP, multiple separate useSelect calls (performance issue), missing get_block_wrapper_attributes() in render.php, missing defined( 'ABSPATH' ) in PHP files, save function with side effects (Math.random, Date.now) |
| INFO | Best practice improvements OR optimization opportunities | Could use render PHP file instead of render_callback function, missing viewScript/viewScriptModule for frontend interactions, not using block.json $schema field, apiVersion 2 (upgrade to 3 available), missing keywords in block.json, block hooks opportunity for auto-insertion, could add block variations for common presets, missing template lock on fixed InnerBlocks layouts |
Output Format (BLK-24)
Report findings grouped by FILE (PHP and JS/JSX files intermixed by actual file path), with line numbers and severity labels. Use BAD/GOOD code pairs for each finding.
…(truncated)