Create a Custom Gutenberg Block Plugin with Underpin

Posted on:

This tutorial covers how to set up a custom Gutenberg block plugin in WordPress using Underpin. This plugin can hold all of your site's custom Gutenberg blocks, and allows you to bring your custom blocks with you when you change themes in the future.

Install Underpin and Set Up Your Plugin

The first thing you'll need to-do is set up and install Underpin, as well as your plugin. If you aren't familiar with how to-do that, check out this guide. It will walk you through setting up Underpin and your plugin using a boilerplate.

The rest of this lesson will assume that you named your plugin custom_blocks. If you used something different than that, be sure to adjust your code to call your own function instead of custom_blocks. Got it? Great, let's get started!

Set Up The Loaders

With Underpin, everything is registered using an Underpin loader. These loaders will handle actually loading all of the things you need to register. Everything from scripts, to blocks, even admin pages, can all be added directly using Underpin's loaders. Loaders make it so that everything uses an identical pattern to add items to WordPress. With this system, all of these things use nearly exact same set of steps to register.

To build a Gutenberg block, we need to add at least two loaders, but you usually end up needing three.

  1. A block loader
  2. A script loader
  3. A style loader (optional)

Create the Gutenberg Block Loader

First things first, install the block loader. In your command line, navigate to your mu-plugins directory and run this command:

composer require underpin/block-loader

This will install the loader necessary to register blocks in Underpin. Now that it's installed, you can register your block by chaining custom_blocks like so:

{
  "name": "acf/snippet",
  "attributes": {
    "id": "block_60dba4de53a54",
    "name": "acf/snippet",
    "data": {
      "language": "php",
      "_language": "field_60d7f32b1b540",
      "code": "// Registers block\r\ncustom_blocks()->blocks()->add( 'my_custom_block', [\r\n\t'name'        => 'My Custom Block',                       // Names your block. Used for debugging.\r\n\t'description' => 'A custom block specific to this site.', // Describes your block. Used for debugging\r\n\t'type'        => 'custom-blocks/hello-world',             // See register_block_type\r\n\t'args'        => [],                                      // See register_block_type\r\n] );\r\n",
      "_code": "field_60d7f36c1b541"
    },
    "align": "",
    "mode": "edit"
  },
  "innerBlocks": []
}

Let's break down what's going on above.

  1. custom_blocks() actually retrieves this plugin's instance of Underpin
  2. blocks() Retrieves the loader registry for this instance of Underpin
  3. add() actually adds this block to the registry

Behind the scenes, Underpin will automatically create an instance of Block, which then automatically runs register_block_type using the provided args and type.

At this point, your plugin's bootstrap.php will look like this:

{
  "name": "acf/snippet",
  "attributes": {
    "id": "block_60dba74d53a55",
    "name": "acf/snippet",
    "data": {
      "language": "php",
      "_language": "field_60d7f32b1b540",
      "code": "<?php\r\n/*\r\nPlugin Name: Custom Blocks\r\nDescription: Plugin Description Replace Me\r\nVersion: 1.0.0\r\nAuthor: An awesome developer\r\nText Domain: custom_blocks\r\nDomain Path: /languages\r\nRequires at least: 5.1\r\nRequires PHP: 7.0\r\nAuthor URI: https://www.designframesolutions.com\r\n*/\r\n\r\nuse Underpin\\Abstracts\\Underpin;\r\n\r\nif ( ! defined( 'ABSPATH' ) ) {\r\n\texit;\r\n}\r\n\r\n/**\r\n * Fetches the instance of the plugin.\r\n * This function makes it possible to access everything else in this plugin.\r\n * It will automatically initiate the plugin, if necessary.\r\n * It also handles autoloading for any class in the plugin.\r\n *\r\n * @since 1.0.0\r\n *\r\n * @return \\Underpin\\Factories\\Underpin_Instance The bootstrap for this plugin.\r\n */\r\nfunction custom_blocks() {\r\n\treturn Underpin::make_class( [\r\n\t\t'root_namespace'      => 'Custom_Blocks',\r\n\t\t'text_domain'         => 'custom_blocks',\r\n\t\t'minimum_php_version' => '7.0',\r\n\t\t'minimum_wp_version'  => '5.1',\r\n\t\t'version'             => '1.0.0',\r\n\t] )->get( __FILE__ );\r\n}\r\n\r\n// Lock and load.\r\ncustom_blocks();\r\n\r\n// Registers block\r\ncustom_blocks()->blocks()->add( 'my_custom_block', [\r\n\t'name'        => 'My Custom Block',                       // Names your block. Used for debugging.\r\n\t'description' => 'A custom block specific to this site.', // Describes your block. Used for debugging\r\n\t'type'        => 'custom-blocks/hello-world',             // See register_block_type\r\n\t'args'        => [],                                      // See register_block_type\r\n] );\r\n",
      "_code": "field_60d7f36c1b541"
    },
    "align": "",
    "mode": "edit"
  },
  "innerBlocks": []
}

Create the Gutenberg Block Script

Next up, install the script loader. In your command line, navigate to your mu-plugins directory and run this command:

composer require underpin/script-loader

Exactly like blocks, this will install the loader necessary to register scripts in Underpin. With it, you can register scripts like so:

{
  "name": "acf/snippet",
  "attributes": {
    "id": "block_60dba8ab53a56",
    "name": "acf/snippet",
    "data": {
      "language": "php",
      "_language": "field_60d7f32b1b540",
      "code": "custom_blocks()->scripts()->add( 'custom_blocks', [\r\n\t'handle'      => 'custom-blocks',                                // Script Handle used in wp_*_script\r\n\t'src'         => custom_blocks()->js_url() . 'custom-blocks.js', // Src used in wp_register_script\r\n\t'name'        => 'Custom Blocks Script',                         // Names your script. Used for debugging.\r\n\t'description' => 'Script that loads in the custom blocks',       // Describes your script. Used for debugging.\r\n] );",
      "_code": "field_60d7f36c1b541"
    },
    "align": "",
    "mode": "edit"
  },
  "innerBlocks": []
}

Let's break down what's going on above.

  1. custom_blocks() actually retrieves this plugin's instance of Underpin
  2. scripts() Retrieves the loader registry for this instance of Underpin
  3. add() actually adds this script to the registry
  4. custom_blocks()->js_url() is a helper function that automatically gets the javascript url for this plugin. This is configured in the custom_blocks function directly, and defaults to build

Behind the scenes, Underpin will automatically create an instance of Script, which then automatically runs wp_register_script using the arguments passed into the registry.

Enqueuing the Script

Now that the script is registered, you actually have to enqueue the script as well. We could manually enqueue the script, but instead we're going to use Underpin's Middleware functionality to automatically enqueue this script in the admin area.

{
  "name": "acf/snippet",
  "attributes": {
    "id": "block_60dbaccc53a57",
    "name": "acf/snippet",
    "data": {
      "language": "php",
      "_language": "field_60d7f32b1b540",
      "code": "\r\ncustom_blocks()->scripts()->add( 'custom_blocks', [\r\n\t'handle'      => 'custom-blocks',                                // Script Handle used in wp_*_script\r\n\t'src'         => custom_blocks()->js_url() . 'custom-blocks.js', // Src used in wp_register_script\r\n\t'name'        => 'Custom Blocks Script',                         // Names your script. Used for debugging.\r\n\t'description' => 'Script that loads in the custom blocks',       // Describes your script. Used for debugging.\r\n\t'middlewares' => [\r\n\t\t'Underpin_Scripts\\Factories\\Enqueue_Admin_Script',             // Enqueues the script in the admin area\r\n\t],\r\n] );",
      "_code": "field_60d7f36c1b541"
    },
    "align": "",
    "mode": "edit"
  },
  "innerBlocks": []
}

Your bootstrap.php file should now look something like this:

{
  "name": "acf/snippet",
  "attributes": {
    "id": "block_60dbad8b53a58",
    "name": "acf/snippet",
    "data": {
      "language": "php",
      "_language": "field_60d7f32b1b540",
      "code": "<?php\r\n/*\r\nPlugin Name: Custom Blocks\r\nDescription: Plugin Description Replace Me\r\nVersion: 1.0.0\r\nAuthor: An awesome developer\r\nText Domain: custom_blocks\r\nDomain Path: /languages\r\nRequires at least: 5.1\r\nRequires PHP: 7.0\r\nAuthor URI: https://www.designframesolutions.com\r\n*/\r\n\r\nuse Underpin\\Abstracts\\Underpin;\r\n\r\nif ( ! defined( 'ABSPATH' ) ) {\r\n\texit;\r\n}\r\n\r\n/**\r\n * Fetches the instance of the plugin.\r\n * This function makes it possible to access everything else in this plugin.\r\n * It will automatically initiate the plugin, if necessary.\r\n * It also handles autoloading for any class in the plugin.\r\n *\r\n * @since 1.0.0\r\n *\r\n * @return \\Underpin\\Factories\\Underpin_Instance The bootstrap for this plugin.\r\n */\r\nfunction custom_blocks() {\r\n\treturn Underpin::make_class( [\r\n\t\t'root_namespace'      => 'Custom_Blocks',\r\n\t\t'text_domain'         => 'custom_blocks',\r\n\t\t'minimum_php_version' => '7.0',\r\n\t\t'minimum_wp_version'  => '5.1',\r\n\t\t'version'             => '1.0.0',\r\n\t] )->get( __FILE__ );\r\n}\r\n\r\n// Lock and load.\r\ncustom_blocks();\r\n\r\n// Registers block\r\ncustom_blocks()->blocks()->add( 'my_custom_block', [\r\n\t'name'        => 'My Custom Block',                       // Names your block. Used for debugging.\r\n\t'description' => 'A custom block specific to this site.', // Describes your block. Used for debugging\r\n\t'type'        => 'underpin/custom-block',                 // See register_block_type\r\n\t'args'        => [],                                      // See register_block_type\r\n] );\r\n\r\ncustom_blocks()->scripts()->add( 'custom_blocks', [\r\n\t'handle'      => 'custom-blocks',                                // Script Handle used in wp_*_script\r\n\t'src'         => custom_blocks()->js_url() . 'custom-blocks.js', // Src used in wp_register_script\r\n\t'name'        => 'Custom Blocks Script',                         // Names your script. Used for debugging.\r\n\t'description' => 'Script that loads in the custom blocks',       // Describes your script. Used for debugging.\r\n\t'middlewares' => [\r\n\t\t'Underpin_Scripts\\Factories\\Enqueue_Admin_Script',             // Enqueues the script in the admin area\r\n\t],\r\n] );",
      "_code": "field_60d7f36c1b541"
    },
    "align": "",
    "mode": "edit"
  },
  "innerBlocks": []
}

Create the Blocks Javascript File

First, you need to modify your webpack.config.js to create a new entry file. It should look like this:

{
  "name": "acf/snippet",
  "attributes": {
    "id": "block_60dbb02a53a59",
    "name": "acf/snippet",
    "data": {
      "language": "javascript",
      "_language": "field_60d7f32b1b540",
      "code": "/**\r\n * WordPress Dependencies\r\n */\r\nconst defaultConfig = require( '@wordpress/scripts/config/webpack.config.js' );\r\n\r\n/**\r\n * External Dependencies\r\n */\r\nconst path = require( 'path' );\r\n\r\nmodule.exports = {\r\n\t...defaultConfig,\r\n\t...{\r\n\t\tentry: {\r\n\t\t\t\"custom-blocks\": path.resolve( process.cwd(), 'src', 'custom-blocks.js' ) // Create \"custom-blocks.js\" file in \"build\" directory\r\n\t\t}\r\n\t}\r\n}",
      "_code": "field_60d7f36c1b541"
    },
    "align": "",
    "mode": "edit"
  },
  "innerBlocks": []
}

This instructs Webpack to take a JS file located in your plugin's src directory, and compile it into build/custom-blocks.js. From here, we need to create a new file in the src directory called custom-blocks.js.

Now we have to register the block in our Javascript, as well. This will allow us to customize how this block behaves in the Gutenberg editor. In this lesson, we're going to just create a very simple "Hello World" block.

{
  "name": "acf/snippet",
  "attributes": {
    "id": "block_60dbb77153a5a",
    "name": "acf/snippet",
    "data": {
      "language": "javascript",
      "_language": "field_60d7f32b1b540",
      "code": "// Imports the function to register a block type.\r\nimport { registerBlockType } from '@wordpress/blocks';\r\n\r\n// Imports the translation function\r\nimport { __ } from '@wordpress/i18n';\r\n\r\n// Registers our block type to the Gutenberg editor.\r\nregisterBlockType( 'custom-blocks/hello-world', {\r\n\ttitle: __( \"Hello World!\", 'beer' ),\r\n\tdescription: __( \"Display Hello World text on the site\", 'beer' ),\r\n\tedit(){\r\n\t\treturn (\r\n\t\t\t<h1 className=\"hello-world\">Hello World!</h1>\r\n\t\t)\r\n\t},\r\n\tsave() {\r\n\t\treturn (\r\n\t\t\t<h1 className=\"hello-world\">Hello World!</h1>\r\n\t\t)\r\n\t}\r\n} );",
      "_code": "field_60d7f36c1b541"
    },
    "align": "",
    "mode": "edit"
  },
  "innerBlocks": []
}

