WordPress custom Gutenberg block development with server-side PHP rendering. Includes block registration patterns, media upload integration, multiple item blocks, proper escaping/sanitization, and editor UI best practices. Maintains separation of concerns where editors control content while developers control design.
Build custom Gutenberg blocks for WordPress themes that give content editors control over text and media while developers retain control over layout and design.
Editors Control:
Developers Control:
Benefits:
Each block consists of three files:
inc/blocks/
├── block-name.php # PHP registration and render
├── js/
│ └── block-name.js # Editor JavaScript
└── css/ # Optional
└── block-name.css # Block-specific styles
Register block in functions.php:
// custom gutenberg blocks
require get_template_directory() . '/inc/blocks/hp-lede.php';
File: /inc/blocks/block-name.php
<?php
/**
* Block Name Block
*/
function register_block_name_block() {
register_block_type('theme/block-name', array(
'render_callback' => 'render_block_name_block',
'attributes' => array(
'blockTitle' => array(
'type' => 'string',
'default' => 'Default Title'
),
'blockDescription' => array(
'type' => 'string',
'default' => 'Default description text.'
),
'blockLink' => array(
'type' => 'string',
'default' => '/default-link/'
),
),
));
}
add_action('init', 'register_block_name_block');
function render_block_name_block($attributes) {
// Sanitize and escape all attributes
$block_title = isset($attributes['blockTitle']) ? esc_html($attributes['blockTitle']) : '';
$block_description = isset($attributes['blockDescription']) ? esc_html($attributes['blockDescription']) : '';
$block_link = isset($attributes['blockLink']) ? esc_url($attributes['blockLink']) : '';
ob_start();
?>
<section class="block-name">
<h2><?php echo $block_title; ?></h2>
<p><?php echo $block_description; ?></p>
<a href="<?php echo $block_link; ?>" class="button">Learn More</a>
</section>
<?php
return ob_get_clean();
}
function enqueue_block_name_block_editor_assets() {
wp_enqueue_script(
'block-name-block',
get_template_directory_uri() . '/inc/blocks/js/block-name.js',
array('wp-blocks', 'wp-element', 'wp-editor', 'wp-components'),
filemtime(get_template_directory() . '/inc/blocks/js/block-name.js'),
false
);
}
add_action('enqueue_block_editor_assets', 'enqueue_block_name_block_editor_assets');
File: /inc/blocks/js/block-name.js
(function(wp) {
const { registerBlockType } = wp.blocks;
const { TextControl, TextareaControl } = wp.components;
const { createElement: el } = wp.element;
registerBlockType('theme/block-name', {
title: 'Block Name',
icon: 'admin-post',
category: 'common',
attributes: {
blockTitle: {
type: 'string',
default: 'Default Title'
},
blockDescription: {
type: 'string',
default: 'Default description text.'
},
blockLink: {
type: 'string',
default: '/default-link/'
}
},
edit: function(props) {
const { attributes, setAttributes } = props;
return el('div', {
className: 'block-name-editor',
style: { padding: '20px', border: '1px solid #ddd' }
},
el('h3', {}, 'Block Name'),
el(TextControl, {
label: 'Block Title',
value: attributes.blockTitle,
onChange: function(value) {
setAttributes({ blockTitle: value });
}
}),
el(TextareaControl, {
label: 'Description',
value: attributes.blockDescription,
onChange: function(value) {
setAttributes({ blockDescription: value });
},
rows: 4
}),
el(TextControl, {
label: 'Link',
value: attributes.blockLink,
onChange: function(value) {
setAttributes({ blockLink: value });
}
})
);
},
save: function() {
return null; // Using PHP render callback
}
});
})(window.wp);
PHP:
'textField' => array(
'type' => 'string',
'default' => 'Default text'
),
JavaScript:
el(TextControl, {
label: 'Text Field',
value: attributes.textField,
onChange: function(value) {
setAttributes({ textField: value });
}
})
PHP:
'textareaField' => array(
'type' => 'string',
'default' => 'Default longer text'
),
JavaScript:
el(TextareaControl, {
label: 'Textarea Field',
value: attributes.textareaField,
onChange: function(value) {
setAttributes({ textareaField: value });
},
rows: 6
})
PHP:
'numberField' => array(
'type' => 'number',
'default' => 0
),
JavaScript:
el(TextControl, {
label: 'Number Field',
type: 'number',
value: attributes.numberField,
onChange: function(value) {
setAttributes({ numberField: parseInt(value) });
}
})
PHP:
'imageId' => array(
'type' => 'number',
'default' => 0
),
'imageUrl' => array(
'type' => 'string',
'default' => ''
),
const { MediaUpload, MediaUploadCheck } = wp.blockEditor;
const { Button } = wp.components;
// In edit function:
el(MediaUploadCheck, {},
el(MediaUpload, {
onSelect: function(media) {
setAttributes({
imageId: media.id,
imageUrl: media.url
});
},
allowedTypes: ['image'],
value: attributes.imageId,
render: function(obj) {
return el('div', { className: 'media-upload-wrapper' },
attributes.imageUrl ?
el('div', {},
el('img', {
src: attributes.imageUrl,
style: { maxWidth: '200px', display: 'block', marginBottom: '10px' }
}),
el(Button, {
onClick: obj.open,
className: 'button'
}, 'Change Image'),
el(Button, {
onClick: function() {
setAttributes({
imageId: 0,
imageUrl: ''
});
},
className: 'button',
style: { marginLeft: '10px' }
}, 'Remove')
) :
el(Button, {
onClick: obj.open,
className: 'button button-primary'
}, 'Upload Image')
);
}
})
)
// Get image URL from ID
$image_url = '';
if (isset($attributes['imageId']) && $attributes['imageId']) {
$image_url = wp_get_attachment_image_url(absint($attributes['imageId']), 'full');
} elseif (isset($attributes['imageUrl'])) {
$image_url = esc_url($attributes['imageUrl']);
}
// Render in template
<?php if ($image_url) : ?>
<img src="<?php echo esc_url($image_url); ?>" alt="" class="block-image">
<?php endif; ?>
For blocks with repeating items:
'attributes' => array(
'blockTitle' => array(
'type' => 'string',
'default' => 'Additional Resources'
),
// Item 1
'item1ImageId' => array('type' => 'number', 'default' => 0),
'item1ImageUrl' => array('type' => 'string', 'default' => ''),
'item1Header' => array('type' => 'string', 'default' => 'Item 1 Title'),
'item1Subhead' => array('type' => 'string', 'default' => 'Item 1 description'),
'item1Link' => array('type' => 'string', 'default' => '/item-1/'),
// Item 2
'item2ImageId' => array('type' => 'number', 'default' => 0),
'item2ImageUrl' => array('type' => 'string', 'default' => ''),
'item2Header' => array('type' => 'string', 'default' => 'Item 2 Title'),
'item2Subhead' => array('type' => 'string', 'default' => 'Item 2 description'),
'item2Link' => array('type' => 'string', 'default' => '/item-2/'),
// Item 3
'item3ImageId' => array('type' => 'number', 'default' => 0),
'item3ImageUrl' => array('type' => 'string', 'default' => ''),
'item3Header' => array('type' => 'string', 'default' => 'Item 3 Title'),
'item3Subhead' => array('type' => 'string', 'default' => 'Item 3 description'),
'item3Link' => array('type' => 'string', 'default' => '/item-3/'),
),
function renderMediaUpload(itemNum) {
const imageIdAttr = 'item' + itemNum + 'ImageId';
const imageUrlAttr = 'item' + itemNum + 'ImageUrl';
return el(MediaUploadCheck, {},
el(MediaUpload, {
onSelect: function(media) {
const attrs = {};
attrs[imageIdAttr] = media.id;
attrs[imageUrlAttr] = media.url;
setAttributes(attrs);
},
allowedTypes: ['image'],
value: attributes[imageIdAttr],
render: function(obj) {
return el('div', { className: 'media-upload-wrapper' },
attributes[imageUrlAttr] ?
el('div', {},
el('img', {
src: attributes[imageUrlAttr],
style: { maxWidth: '200px', display: 'block', marginBottom: '10px' }
}),
el(Button, {
onClick: obj.open,
className: 'button'
}, 'Change Image'),
el(Button, {
onClick: function() {
const attrs = {};
attrs[imageIdAttr] = 0;
attrs[imageUrlAttr] = '';
setAttributes(attrs);
},
className: 'button',
style: { marginLeft: '10px' }
}, 'Remove')
) :
el(Button, {
onClick: obj.open,
className: 'button button-primary'
}, 'Upload Image')
);
}
})
);
}
// Use in edit function:
el('h4', {}, 'Item 1'),
renderMediaUpload(1),
el(TextControl, {
label: 'Header',
value: attributes.item1Header,
onChange: function(value) {
setAttributes({ item1Header: value });
}
}),
// ... more fields
// Text
$title = isset($attributes['title']) ? esc_html($attributes['title']) : '';
// Attributes
$class = isset($attributes['className']) ? esc_attr($attributes['className']) : '';
// URLs
$link = isset($attributes['link']) ? esc_url($attributes['link']) : '';
// Multi-paragraph text (preserves formatting)
$description = isset($attributes['description']) ? wp_kses_post(wpautop($attributes['description'])) : '';
// Integers (for image IDs, etc.)
$image_id = isset($attributes['imageId']) ? absint($attributes['imageId']) : 0;
// Numbers
$count = isset($attributes['count']) ? intval($attributes['count']) : 0;
// CORRECT:
get_template_directory_uri() . '/inc/blocks/js/block-name.js'
get_template_directory_uri() . '/assets/img/site/hero.jpg'
// Use filemtime for cache busting
filemtime(get_template_directory() . '/inc/blocks/js/block-name.js')
wp_enqueue_script(
'block-name',
get_template_directory_uri() . '/inc/blocks/js/block-name.js',
array(
'wp-blocks', // Core block functionality
'wp-element', // React elements
'wp-editor', // Editor components
'wp-components', // UI components
'wp-block-editor' // For MediaUpload
),
filemtime(get_template_directory() . '/inc/blocks/js/block-name.js'),
false // Load in header for editor
);
Common Dashicons for blocks: