This is a written version of a workshop given most recently at WordCamp Rochester 2024. The workshop is more hands-on, but my hope is that the code and explanations here are detailed enough to help you in creating your first custom block.
The code for the plugin is available on my GitHub profile, and you can walk through the commits to see the order in which the plugin is built.
Do you need someone to give a workshop or presentation on web development? Do you want to hire someone to build a custom plugin or WordPress site for you? Check out my speaking page to find out more and contact me.
This workshop assumes a basic understanding of PHP and JavaScript, as well as a code editor and somewhere to test the code, such as a test WordPress site, or a local development environment. It does not require the use of React or another JavaScript framework, nor does it require build tools.
I won’t be describing every single line-by-line change, but if you follow along and view the commits, you should be able to see everything that changes for each step.
Setting Up the Plugin
Start by creating a folder in your wp-content/plugins folder in WordPress. You can name the folder whatever you want, though I’m going with testimonial-block-workshop.
Create plugin files
Add new files to plugin folder
In that folder, create three files: testimonial-block.php, testimonial-block.js, and testimonial-block.css. These files are going to hold the code to register the plugin, build the block, and style the block, respectively.
Add plugin header code
The following code, placed into testimonial-block.php, will let the plugin show up in the site dashboard. This starts the PHP file and uses PHP comments as a way to register the plugin. We’re providing a name, description, version, and author. All of these show up in the dashboard, and the version is important for when updates come in the future.
<?php
/**
* Plugin Name: Testimonial Block Workshop
* Description: Basic Custom Testimonial Block with no build tools needed - Workshop Demo
* Version: 1.0.0
* Author: david wolfpaw
*/
After you save this code, you should be able to go to the plugin dashboard and see your plugin! Activate it and make it look all highlighted and happy.

Add exit code and define version constant
Add exit code and define version constant
Now we’re gonna add code to the PHP file that will ensure that the plugin file doesn’t get accessed directly, and defines a constant for the version of the plugin. We’ll be using this later.
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
// Define plugin version
define( 'TESTIMONIAL_BLOCK_VERSION', '1.0.0' );
Registering the Block
Now that we’re setup, it’s time to register the block, so that it’s visible in the WordPress editor.
Register PHP for the block
This code registers the JavaScript file for the plugin, which is where we’ll be doing the most work. Note that we first register the file with wp_register_script, and then we use register_block_type and tell it which script file to use.
// Function to register the block and enqueue scripts/styles
function register_testimonial_block() {
// Register the block's JavaScript file
wp_register_script(
'testimonial-block-script', // Unique script handle
plugins_url( 'testimonial-block.js', __FILE__ ), // URL of where the block file exists
array( 'wp-blocks', 'wp-block-editor' ), // Scripts that should load before this script
TESTIMONIAL_BLOCK_VERSION, // Version of the plugin script (optional)
true, // Is script in HTML Footer (optional)
);
// Register the block type with the scripts and styles
register_block_type(
// Block namespace and name
'workshop/testimonial-block',
// Array of arguments for the block
array(
'editor_script' => 'testimonial-block-script',
)
);
}
add_action( 'init', 'register_testimonial_block' );
Register JS for the block
In the JavaScript file, we likewise register the block. We use the same namespace and name for the block that we used in the PHP file (workshop/testimonial-block).
The main things that we’re doing are adding a title and icon, as well as choosing the category of blocks that it will go into for when we select it. The edit and save functions right now are just returning a paragraph tag. The goal at this point is to test to ensure that we can see the block in the editor, and that when we save it, something will display on the frontend of the site.
(function (blocks, element) {
const { registerBlockType } = blocks;
const { createElement } = element;
// Step 1: Register the block
registerBlockType('workshop/testimonial-block', {
title: 'Workshop Testimonial Block', // Block title
icon: 'admin-comments', // Icon from WordPress dashicons
category: 'common', // Category in the block inserter
edit: function () {
return createElement('p', null, 'Workshop Testimonial Block'); // Placeholder text in editor
},
save: function () {
return createElement('p', null, 'Workshop Testimonial Block'); // Placeholder text on frontend
}
});
})(window.wp.blocks, window.wp.element);
With both of these files saved, you should be able to go to edit a post or page and find your block.