Okay, so what's going on here?

  1. We're importing registerBlockType so we can use it in this file
  2. We're also importing __ so we can make translate-able strings
  3. We are running registerBlockType to register our "Hello World" block to the editor.

Now run npm install and npm run start. This will create two files in your build directory:

  1. custom-blocks.js - This is your compiled Javascript file that gets enqueued by Underpin's script loader.
  2. custom-blocks-asset.php - This asset file tells WordPress what additional scripts need enqueued in-order for this script to work properly.

You will notice that we did not install @wordpress/blocks or @wordpress/i18n. That's not a mistake. Since these are internal WordPress scripts, we need to tell WordPress to enqueue those scripts before our script. Fortunately, WordPress and Underpin make this pretty easy to-do.

Update Underpin Script to include

Back in bootstrap.php, update your script's add function to include a deps argument. Since this argument is a path, it will automatically require the file, and use it to tell WordPress which scripts need enqueued. Since Webpack automatically generates this file for us, we no-longer need to worry about adding dependencies every time we want to use a WordPress library.

{
  "name": "acf/snippet",
  "attributes": {
    "id": "block_60dbba5653a5c",
    "name": "acf/snippet",
    "data": {
      "language": "php",
      "_language": "field_60d7f32b1b540",
      "code": "custom_blocks()->scripts()->add( 'custom_blocks', [\r\n\t'handle'      => 'custom-blocks',                                          // Script Handle used in wp_*_script\r\n\t'src'         => custom_blocks()->js_url() . 'custom-blocks.js',           // Src used in wp_register_script\r\n\t'name'        => 'Custom Blocks Script',                                   // Names your script. Used for debugging.\r\n\t'description' => 'Script that loads in the custom blocks',                 // Describes your script. Used for debugging.\r\n\t'deps'        => custom_blocks()->dir() . 'build/custom-blocks.asset.php', // Load these scripts first.\r\n\t'middlewares' => [\r\n\t\t'Underpin_Scripts\\Factories\\Enqueue_Admin_Script',                       // Enqueues the script in the admin area\r\n\t],\r\n] );",
      "_code": "field_60d7f36c1b541"
    },
    "align": "",
    "mode": "edit"
  },
  "innerBlocks": []
}

From your admin screen, if you navigate to posts>>Add New, you will find that you can use a new block called "Hello World", which will simply display "Hello World" In gigantic letters on the page.

With this script, you can create as many blocks as you need by simply creating another registerBlockType call, and registering the block though Underpin using custom_blocks()->blocks()->add.

Create the Gutenberg Block Stylesheet (Optional)

Stylesheets need a bit of extra thought in-order for them to work-as expected. Normally, you would simply enqueue the script much like you enqueue a script. The catch is that this stylesheet also needs to be used in the block editor in-order to accurately display the block output. Let's get into how to set that up.

Just like everything else with Underpin, the first step is to install the appropriate loader, the register the style.

In your mu-plugins directory, run:

composer require underpin/style-loader

From there, register a style in your bootstrap.php file:

{
  "name": "acf/snippet",
  "attributes": {
    "id": "block_60dbd05553a5e",
    "name": "acf/snippet",
    "data": {
      "language": "php",
      "_language": "field_60d7f32b1b540",
      "code": "custom_blocks()->styles()->add( 'custom_block_styles', [\r\n\t'handle'      => 'custom-blocks',                                        // handle used in wp_register_style\r\n\t'src'         => custom_blocks()->css_url() . 'custom-block-styles.css', // src used in wp_register_style\r\n\t'name'        => 'Custom Blocks Style',                                  // Names your style. Used for debugging\r\n\t'description' => 'Styles for custom Gutenberg blocks',                   // Describes your style. Used for debugging\r\n] );",
      "_code": "field_60d7f36c1b541"
    },
    "align": "",
    "mode": "edit"
  },
  "innerBlocks": []
}

Then, update webpack.config.js to include custom-block-styles.css, like so:

