In part 1 we covered what a Statamic addon is and what changed in Statamic 6. Now it's time to build a Statamic addon of our own.
Also Read: Claude Code & Cursor on Filament: AI Agent Rules That Work
By the end of this post you'll have a working Reading Time addon with:
- a
reading_timemodifier:{{ content | reading_time }}β "4 min read" - a
reading_timetag, with a pair version that exposes{{ minutes }}and{{ words }} - a settings screen in the Control Panel so editors can change the reading speed and label without touching code
You'll need a local Statamic 6 site to develop in. If you don't have one yet, the setup steps are in part 1.
Step 1: Scaffold the addon with make:addon
From the root of your Statamic site, run:
php please make:addon thewebtier/reading-time
The argument is a Composer package name: vendor/package. Use your own vendor name (your GitHub username or company), because that's what customers will composer require later.
Also Read: Auto-Post to LinkedIn PHP from Laravel (2026 Guide)
make:addon does more than create files. Behind the scenes it:
- Creates the addon in
addons/thewebtier/reading-time - Writes
composer.json,src/ServiceProvider.php, a PHPUnit setup,README.mdand.gitignore - Runs
composer installinside the addon folder, so you can run its tests on their own - Adds a
pathrepository to your site'scomposer.json - Runs
composer require thewebtier/reading-time:*@devso your site loads the addon straight from that folder
πΈ SCREENSHOT TO ADD Β·
02-make-addon-terminal.pngTerminal output of
php please make:addon thewebtier/reading-time, ending with "Your addon is ready! π".Alt text: "Terminal output of php please make:addon creating a Statamic addon"
You can also scaffold components at the same time with flags: --tag (-t), --modifier (-m), --fieldtype (-f), --widget (-w), --scope (-s), --action, --filter, or --all for the lot. We'll add them one at a time here so you can see what each piece does.
The path repository
Open your site's composer.json and you'll see something like this at the bottom:
"repositories": [
{
"type": "path",
"url": "addons/thewebtier/reading-time"
}
]
This is what lets you edit the addon and see changes straight away. Remove it before you deploy the site. Once the addon is on Packagist, your site should install it from there like any other package.
Also Read: Create a LinkedIn Developer App for Laravel (2026)
Step 2: Understand the generated files
The two files that matter most are composer.json and the service provider.
composer.json
Here's the generated file, tidied up with a description, licence and support links. Reviewers and customers read these, so fill them in properly:
{
"name": "thewebtier/reading-time",
"description": "Reading time modifier, tag and fieldtype for Statamic.",
"type": "statamic-addon",
"license": "MIT",
"keywords": ["statamic", "statamic-addon", "reading-time"],
"support": {
"issues": "https://github.com/thewebtier/statamic-reading-time/issues",
"source": "https://github.com/thewebtier/statamic-reading-time"
},
"autoload": {
"psr-4": { "TheWebTier\\ReadingTime\\": "src" }
},
"autoload-dev": {
"psr-4": { "TheWebTier\\ReadingTime\\Tests\\": "tests" }
},
"require": {
"php": "^8.3",
"statamic/cms": "^6.0"
},
"require-dev": {
"orchestra/testbench": "^10.8"
},
"extra": {
"statamic": {
"name": "Reading Time",
"description": "Show how long your content takes to read."
},
"laravel": {
"providers": ["TheWebTier\\ReadingTime\\ServiceProvider"]
}
}
}
The important parts:
extra.statamicmarks the package as an addon and sets the name and description shown in the Control Panel.extra.laravel.providerstells Laravel's package discovery which service provider to load.requirelists the Statamic versions you support. Keep this accurate: guideline 06 says releases must match their compatibility claims.
"type": "statamic-addon" is optional, but it's a common convention on Packagist and it helps people find your package.
Also Read: Laravel: Get a LinkedIn
The service provider
<?php
namespace TheWebTier\ReadingTime;
use Statamic\Providers\AddonServiceProvider;
class ServiceProvider extends AddonServiceProvider
{
public function bootAddon()
{
//
}
}
Two rules to remember:
- Extend
AddonServiceProvider, not Laravel'sServiceProvider. It handles auto-registration, assets, routes, settings and more. - Put boot logic in
bootAddon(), notboot().bootAddon()runs after Statamic itself has booted, so facades likeAddon,EntryandCollectionare ready to use.
Step 3: Keep the logic in a plain PHP class
Before writing the modifier or tag, put the real work in a small class that knows nothing about Statamic. It's easier to test, and the modifier, tag and (in part 3) fieldtype can all share it.
Create src/Support/ReadingTime.php:
<?php
namespace TheWebTier\ReadingTime\Support;
class ReadingTime
{
private int $wordsPerMinute;
public function __construct(int $wordsPerMinute = 200)
{
$this->wordsPerMinute = max(1, $wordsPerMinute);
}
public function wordsPerMinute(): int
{
return $this->wordsPerMinute;
}
public function words(mixed $content): int
{
$text = $this->toText($content);
if ($text === '') {
return 0;
}
return count(preg_split('/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY));
}
public function minutes(mixed $content): int
{
$words = $this->words($content);
return $words === 0 ? 0 : (int) max(1, ceil($words / $this->wordsPerMinute));
}
private function toText(mixed $content): string
{
// Raw Bard values are ProseMirror arrays. Only the "text" nodes are words.
if (is_array($content)) {
$strings = [];
array_walk_recursive($content, function ($item, $key) use (&$strings) {
if ($key === 'text' && is_string($item)) {
$strings[] = $item;
}
});
$content = implode(' ', $strings);
}
// Swap tags for spaces so "<p>One</p><p>Two</p>" counts as two words, not one.
$text = preg_replace('/<[^>]*>/', ' ', (string) $content);
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim($text);
}
}
This handles the three shapes content usually comes in: Markdown strings, HTML strings and raw Bard arrays. That matters for guideline 05, "work beyond the demo's happy path". Reviewers will try it on real content, not a lorem ipsum paragraph.
Also Read: Renew LinkedIn Access Tokens PHP in Laravel (60-Day Fix)
Step 4: Add a settings screen
Hard-coding "200 words per minute" would fail guideline 03, which says editors must be able to handle routine tasks without writing code. Statamic 6 makes this easy: add a blueprint at resources/blueprints/settings.yaml and the Control Panel builds the form for you.
tabs:
main:
sections:
-
display: 'Reading time'
fields:
-
handle: words_per_minute
field:
type: integer
display: 'Words per minute'
instructions: 'Average adult reading speed is around 200β250.'
default: 200
validate: 'required|integer|min:50|max:1000'
-
handle: label
field:
type: text
display: 'Label'
instructions: 'Use `:minutes` as the placeholder, e.g. `:minutes min read`.'
default: ':minutes min read'
The settings page shows up under Tools β Addons β Reading Time. Values are saved to resources/addons/reading-time.yaml, or to the database if the site uses the Eloquent driver (php please install:eloquent-driver).
πΈ SCREENSHOT TO ADD Β·
02-cp-addon-settings.pngTools β Addons β Reading Time β Settings, showing "Words per minute" and "Label" filled in.
Alt text: "Statamic 6 addon settings page generated from a settings.yaml blueprint"
If you'd rather define the blueprint in PHP (because it's built dynamically, say), call $this->registerSettingsBlueprint([...]) in bootAddon(). You can also pass a closure so it's only built when it's needed.
Read settings anywhere with the Addon facade
use Statamic\Facades\Addon;
Addon::get('thewebtier/reading-time')->settings()->get('words_per_minute', 200);
Now bind the calculator in the service provider so it always uses the saved speed:
<?php
namespace TheWebTier\ReadingTime;
use Statamic\Facades\Addon;
use Statamic\Providers\AddonServiceProvider;
use TheWebTier\ReadingTime\Support\ReadingTime;
class ServiceProvider extends AddonServiceProvider
{
public function bootAddon()
{
$this->app->bind(ReadingTime::class, function () {
$wpm = Addon::get('thewebtier/reading-time')
->settings()
->get('words_per_minute', 200);
return new ReadingTime((int) $wpm);
});
}
}
Anything that asks the container for ReadingTime now gets a calculator set to the site's reading speed.
Also Read: How to Create a WordPress Plugin from Scratch (2026 Guide)
Step 5: Build a custom modifier
Modifiers transform a value inside a template. Generate one inside the addon by passing the package name as the second argument:
php please make:modifier ReadingTime thewebtier/reading-time
That creates src/Modifiers/ReadingTime.php. Because it's in src/Modifiers, Statamic registers it automatically. Its handle is the snake-cased class name, reading_time.
<?php
namespace TheWebTier\ReadingTime\Modifiers;
use Statamic\Facades\Addon;
use Statamic\Modifiers\Modifier;
use TheWebTier\ReadingTime\Support\ReadingTime as Calculator;
class ReadingTime extends Modifier
{
/**
* {{ content | reading_time }} -> "4 min read"
* {{ content | reading_time:raw }} -> 4
*/
public function index($value, $params, $context)
{
$minutes = app(Calculator::class)->minutes($value);
if (($params[0] ?? null) === 'raw') {
return $minutes;
}
$label = Addon::get('thewebtier/reading-time')
->settings()
->get('label', ':minutes min read');
return str_replace(':minutes', (string) $minutes, $label);
}
}
index() receives three things:
$value: whatever is on the left of the pipe$params: modifier parameters, as an array (reading_time:rawgives['raw'])$context: every variable available in the template at that point
Use it in Antlers:
<p >{{ content | reading_time }}</p>
or in Blade:
<p >{{ Statamic::modify($content)->readingTime() }}</p>
Step 6: Build a custom tag
Tags are more flexible than modifiers. They take named parameters, can read the whole template context, and can work as pairs that loop or expose variables.
php please make:tag ReadingTime thewebtier/reading-time
Replace the generated src/Tags/ReadingTime.php with:
<?php
namespace TheWebTier\ReadingTime\Tags;
use Statamic\Tags\Tags;
use TheWebTier\ReadingTime\Support\ReadingTime as Calculator;
class ReadingTime extends Tags
{
/**
* {{ reading_time field="content" }}
*
* As a pair, exposes {{ minutes }} and {{ words }}:
* {{ reading_time field="content" }}{{ minutes }} min Β· {{ words }} words{{ /reading_time }}
*/
public function index()
{
$field = $this->params->get('field', 'content');
$value = $this->context->raw($field);
$calculator = $this->params->has('wpm')
? new Calculator($this->params->int('wpm'))
: app(Calculator::class);
$data = [
'minutes' => $calculator->minutes($value),
'words' => $calculator->words($value),
];
return $this->isPair ? $data : $data['minutes'];
}
}
What's going on:
index()runs when the tag is called with no method:{{ reading_time }}. Every other public method becomes a sub-tag, sopublic function words()would be{{ reading_time:words }}.$this->paramsgives you typed helpers:get(),int(),bool(),float(),explode().$this->context->raw($field)reads the field's raw value from the current entry. For Bard that's the ProseMirror array, which our calculator already handles.$this->isPairtells you whether the tag has a closing tag. Returning an array from a pair makes its keys available inside it.
In Antlers:
{{# Single tag: prints the number of minutes #}}
{{ reading_time field="content" }} min
{{# Pair: minutes and words, with a custom speed #}}
{{ reading_time field="content" wpm="250" }}
{{ minutes }} min read Β· {{ words }} words
{{ /reading_time }}
In Blade, using Antlers Blade Components:
<s:reading_time field="content" /> min
Step 7: Try it on a real entry
Open your site's blog entry template (for example resources/views/blog/show.antlers.html) and add:
<header>
<h1>{{ title }}</h1>
<p>{{ date format="j F Y" }} Β· {{ content | reading_time }}</p>
</header>
Load a blog post and you should see "4 min read" (or similar) under the title. Change Words per minute in the Control Panel, refresh, and the number changes.
πΈ SCREENSHOT TO ADD Β·
02-frontend-reading-time.pngA blog post on the front end with "4 min read" under the title.
Alt text: "A Statamic blog post showing the reading time output by a custom modifier"
Where to go from here: routes, navigation and permissions
You won't need these for Reading Time, but most larger addons use them. Each is a few lines in the service provider:
- Routes. Put files in
routes/cp.php,routes/actions.phporroutes/web.php. Since Statamic 5.29 they're registered automatically. CP routes are prefixed with/cpand require a logged-in user. Action routes live under/!/reading-time/.... - Navigation. Use
Nav::extend(fn ($nav) => ...)inbootAddon()to add Control Panel nav items. - Permissions. Register your own with
Permission::register()inside aPermission::extend()closure, and check them on the server in your controllers. Guideline 07 specifically calls out "unauthorized access to control-panel actions" as grounds for rejection. - Events and scheduling. Listeners in
src/Listenersare discovered automatically (5.35+). Scheduled tasks go in the provider'sschedule()method.
If you're coming from plain Laravel, our guide to roles and permissions in Laravel covers the underlying ideas.
Common gotchas
- Changes to the addon's
composer.jsondon't show up. New classes undersrc/load straight away, but if you change the addon'sautoloadsection, requirements orextrablock, runcomposer update thewebtier/reading-timein your site so it re-reads the package. - Your modifier or tag isn't picked up. Check it's in
src/Modifiersorsrc/Tagsand extends the right base class. Auto-discovery only looks in those folders. - Settings return
null. Check the package name inAddon::get()matchescomposer.jsonexactly, and pass a default as the second argument toget(). - Using
boot()instead ofbootAddon(). Statamic facades may not be ready yet inboot(), which leads to confusing errors.
FAQ
What's the difference between a Statamic tag and a modifier?
A modifier transforms one value ({{ value | modifier }}). A tag is a standalone call with named parameters that can read the whole template context and work as a pair. If you're transforming a variable, use a modifier. If you're fetching or computing something, use a tag.
Do I need to register tags and modifiers in the service provider?
Not in Statamic 5.28 and later, as long as they're in src/Tags and src/Modifiers. On older versions, add them to the $tags and $modifiers arrays on the provider.
Can my addon use Blade instead of Antlers?
Yes. Tags work in Blade as <s:tag_name /> (Antlers Blade Components) and modifiers work through Statamic::modify($value)->modifierName(). Document both in your README, because customers use both.
Where are addon settings stored?
In resources/addons/{addon-slug}.yaml by default, or in the database if the site uses the Eloquent driver.
Further reading
Previous: β Part 1: The Complete Guide Β· Next: Part 3: Build a Custom Fieldtype with Vue 3 and Vite β