Creating Fields to Edit Block
Alright, now that we’ve gotten really set up, it’s time to create some fields for our block. We’re going to create some text fields, an image upload button, and color selectors.
Add editable text fields
The following code adds some of the other constants that we need to make text controls, and adds two text fields: testimonialText and authorName to unsurprisingly store the testimonial text, and the author name.
(function (blocks, element, blockEditor, components) {
const { registerBlockType } = blocks;
const { TextControl } = components; // Import the TextControl component
const { createElement, Fragment } = element;
// Step 1: Register the block
registerBlockType('workshop/testimonial-block', {
title: 'Workshop Testimonial Block', // Block title
icon: 'admin-comments', // Icon from WordPress dashicons
category: 'common', // Category in the block inserter
// Step 2: Add Editable Text Fields
attributes: {
testimonialText: { type: 'string', default: '' }, // Testimonial text
authorName: { type: 'string', default: '' }, // Author name
},
edit: function ({ attributes, setAttributes }) {
const { testimonialText, authorName } = attributes;
return createElement(
Fragment,
null,
// Add TextControl for Testimonial Text
createElement(TextControl, {
label: 'Testimonial Text',
value: testimonialText,
onChange: (value) => setAttributes({ testimonialText: value }),
placeholder: 'Enter the testimonial text…'
}),
// Add TextControl for Author Name
createElement(TextControl, {
label: 'Author Name',
value: authorName,
onChange: (value) => setAttributes({ authorName: value }),
placeholder: 'Enter the author’s name…'
})
);
},
save: function ({ attributes }) {
return createElement('p', null, 'Custom Testimonial Block'); // Placeholder text on frontend
}
});
})(window.wp.blocks, window.wp.element, window.wp.blockEditor, window.wp.components);
We are creating the elements that you’ll be able to see on the backend of the site, including labels and placeholders for those fields.

Display text on frontend
The save function will affect what saves tot he database to display on the frontend of the site. We’ve replaced the placeholder paragraph with a div that holds a blockquote and bolded paragraph tag for the testimonial text and the author name.
save: function ({ attributes }) {
// Step 3: Create Elements to Display Text on Frontend
const { testimonialText, authorName } = attributes;
return createElement('div', null,
createElement('blockquote', null, testimonialText),
createElement('p', { style: { fontWeight: 'bold' } }, authorName)
);
}