{
  "name": "acf/snippet",
  "attributes": {
    "id": "block_60dbd52a53a5f",
    "name": "acf/snippet",
    "data": {
      "language": "javascript",
      "_language": "field_60d7f32b1b540",
      "code": "/**\r\n * WordPress Dependencies\r\n */\r\nconst defaultConfig = require( '@wordpress/scripts/config/webpack.config.js' );\r\n\r\n/**\r\n * External Dependencies\r\n */\r\nconst path = require( 'path' );\r\n\r\nmodule.exports = {\r\n\t...defaultConfig,\r\n\t...{\r\n\t\tentry: {\r\n\t\t\t\"custom-blocks\": path.resolve( process.cwd(), 'src', 'custom-blocks.js' ), // Create \"custom-blocks.js\" file in \"build\" directory\r\n\t\t\t\"custom-block-styles\": path.resolve( process.cwd(), 'src', 'custom-block-styles.css' )\r\n\t\t}\r\n\t}\r\n}",
      "_code": "field_60d7f36c1b541"
    },
    "align": "",
    "mode": "edit"
  },
  "innerBlocks": []
}

Next, update your registered block to use the style to specify the stylesheet to be used with this block like so:

{
  "name": "acf/snippet",
  "attributes": {
    "id": "block_60dbda9853a60",
    "name": "acf/snippet",
    "data": {
      "language": "php",
      "_language": "field_60d7f32b1b540",
      "code": "// Registers block\r\ncustom_blocks()->blocks()->add( 'my_custom_block', [\r\n\t'name'        => 'My Custom Block',                       // Names your block. Used for debugging.\r\n\t'description' => 'A custom block specific to this site.', // Describes your block. Used for debugging\r\n\t'type'        => 'custom-blocks/hello-world',             // See register_block_type\r\n\t'args'        => [                                        // See register_block_type\r\n\t\t'style' => 'custom-blocks',                             // Stylesheet handle to use in the block\r\n\t],\r\n] );",
      "_code": "field_60d7f36c1b541"
    },
    "align": "",
    "mode": "edit"
  },
  "innerBlocks": []
}

That will update your block to enqueue the stylesheet in the block editor automatically, and will reflect the styles in the stylesheet. This will work both on the actual site and the block editor.

With the style set as-such:

{
  "name": "acf/snippet",
  "attributes": {
    "id": "block_60dbdc1953a61",
    "name": "acf/snippet",
    "data": {
      "language": "css",
      "_language": "field_60d7f32b1b540",
      "code": ".hello-world {\r\n\tbackground:rebeccapurple;\r\n}",
      "_code": "field_60d7f36c1b541"
    },
    "align": "",
    "mode": "edit"
  },
  "innerBlocks": []
}

You'll get this in the block editor, and the front end:

Use Server-Side Rendering (Optional)

This is all fine and dandy, but there's one problem with how this is built - what happens if a theme needs to change the markup of our block? Or, what if, for some reason, it makes more sense to use PHP to render this block instead of Javascript?

A fundamental problem with blocks is that it will hardcode the saved block result inside of the WordPress content. In my opinion, it's better to render using server-side rendering. This tells WordPress that, instead of saving the HTML output, to instead create a placeholder for the block, and just before the content is rendered, WordPress will inject the content from a PHP callback. This allows you to update blocks across your site quickly just by updating a PHP callback whenever you want.

Call me old fashioned, but I think that's a lot more maintain-able, and thankfully it's pretty easy to-do.

First, update your registered block so that save returns null. This instructs the editor to simply not save HTML, and just put a placeholder there instead.

{
  "name": "acf/snippet",
  "attributes": {
    "id": "block_60dbe3a653a62",
    "name": "acf/snippet",
    "data": {
      "language": "javascript",
      "_language": "field_60d7f32b1b540",
      "code": "// Registers our block type to the Gutenberg editor.\r\nregisterBlockType( 'custom-blocks/hello-world', {\r\n\ttitle: __( \"Hello World!\", 'beer' ),\r\n\tdescription: __( \"Display Hello World text on the site\", 'beer' ),\r\n\tedit(){\r\n\t\treturn (\r\n\t\t\t<h1 className=\"hello-world\">Hello World!</h1>\r\n\t\t)\r\n\t},\r\n\tsave: () => null\r\n} );",
      "_code": "field_60d7f36c1b541"
    },
    "align": "",
    "mode": "edit"
  },
  "innerBlocks": []
}

Now, if you specify a render_callback in your registered block arguments, it will use the callback instead of what was originally in the save callback.

