
If you write PHP for WordPress but have avoided the block editor’s JavaScript side, you are not alone. Gutenberg looks like a foreign country from the outside, but the toolchain has matured to the point where building a single custom block is a weekend project, not a career change. This walkthrough builds one real block from scratch and explains every file it generates along the way.
Why blocks replaced shortcodes
Shortcodes gave editors a text token that expanded into HTML on render. Blocks give editors a structured, visual representation of that same content directly in the editor, with attributes stored as JSON in the post content and a defined save/render path. The payoff is that editors see what they get before they publish, and developers get a formal contract (attributes, supports, category) instead of parsing arbitrary shortcode strings. For anything beyond simple text substitution, blocks are the more maintainable choice going forward.
The toolchain you actually need
You do not need to hand-roll a webpack config. The official @wordpress/scripts package wraps all the build tooling (webpack, Babel, ESLint rules tuned for WordPress) behind a few npm scripts, and @wordpress/create-block scaffolds a working plugin in one command. You will need:
- Node.js (an LTS version) and npm installed locally
- A local or staging WordPress install you can activate plugins on
wp-clifor activating and inspecting the plugin from the command line
Scaffold the block with:
npx @wordpress/create-block@latest servertip-calloutThis creates a servertip-callout directory that is itself a valid WordPress plugin, with a build system already wired up. Move it into your wp-content/plugins directory (or symlink it there during development), then start the dev build:
cd servertip-callout
npm startnpm start runs webpack in watch mode, so every save to your source files rebuilds automatically. Activate the plugin from the command line:
wp plugin activate servertip-calloutblock.json is the contract
Open src/block.json. This file is the single source of truth for the block, and both PHP and JavaScript read from it. The fields that matter most on your first pass:
- name: a namespaced identifier like
servertip/callout, used internally to register the block - title and description: what editors see in the block inserter
- category: which group the block appears under (text, media, design, and so on)
- attributes: the data schema for whatever the block stores, each with a type and a default
- supports: opt-in features like custom colors, alignment, or spacing controls, handled automatically by core
- editorScript, editorStyle, and style: which built files load in the editor versus the front end
Because block.json is machine-readable, WordPress core can register the block correctly with a single PHP call. In your main plugin file you will see:
register_block_type( __DIR__ . '/build' );That one line reads block.json and wires up scripts, styles, and attributes for you. No manual wp_register_script calls needed for the basics.
Anatomy of the generated files
Inside src/ you will find a small, predictable set of files:
- index.js: registers the block on the client, importing
editandsave - edit.js: the component rendered inside the editor, where you build the UI editors interact with
- save.js: returns the static HTML markup saved into the post content for static blocks
- editor.scss and style.scss: styles scoped to the editor only, or shared between editor and front end
For a simple callout box block, edit.js might expose a RichText component bound to a content attribute, plus a color picker in the block’s sidebar via the built-in supports.color setting. save.js then outputs the equivalent static markup, wrapped in whatever HTML tag and classes your CSS expects. Because the attribute schema lives in block.json, both files agree on what data exists without you writing custom validation.
When you need PHP: dynamic blocks
Static blocks are fine for content that does not change after publishing, but many real-world blocks need to run PHP at render time, for example a block that queries recent posts or pulls in live data. For that, add a render.php file and reference it in block.json:
"render": "file:./render.php"WordPress calls that file at render time with $attributes and $content already in scope, and whatever it echoes becomes the front-end output. This is the natural bridge for PHP developers: the editing experience stays in JavaScript, but the actual rendering logic on the front end can be plain, familiar PHP.
Building for production
Once the block works the way you want, stop the dev watcher and run:
npm run buildThis produces minified, production-ready files in build/, which is what register_block_type should point to on a live site. Do not deploy the src/ directory or run npm start on production; ship only the built output.
Performance and caching notes
A custom block’s front-end script and style are just another asset request, so the usual performance rules apply: keep the bundle small, avoid loading editor-only code on the front end (the scaffold already separates these correctly), and make sure your object cache and page cache are configured so PHP-driven dynamic blocks are not re-querying the database on every page load. If you are on a LiteSpeed stack with Redis object caching, a dynamic block’s PHP query results can be cached transparently once the plugin is set up correctly, so a well-built block should not hurt Core Web Vitals like LCP or INP even when it runs server-side logic. Hosts like ServerBorn handle the LiteSpeed and Redis layer for you, so your job is just writing a block that plays nicely with a cache, not fighting one.
The takeaway
You do not need to master a JavaScript framework to build a working Gutenberg block. Scaffold with @wordpress/create-block, let block.json define the contract, keep edit.js and save.js focused, and reach for a render.php file whenever you need PHP logic at render time. Build for production with npm run build, activate with wp-cli, and you have a real, maintainable block instead of another shortcode nobody remembers the syntax for.