Add image upload controls
Same as before, we add the constants needed for the media uploader. We’re going to be saving the URL of the image to use on the frontend. We’re also creating a button that says “Upload Author Image” if no image is set, and changes to “Change Author Image” if an image is already present.
(function (blocks, element, blockEditor, components) {
const { registerBlockType } = blocks;
// Step 4: Add Button and Media Upload Components
const { TextControl, Button } = components; // Import the Button Component
const { MediaUpload, MediaUploadCheck } = blockEditor; // Import the Media Upload Components
const { createElement, Fragment } = element;
registerBlockType('workshop/testimonial-block', {
title: 'Workshop Testimonial Block', // Block title
icon: 'admin-comments', // Icon from WordPress dashicons
category: 'common', // Category in the block inserter
attributes: {
testimonialText: { type: 'string', default: '' }, // Testimonial text
authorName: { type: 'string', default: '' }, // Author name
// Step 4: Add Image URL Attribute
authorImage: { type: 'string', default: '' }, // Author image URL
},
edit: function ({ attributes, setAttributes }) {
// Step 4: Add Image URL Attribute
const { testimonialText, authorName, authorImage } = attributes;
return createElement(
Fragment,
null,
// Step 4: Add Media Upload for Author Image
// Media Upload for Author Image
createElement(MediaUploadCheck, {},
createElement(MediaUpload, {
onSelect: (media) => setAttributes({ authorImage: media.url }),
allowedTypes: ['image'],
value: authorImage,
render: ({ open }) => createElement(Button, { onClick: open, className: 'is-primary' }, authorImage ? 'Change Author Image' : 'Upload Author Image')
})
),
// Display Uploaded Image in Editor
authorImage && createElement('img', { src: authorImage, alt: 'Author Image' }),
// Add TextControl for Testimonial Text
createElement(TextControl, {
label: 'Testimonial Text',
);
},

Display image on frontend
Same as with the text, we’re now using the same code to create an image element on the frontend to display our author image.
save: function ({ attributes }) {
const { testimonialText, authorName, authorImage } = attributes;
return createElement('div', null,
// Step 5: Render Image on Frontend
authorImage && createElement('img', { src: authorImage, alt: 'Author Image' }),
createElement('blockquote', null, testimonialText),
createElement('p', { style: { fontWeight: 'bold' } }, authorName)
);
}

Add color control settings
With the following four changes, we’re adding the ability to create a color panel like the color selectors that appear in other WordPress blocks. We’re saving the settings for a background color and text color for the testimonial block.
// Step 6: Import the Panel Color Settings
const { MediaUpload, MediaUploadCheck, PanelColorSettings } = blockEditor;
// Step 6: Add Attributes for Background and Text Colors
backgroundColor: { type: 'string', default: '#ffffff' }, // Background color
textColor: { type: 'string', default: '#000000' }, // Text color
edit: function ({ attributes, setAttributes }) {
// Step 6: Add Attributes for Background and Text Colors
const { testimonialText, authorName, authorImage, backgroundColor, textColor } = attributes;
// Step 6: Panel for Color Settings
createElement(PanelColorSettings, {
title: 'Color Settings',
initialOpen: true,
colorSettings: [
{
value: backgroundColor,
onChange: (value) => setAttributes({ backgroundColor: value }),
label: 'Background Color',
},
{
value: textColor,
onChange: (value) => setAttributes({ textColor: value }),
label: 'Text Color',
}
],

Move image and color controls to Inspector Panel
Move image and color controls to Inspector Panel
Now that we’ve added more controls to our block, it’s starting to look a bit cluttered in the editor. At this point, I think that it’s reasonable to move these settings from the main block editor to the Inspector Panel that shows up on right sidebar when you have a specific block clicked.
We’re going to move the media upload button and color selectors to the sidebar, while keeping the text fields in the main editor for now. Spoiler: we’re going to move those later as well to tidy up our block view even more.
edit: function ({ attributes, setAttributes }) {
const { testimonialText, authorName, authorImage, backgroundColor, textColor } = attributes;
// Step 7: Move Settings into Inspector Controls
return (
createElement(Fragment,
null,
// InspectorControls Adds Settings in the Sidebar
createElement(InspectorControls,
null,
// PanelBody for Testimonial Settings
createElement(PanelBody,
{ title: 'Testimonial Settings' },
// Media Upload for Author Image
createElement(MediaUploadCheck, {},
createElement(MediaUpload, {
onSelect: (media) => setAttributes({ authorImage: media.url }),
allowedTypes: ['image'],
value: authorImage,
render: ({ open }) => createElement(Button, { onClick: open, isPrimary: true }, authorImage ? 'Change Author Image' : 'Upload Author Image')
})
)
),
// Panel for Color Settings
createElement(PanelColorSettings, {
title: 'Color Settings',
initialOpen: true,
colorSettings: [
{
value: backgroundColor,
onChange: (value) => setAttributes({ backgroundColor: value }),
label: 'Background Color',
},
{
value: textColor,
onChange: (value) => setAttributes({ textColor: value }),
label: 'Text Color',
}
],
})
),
// Step 8: Make div to Display Testimonial in Editor
createElement('div',
null,
// Display Uploaded Image in Editor
authorImage && createElement('img', { src: authorImage, alt: 'Author Image' }),
// Add TextControl for Testimonial Text
createElement(TextControl, {
label: 'Testimonial Text',
value: testimonialText,
onChange: (value) => setAttributes({ testimonialText: value }),
placeholder: 'Enter the testimonial text…'
}),
// Add TextControl for Author Name
createElement(TextControl, {
label: 'Author Name',
value: authorName,
onChange: (value) => setAttributes({ authorName: value }),
placeholder: 'Enter the author’s name…'
})
)
)
);
},

Display color changes in editor
Display color changes in editor
Now that we’ve moved the color and media settings to the sidebar, we can add some attributes to the div in the editor. Instead of an attribute set of null as it was before, we’re adding a JSON array that includes styles, and a class name.
This will make inline style changes that will update when a new color is selected for either the text or background.
// Step 9: Display Color Changes in Editor
{
style: {
color: textColor,
backgroundColor: backgroundColor
},
className: 'custom-testimonial-block'
},

Display color changes on frontend
Display color changes on frontend
This should look basically the same as the previous step. We’re making the save function return a div that has some style attributes and a class name. The class name we’ll be using when we do more styling at the end.
// Step 10: Display Color Changes on Frontend
const { testimonialText, authorName, authorImage, backgroundColor, textColor } = attributes;
return createElement('div',
{
style: {
color: textColor,
backgroundColor: backgroundColor
},
className: 'custom-testimonial-block'
},

Move text controls to Inspector Panel
Move text controls to Inspector Panel
I had an idea that I’d be moving the text controls to the Inspector Panel in the sidebar as well. While it’s not editable right where it’d be on the page, I think that it looks and acts cleaner this way, with less clutter in the display and all of the things that you’ll actually edit next to one another.
This step involves moving the controls into the InspectorControls element, as well as creating new elements in the div in the main page editor to display the updated blockquote and paragraph tags.
// Step 11: Move Text Controls into Inspector Panel
// Add TextControl for Testimonial Text
createElement(TextControl, {
label: 'Testimonial Text',
value: testimonialText,
onChange: (value) => setAttributes({ testimonialText: value }),
placeholder: 'Enter the testimonial text…'
}),
// Add TextControl for Author Name
createElement(TextControl, {
label: 'Author Name',
value: authorName,
onChange: (value) => setAttributes({ authorName: value }),
placeholder: 'Enter the author’s name…'
}),
// Step 11: Move Text Controls into Inspector Panel
createElement('blockquote', null, testimonialText),
createElement('p', { style: { fontWeight: 'bold' } }, authorName)

Block Alignment and Styles
We’re almost at the end! Now that we’ve made it this far, it’s time to add a bit of style to our testimonials. We’re going to do this in two ways: add support to allow you to change the alignment of the testimonial block, and add some custom styles for it.
Add block alignment support
We’re adding a new parameter to our block: supports. This will let us use an existing feature in the block editor to control alignment. I’ve set all five core options, to allow us to float the testimonial block, or to make it wide or full width on the page. We set an attribute with whatever is saved to allow it to display properly.
Unlike with the custom attributes that we created, we don’t need to add this to our edit or save functions.
// Step 12: Add alignment support
supports: {
align: ['left', 'center', 'right', 'wide', 'full'], // Supports alignment options
},
attributes: {
testimonialText: { type: 'string', default: '' }, // Testimonial text
authorName: { type: 'string', default: '' }, // Author name
authorImage: { type: 'string', default: '' }, // Author image URL
backgroundColor: { type: 'string', default: '#ffffff' }, // Background color
textColor: { type: 'string', default: '#000000' }, // Text color
blockAlignment: { type: 'string', default: 'none' }, // Block alignment
},

Register block stylesheet
Finally touching that PHP file again! The main things of note here are that we are registering the stylesheet that we already created, testimonial-block.css, and telling WordPress where it can be found. We are then adding style_handles to the register_block_type function. This is an array, since there could be more than one stylesheet added.
You do have the ability to specify stylesheets for the frontend and backend of the site separately, but in our case we’re going to have the same one apply, since it isn’t too hard on this simple block to style to look the same to visitors as it does while editing.
// Register the block's styles
wp_register_style(
'testimonial-block-style', // Unique stylesheet handle
plugins_url( 'testimonial-block.css', __FILE__ ), // URL of where the stylesheet file exists
array( 'wp-edit-blocks' ), // Stylesheets that should load before this script
TESTIMONIAL_BLOCK_VERSION // Version of the plugin script (optional)
);
// Register the block type with the scripts and styles
register_block_type(
// Block namespace and name
'workshop/testimonial-block',
// Array of arguments for the block
array(
'editor_script' => 'testimonial-block-script',
'style_handles' => array( 'testimonial-block-style' ),
)
);
Add block styles
And now we’re finally using our CSS file!
Nothing very special of note here. This code:
- makes a box and adds a border to it (same color as the text color)
- centers the image and text
- rounds the image to a circle and adds a border to it as well (same color as the text color)
- makes the block quote look a bit fancy with a large quotation mark
- bolds the author name
.custom-testimonial-block {
text-align: center;
padding: 20px;
border-radius: 20px;
border: 3px solid;
font-size: 1em;
}
.custom-testimonial-block img {
border-radius: 50%;
border: 3px solid;
background-color: #fff;
}
.custom-testimonial-block blockquote {
font-size: 1.5em;
padding: 50px;
font-style: italic;
position: relative;
}
.custom-testimonial-block blockquote::before {
content: '“';
font-family: sans-serif;
font-size: 4em;
line-height: 1;
position: absolute;
left: 10px;
top: -10px;
}
.custom-testimonial-block p {
font-size: 1em;
font-weight: bold;
}
You probably noticed that the author name was already bolded before. That’s because we originally used this:
createElement('p', { style: { fontWeight: 'bold' } }, authorName)
Now we removed the style attributes, to get this:
createElement('p', null, authorName)
The main reason that I made this change is just to keep styling consistently in one area, so that we can more easily make changes to it in the future if we want.

Final Thoughts
I realize that was a lot to go through at once. I don’t blame you if it takes a few tries (it took more than a few for me!) or requires a few sessions and examples to get it down.
This is just one way of building a custom block, and not how most that you will see will look. That’s because instead of using the fancy and very helpful @wordpress/create-block tooling, I opted for something that would not require you to learn any build tools, how to use the command line, or having a separation of source and build files.
From here I want you to go out and create custom blocks that are useful for your own sites, and share them with the world! I’ll be happy to showcase some if you reply with a link to what you end up building.



