{
  "name": "acf/snippet",
  "attributes": {
    "id": "block_60dbeb2d53a63",
    "name": "acf/snippet",
    "data": {
      "language": "php",
      "_language": "field_60d7f32b1b540",
      "code": "// Registers block\r\ncustom_blocks()->blocks()->add( 'my_custom_block', [\r\n\t'name'        => 'My Custom Block',                       // Names your block. Used for debugging.\r\n\t'description' => 'A custom block specific to this site.', // Describes your block. Used for debugging\r\n\t'type'        => 'custom-blocks/hello-world',             // See register_block_type\r\n\t'args'        => [                                        // See register_block_type\r\n\t\t'style' => 'custom-blocks',                             // Stylesheet handle to use in the block\r\n\t\t'render_callback' => function(){\r\n\t\t\treturn '<h1 class=\"hello-world\">Hey, this is a custom callback!</h1>';\r\n\t\t}\r\n\t],\r\n] );",
      "_code": "field_60d7f36c1b541"
    },
    "align": "",
    "mode": "edit"
  },
  "innerBlocks": []
}

Now if you look in your editor, you'll still see "Hello World", because that's what the Javascript's edit method returns, however, if you save and look at the actual post, you'll find that the actual post will show "Hey, this is a custom callback" instead. This is because it's using PHP to render the output on the fly. Now, if you change the content of the render_callback, it will automatically render this output.

Going Further - Use Underpin's Template System

What happens if you have a WordPress theme, and you want to actually override the render callback? A good way to approach this is to use Underpin's built-in Template loader system. This system allows you to specify file locations for PHP templates that render content, and also has baked-in support for template overriding by themes.

Underpin's template system is a PHP trait. It can be applied to any class that needs to output HTML content. The tricky part is, we haven't made a class yet, have we?

...Have we?

Well, actually, we have. Every time we run the add method in WordPress, it automatically creates an instance of a class, and it uses the array of arguments to construct our class for us. However, now, we need to actually make the class ourselves so we can apply the Template trait to the class, and render our template. So, next up we're going to take our registered block, and move it into it's own PHP class, and then instruct Underpin to use that class directly instead of making it for us.

First up, create a directory called lib inside your plugin directory, and then inside lib create another directory called blocks. Inside that, create a new PHP file called Hello_World.php. Underpin comes with an autoloader, so the naming convention matters here.

ā”œā”€ā”€ lib
ā”‚&nbsp;&nbsp; ā””ā”€ā”€ blocks
ā”‚&nbsp;&nbsp;     ā””ā”€ā”€ Hello_World.php

Inside your newly created PHP file, create a new PHP class called Hello_World that extends Block, then move all of your array arguments used in your add method as parameters inside the class, like so:

{
  "name": "acf/snippet",
  "attributes": {
    "id": "block_60dc5a5c514e3",
    "name": "acf/snippet",
    "data": {
      "language": "php",
      "_language": "field_60d7f32b1b540",
      "code": "<?php\r\n\r\nnamespace Custom_Blocks\\Blocks;\r\n\r\n\r\nuse Underpin_Blocks\\Abstracts\\Block;\r\n\r\nif ( ! defined( 'ABSPATH' ) ) {\r\n\texit;\r\n}\r\n\r\nclass Hello_World extends Block {\r\n\r\n\tpublic $name        = 'My Custom Block';                       // Names your block. Used for debugging.\r\n\tpublic $description = 'A custom block specific to this site.'; // Describes your block. Used for debugging\r\n\tpublic $type        = 'custom-blocks/hello-world';             // See register_block_type\r\n\r\n\tpublic function __construct() {\r\n\t\t$this->args        = [                                        // See register_block_type\r\n\t\t\t'style' => 'custom-blocks',                                 // Stylesheet handle to use in the block\r\n\t\t\t'render_callback' => function(){\r\n\t\t\t\treturn '<h1 class=\"hello-world\">Hey, this is a custom callback!</h1>';\r\n\t\t\t}\r\n\t\t];\r\n\t\tparent::__construct();\r\n\t}\r\n\r\n}",
      "_code": "field_60d7f36c1b541"
    },
    "align": "",
    "mode": "edit"
  },
  "innerBlocks": []
}

Then, replace the array of arguments in your add callback with a string that references the class you just created, like so:

