The fastest way to learn plugin development is to build one plugin all the way to the finish line instead of collecting isolated snippets. In this walkthrough you build GB Reading Time: a plugin that calculates an estimated reading time for a post and exposes it two ways, as a shortcode and as a real editor block defined by block.json. It ships with translations, an asset build, and a clean uninstall routine.
str_word_count( wp_strip_all_tags( $content ) ) and divides by a words per minute figure, register it with add_shortcode() for classic use, then add a dynamic block by placing a block.json with "apiVersion": 3 and a render file in a build directory and calling register_block_type( __DIR__ . '/build/reading-time' ) on init. Finish with uninstall.php so deleting the plugin removes its options.This is a complete project rather than a tour of concepts. If you have never touched a plugin header or a hook before, start with our beginner tutorial on creating a WordPress plugin, which explains folders, activation hooks, the Settings API, and escaping before you get here. Everything below assumes WordPress 6.7 or newer; the current release line is WordPress 7.1, and Node 20 or later for the build step.
What we are building and why the two interfaces matter
Reading time is a good first project because it touches every layer of a modern plugin without needing a database table. You need a pure PHP calculation, a way to render it inside post content, an editor experience, an asset pipeline, and a removal path. Offering both a shortcode and a block is not padding: shortcodes still work everywhere, including widgets, page builders, and templates, while a block is what people expect in the editor.
| Concern | Shortcode | Dynamic block |
|---|---|---|
| Registration | add_shortcode() on init | register_block_type() with a folder path |
| Build step | None | Node plus wp-scripts |
| Editor preview | Raw text only | Live server rendered preview |
| Settings UI | Attributes typed by hand | Sidebar controls |
| Works in widgets and templates | Yes, through do_shortcode() | Yes in block themes |
Project layout
Keep source and built assets separate. The src directory is what you edit, build is what wp-scripts produces and what WordPress actually registers.
gb-reading-time/
├── gb-reading-time.php
├── uninstall.php
├── package.json
├── includes/
│ └── class-gbrt-calculator.php
├── src/
│ └── reading-time/
│ ├── block.json
│ ├── index.js
│ ├── edit.js
│ ├── style.scss
│ └── render.php
└── build/ # generated, do not editSet up the toolchain with the official build package, which wraps webpack and Babel so you do not configure either.
cd wp-content/plugins/gb-reading-time
npm init -y
npm install --save-dev @wordpress/scripts
npx wp-scripts start # watch mode while developing
npx wp-scripts build # minified output for releaseThe main file and the calculation
Start with the header and a small class that does one job. Keeping the math out of the render callbacks means the shortcode and the block share exactly one implementation.
<?php
/**
* Plugin Name: GB Reading Time
* Description: Estimated reading time as a shortcode and a block.
* Version: 1.0.0
* Requires at least: 6.7
* Requires PHP: 7.4
* Author: Ethan Caldwell
* License: GPL-2.0-or-later
* Text Domain: gb-reading-time
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
require_once __DIR__ . '/includes/class-gbrt-calculator.php';<?php
// includes/class-gbrt-calculator.php
class GBRT_Calculator {
const DEFAULT_WPM = 225;
public static function minutes( $post_id = 0, $wpm = self::DEFAULT_WPM ) {
$post = get_post( $post_id ?: get_the_ID() );
if ( ! $post instanceof WP_Post ) {
return 0;
}
$wpm = max( 50, absint( $wpm ) );
$text = wp_strip_all_tags( strip_shortcodes( $post->post_content ) );
$n = str_word_count( $text );
return max( 1, (int) ceil( $n / $wpm ) );
}
public static function label( $minutes ) {
return sprintf(
/* translators: %s: number of minutes */
_n( '%s min read', '%s min read', $minutes, 'gb-reading-time' ),
number_format_i18n( $minutes )
);
}
}Two decisions worth calling out. strip_shortcodes() runs before wp_strip_all_tags() so an embedded gallery does not inflate the count. And the words per minute value is clamped, because a zero passed in from an attribute would otherwise divide by zero.
str_word_count() is byte oriented and undercounts languages that do not separate words with spaces, such as Japanese. If your audience is multilingual, fall back to counting characters and dividing by a per language factor rather than pretending the English number is universal.Ship it as a shortcode first
The shortcode is ten lines and gives you something testable before the build tooling is involved.
add_action( 'init', 'gbrt_register_shortcode' );
function gbrt_register_shortcode() {
add_shortcode( 'reading_time', 'gbrt_shortcode' );
}
function gbrt_shortcode( $atts ) {
$atts = shortcode_atts(
array(
'wpm' => GBRT_Calculator::DEFAULT_WPM,
'post' => 0,
),
$atts,
'reading_time'
);
$minutes = GBRT_Calculator::minutes( absint( $atts['post'] ), $atts['wpm'] );
return '<span class="gbrt-time">' . esc_html( GBRT_Calculator::label( $minutes ) ) . '</span>';
}Drop [reading_time wpm="200"] into any post and it renders. A shortcode callback must return a string, never echo, or the output jumps to the top of the page.
Turn it into a block with block.json
Since WordPress 5.8 the canonical way to define a block is metadata in block.json, and since 6.1 a dynamic block can point at a PHP render file directly. The current metadata version is apiVersion 3.
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "gbrt/reading-time",
"version": "1.0.0",
"title": "Reading Time",
"category": "widgets",
"icon": "clock",
"description": "Estimated reading time for the current post.",
"textdomain": "gb-reading-time",
"attributes": {
"wpm": { "type": "number", "default": 225 }
},
"supports": {
"html": false,
"color": { "text": true, "background": false },
"typography": { "fontSize": true }
},
"editorScript": "file:./index.js",
"style": "file:./style-index.css",
"render": "file:./render.php"
}<?php
// src/reading-time/render.php
// $attributes, $content and $block are available here.
$minutes = GBRT_Calculator::minutes( 0, $attributes['wpm'] ?? 225 );
?>
<p <?php echo wp_kses_data( get_block_wrapper_attributes() ); ?>>
<?php echo esc_html( GBRT_Calculator::label( $minutes ) ); ?>
</p>Register it once, on init, pointing at the built folder. register_block_type() reads the metadata, enqueues the editor script and the stylesheet only on pages where the block appears, and wires the render file as the callback.
add_action( 'init', 'gbrt_register_block' );
function gbrt_register_block() {
register_block_type( __DIR__ . '/build/reading-time' );
}The editor side is a small React component. The important part is registering under the same name and reading metadata from the JSON file rather than repeating it.
// src/reading-time/index.js
import { registerBlockType } from '@wordpress/blocks';
import metadata from './block.json';
import Edit from './edit';
import './style.scss';
registerBlockType( metadata.name, { edit: Edit } );ServerSideRender from @wordpress/server-side-render in edit.js. The editor then calls the REST endpoint and shows the same markup visitors get, so you never maintain two copies of the output.Translations, assets, and text domain
Keep the text domain identical to the plugin slug. Plugins hosted on WordPress.org have their PHP translations loaded automatically, but JavaScript strings need one extra call so the editor picks up the JSON translation files.
add_action( 'init', 'gbrt_set_translations', 20 );
function gbrt_set_translations() {
wp_set_script_translations(
'gbrt-reading-time-editor-script',
'gb-reading-time',
plugin_dir_path( __FILE__ ) . 'languages'
);
}Generate the POT file with WP-CLI using wp i18n make-pot . languages/gb-reading-time.pot, and convert PO files for the editor with wp i18n make-json languages/. The handle passed to wp_set_script_translations() is generated from the block name, so check it against the output of wp_scripts() if translations do not appear.
uninstall.php so removal is clean
Deactivation should leave data alone. Deletion should not. Put an uninstall.php at the plugin root and WordPress runs it when someone deletes the plugin from the Plugins screen.
<?php
// uninstall.php
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
exit;
}
delete_option( 'gbrt_default_wpm' );
delete_site_option( 'gbrt_default_wpm' );
delete_metadata( 'post', 0, '_gbrt_cached_minutes', '', true );WP_UNINSTALL_PLUGIN guard is not optional. Without it the file can be requested directly and will delete data on demand. Also remember that uninstall.php takes precedence over any uninstall hook you registered, so do not maintain both.Troubleshooting
The block does not appear in the inserter. Nine times out of ten the build folder is missing or stale. Run npx wp-scripts build and confirm build/reading-time/block.json exists. wp-scripts copies block.json and render.php into the build output automatically, so registering against src is the wrong path.
The editor shows “Your site does not include support for this block”. The block name in block.json and the name used in registerBlockType() disagree, or the block is registered on the server but the editor script failed to load. Open the browser console and look for a 404 on index.js.
The shortcode prints the raw text instead of rendering. Something is calling the_content without shortcode processing, or the shortcode was registered too late. Registering inside an init callback rather than at file scope fixes the ordering.
Reading time is always one minute. The calculator is receiving an empty post_content, which happens when the block renders in a template context with no queried post. Pass an explicit post ID, or bail out when get_the_ID() returns false.
Styles do not load on the front end. The style key in block.json must point at the compiled file name, which wp-scripts emits as style-index.css, not style.scss.
Frequently asked questions
Do I have to use Node just to make a block?
Not for a purely dynamic block. You can register a block with only a block.json and a PHP render file, skipping the editor script entirely, and the block appears with a generic placeholder in the editor. You need Node the moment you want custom sidebar controls or a rich editor preview.
Should I use create-block instead of building the folder by hand?
Running npx @wordpress/create-block is a reasonable shortcut and produces the same structure shown here. Doing it manually once is worth the hour because you learn which file does what, which makes debugging a scaffolded project far less mysterious later.
How do I cache the reading time instead of recalculating it?
Store the result in post meta on the save_post hook and read the meta in the render callback, falling back to a live calculation when the meta is missing. For a word count on a normal post the calculation is cheap, so only bother if you are rendering hundreds of items in a loop.
Can one plugin register several blocks?
Yes. Give each block its own folder under src, and either call register_block_type() once per folder or use wp_register_block_types_from_metadata_collection() on newer releases. Keeping one block per directory keeps the build output predictable and lets WordPress load only the assets a page needs.
What is the next step after this build?
Either package it for distribution or restructure it for scale. Submitting a plugin to the WordPress repository covers readme.txt and the Subversion workflow, while building a WordPress plugin from scratch replaces the include statements here with Composer autoloading and tests.
Wrapping up
You now have a plugin with a shared calculation, two rendering surfaces, a real asset build, translation support, and an uninstall path. That combination is what separates a snippet from something you can hand to another person without an apology attached.
Test it against a long post and a nearly empty one, delete it and confirm the options are gone, then read the official block metadata reference for the attributes and supports you did not use here. If you are storing anything larger later, our piece on where WordPress plugin settings are stored explains when options stop being the right home.
