A plugin is a PHP file (or folder) in wp-content/plugins/. WordPress finds it by a header comment and runs it when activated.
The smallest plugin
Create wp-content/plugins/reading-time/reading-time.php:
<?php
/**
* Plugin Name: Reading Time
* Description: Adds an estimated reading time above single posts.
* Version: 1.0.0
* Requires PHP: 8.1
* Author: Your Name
* License: GPL-2.0-or-later
* Text Domain: reading-time
*/
declare(strict_types=1);
if (!defined("ABSPATH")) {
exit; // do not run if the file is opened directly
}
function reading_time_minutes(string $content, int $wordsPerMinute = 200): int
{
$words = str_word_count(wp_strip_all_tags($content));
return max(1, (int) ceil($words / $wordsPerMinute));
}
add_filter("the_content", function (string $content): string {
if (!is_singular("post") || !in_the_loop() || !is_main_query()) {
return $content;
}
$minutes = reading_time_minutes($content);
return sprintf('<p class="reading-time">%s</p>', esc_html(sprintf(_n("%d minute read", "%d minutes read", $minutes, "reading-time"), $minutes))) . $content;
});
Go to Plugins in the admin and activate it. Every single post now shows its reading time.
The parts
- Header comment. Only
Plugin Nameis required. The rest is information. ABSPATHcheck. Stops direct access to the file by URL.- A prefixed function.
reading_time_minutes(), so it never clashes with another plugin. - A filter. The plugin changes data through a hook, never by editing core.
- Escaping and translation.
esc_html(),_n()(singular and plural) and a text domain.
Testing the logic outside WordPress
The core calculation is plain PHP. We can run it directly.
<?php
function reading_time_minutes(string $text, int $wordsPerMinute = 200): int
{
$words = str_word_count(strip_tags($text));
return max(1, (int) ceil($words / $wordsPerMinute));
}
echo reading_time_minutes(str_repeat("word ", 450)) . " minutes\n";
echo reading_time_minutes("A short note") . " minute";
3 minutes
1 minute
Keep your logic in functions that do not depend on WordPress, and it is easy to test.
A class-based structure
For anything beyond a few lines, use a class and a namespace.
<?php
/**
* Plugin Name: Reading Time
* Version: 1.0.0
* Requires PHP: 8.1
*/
declare(strict_types=1);
namespace MyPlugin\ReadingTime;
if (!defined("ABSPATH")) {
exit;
}
final class Plugin
{
public function __construct(private int $wordsPerMinute = 200) {}
public function register(): void
{
add_filter("the_content", [$this, "prepend"]);
}
public function minutes(string $content): int
{
return max(1, (int) ceil(str_word_count(wp_strip_all_tags($content)) / $this->wordsPerMinute));
}
public function prepend(string $content): string
{
if (!is_singular("post") || !in_the_loop() || !is_main_query()) {
return $content;
}
$minutes = $this->minutes($content);
return '<p class="reading-time">' . esc_html((string) $minutes) . " min read</p>" . $content;
}
}
(new Plugin())->register();
Activation and deactivation
<?php
register_activation_hook(__FILE__, function (): void {
add_option("reading_time_wpm", 200);
});
register_deactivation_hook(__FILE__, function (): void {
// clean up scheduled tasks; do NOT delete user data here
});
Deleting data belongs in an uninstall step (uninstall.php in the plugin folder), which runs only when the user deletes the plugin.
Good practice checklist
- A unique prefix or namespace for everything.
- The
ABSPATHguard in every PHP file. - Escape output, sanitise input, check nonces and capabilities.
- Use hooks, never edit core or other plugins.
- Load scripts only where needed (
wp_enqueue_scriptswith conditions). - Make text translatable (
__(),_e(), a text domain). - Provide an uninstall routine, and do not leave junk in the database.
- Follow the WordPress coding standards, but modern PHP (types, classes) is welcome in your own code.
Composer in plugins
You can use Composer inside a plugin for autoloading and libraries. Be careful: two plugins that bundle different versions of the same library can clash. Tools such as PHP-Scoper prefix the libraries to avoid it.