{
  "name": "acf/snippet",
  "attributes": {
    "id": "block_60dc5ac7514e4",
    "name": "acf/snippet",
    "data": {
      "language": "php",
      "_language": "field_60d7f32b1b540",
      "code": "// Registers block\r\ncustom_blocks()->blocks()->add( 'my_custom_block', 'Custom_Blocks\\Blocks\\Hello_World' );",
      "_code": "field_60d7f36c1b541"
    },
    "align": "",
    "mode": "edit"
  },
  "innerBlocks": []
}

By doing this, you have instructed Underpin to use your PHP class instead of creating one from the array of arguments. Now that we have a full-fledged PHP class in-place, we can do a lot of things to clean this up a bit, and use that template Trait I mentioned before.

Add use \Underpin\Traits\Templates to the top of your PHP class, and add the required methods to the trait as well, like so:

{
  "name": "acf/snippet",
  "attributes": {
    "id": "block_60dc5b95514e5",
    "name": "acf/snippet",
    "data": {
      "language": "php",
      "_language": "field_60d7f32b1b540",
      "code": "<?php\r\n\r\nnamespace Custom_Blocks\\Blocks;\r\n\r\n\r\nuse Underpin_Blocks\\Abstracts\\Block;\r\n\r\nif ( ! defined( 'ABSPATH' ) ) {\r\n\texit;\r\n}\r\n\r\nclass Hello_World extends Block {\r\n\tuse \\Underpin\\Traits\\Templates;\r\n\r\n\tpublic $name        = 'My Custom Block';                       // Names your block. Used for debugging.\r\n\tpublic $description = 'A custom block specific to this site.'; // Describes your block. Used for debugging\r\n\tpublic $type        = 'custom-blocks/hello-world';             // See register_block_type\r\n\r\n\tpublic function __construct() {\r\n\t\t$this->args        = [                                        // See register_block_type\r\n\t\t\t'style' => 'custom-blocks',                                 // Stylesheet handle to use in the block\r\n\t\t\t'render_callback' => function(){\r\n\t\t\t\treturn '<h1 class=\"hello-world\">Hey, this is a custom callback!</h1>';\r\n\t\t\t}\r\n\t\t];\r\n\t\tparent::__construct();\r\n\t}\r\n\r\n\tpublic function get_templates() {\r\n\t\t// TODO: Implement get_templates() method.\r\n\t}\r\n\r\n\tprotected function get_template_group() {\r\n\t\t// TODO: Implement get_template_group() method.\r\n\t}\r\n\r\n\tprotected function get_template_root_path() {\r\n\t\t// TODO: Implement get_template_root_path() method.\r\n\t}\r\n\r\n}",
      "_code": "field_60d7f36c1b541"
    },
    "align": "",
    "mode": "edit"
  },
  "innerBlocks": []
}

Now, we're going to fill out each of these functions. get_templates should return an array of template file names with an array declaring if that template can be manipulated by a theme, or not, like so:

{
  "name": "acf/snippet",
  "attributes": {
    "id": "block_60dc5ca1514e6",
    "name": "acf/snippet",
    "data": {
      "language": "php",
      "_language": "field_60d7f32b1b540",
      "code": "public function get_templates() {\r\n\treturn ['wrapper' => [ 'override_visibility' => 'public' ]];\r\n}",
      "_code": "field_60d7f36c1b541"
    },
    "align": "",
    "mode": "edit"
  },
  "innerBlocks": []
}

get_template_group should return a string, that indicates what the template sub directory should be called. In our case, we're going to make it hello-world.

{
  "name": "acf/snippet",
  "attributes": {
    "id": "block_60dc5cff514e7",
    "name": "acf/snippet",
    "data": {
      "language": "php",
      "_language": "field_60d7f32b1b540",
      "code": "protected function get_template_group() {\r\n\treturn 'hello-world';\r\n}",
      "_code": "field_60d7f36c1b541"
    },
    "align": "",
    "mode": "edit"
  },
  "innerBlocks": []
}

get_template_root_path should simply return custom_blocks()->template_dir(), as we don't need to use a custom template directory or anything.

{
  "name": "acf/snippet",
  "attributes": {
    "id": "block_60dc5d2d514e8",
    "name": "acf/snippet",
    "data": {
      "language": "php",
      "_language": "field_60d7f32b1b540",
      "code": "protected function get_template_root_path() {\r\n\treturn custom_blocks()->template_dir();\r\n}",
      "_code": "field_60d7f36c1b541"
    },
    "align": "",
    "mode": "edit"
  },
  "innerBlocks": []
}

Finally, we have the option to override the template override directory name into something specific to our own plugin. Let's do that, too:

{
  "name": "acf/snippet",
  "attributes": {
    "id": "block_60dc5d9a514e9",
    "name": "acf/snippet",
    "data": {
      "language": "php",
      "_language": "field_60d7f32b1b540",
      "code": "\t\r\nprotected function get_override_dir() {\r\n\treturn 'custom-blocks/';\r\n}",
      "_code": "field_60d7f36c1b541"
    },
    "align": "",
    "mode": "edit"
  },
  "innerBlocks": []
}

With these three items in-place, you can now create a new file in templates/hello-world called wrapper.php. Inside your theme, this template can be completely overridden by adding a file in custom-blocks/hello-world called wrapper.php. Let's start by adding our template in the plugin file.

The first thing your template needs is a header that checks to make sure the template was loaded legitimately. You don't want people to load this template outside of the intended way, so you must add a check at the top level to make sure it was loaded properly, like so:

{
  "name": "acf/snippet",
  "attributes": {
    "id": "block_60dc5eb0514ea",
    "name": "acf/snippet",
    "data": {
      "language": "php",
      "_language": "field_60d7f32b1b540",
      "code": "<?php\r\n\r\nif ( ! isset( $template ) || ! $template instanceof \\Custom_Blocks\\Blocks\\Hello_World ) {\r\n\treturn;\r\n}\r\n\r\n?>",
      "_code": "field_60d7f36c1b541"
    },
    "align": "",
    "mode": "edit"
  },
  "innerBlocks": []
}

Underpin automatically creates a new variable called $template and assigns it to the class that renders the actual template. So inside your template file $template will always be the instance of your registered block. This allows you to create custom methods inside the block for rendering purposes if you want, but it also gives you access to rendering other sub-templates using $template->get_template(), plus a lot of other handy things that come with the Template trait. As you can see above, this also provides you with a handy way to validate that the required file is legitimate.

Now, simply add the HTML output at the bottom, like this:

{
  "name": "acf/snippet",
  "attributes": {
    "id": "block_60dc5f65514eb",
    "name": "acf/snippet",
    "data": {
      "language": "php",
      "_language": "field_60d7f32b1b540",
      "code": "<?php\r\n\r\nif ( ! isset( $template ) || ! $template instanceof \\Custom_Blocks\\Blocks\\Hello_World ) {\r\n\treturn;\r\n}\r\n\r\n?>\r\n<h1 class=\"hello-world\">Hey, this is a custom callback, and it is inside my template!</h1>",
      "_code": "field_60d7f36c1b541"
    },
    "align": "",
    "mode": "edit"
  },
  "innerBlocks": []
}

From there, go back into your Hello_World class, and update the render callback to use your template. This is done using get_template, like so:

{
  "name": "acf/snippet",
  "attributes": {
    "id": "block_60dc5fdb514ec",
    "name": "acf/snippet",
    "data": {
      "language": "php",
      "_language": "field_60d7f32b1b540",
      "code": "\tpublic function __construct() {\r\n\t\t$this->args = [                         // See register_block_type\r\n\t\t\t'style'           => 'custom-blocks', // Stylesheet handle to use in the block\r\n\t\t\t'render_callback' => function () {\r\n\t\t\t\treturn $this->get_template( 'wrapper' );\r\n\t\t\t},\r\n\t\t];\r\n\t\tparent::__construct();\r\n\t}",
      "_code": "field_60d7f36c1b541"
    },
    "align": "",
    "mode": "edit"
  },
  "innerBlocks": []
}

This instructs the render_callback to use get_template, which will then retrieve the template file you created. If you look at your template's output, you'll notice that your h1 tag changed to read "Hey, this is a custom callback, and it is inside my template!".

Now, go into your current theme, create a php file inside custom-blocks/hello-world called wrapper.php. Copy the contents of your original wrapper.php file, and paste them in. Finally, change the output a little bit. When you do this, the template will automatically be overridden by your theme.

Conclusion

Now that you have one block set-up, it's just a matter of registering new blocks using Underpin, and inside your Javascript using registerBlockType. If necessary, you can create a block class for each block, and use the template system to render the content.

This post barely scratches the surface of what can be done with Underpin, the template loader, and Gutenberg. From here, you could really flesh out your block into something more than a trivial example. If you want to go deeper on these subjects, check out my WordPress plugin development course, where we create a block much like how I describe it here, and then build out a fully-functional Gutenberg block, as well as many other things.