Search

How to Build a Statamic Addon: Step-by-Step Tutorial (Statamic 6)

How to Build a Statamic Addon: Step-by-Step Tutorial (Statamic 6)

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_time modifier: {{ content | reading_time }} β†’ "4 min read"
  • a reading_time tag, 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:

  1. Creates the addon in addons/thewebtier/reading-time
  2. Writes composer.json, src/ServiceProvider.php, a PHPUnit setup, README.md and .gitignore
  3. Runs composer install inside the addon folder, so you can run its tests on their own
  4. Adds a path repository to your site's composer.json
  5. Runs composer require thewebtier/reading-time:*@dev so your site loads the addon straight from that folder

πŸ“Έ SCREENSHOT TO ADD Β· 02-make-addon-terminal.png

Terminal 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.statamic marks the package as an addon and sets the name and description shown in the Control Panel.
  • extra.laravel.providers tells Laravel's package discovery which service provider to load.
  • require lists 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:

  1. Extend AddonServiceProvider, not Laravel's ServiceProvider. It handles auto-registration, assets, routes, settings and more.
  2. Put boot logic in bootAddon(), not boot(). bootAddon() runs after Statamic itself has booted, so facades like Addon, Entry and Collection are 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.png

Tools β†’ 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:raw gives ['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, so public function words() would be {{ reading_time:words }}.
  • $this->params gives 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->isPair tells 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.png

A 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.php or routes/web.php. Since Statamic 5.29 they're registered automatically. CP routes are prefixed with /cp and require a logged-in user. Action routes live under /!/reading-time/....
  • Navigation. Use Nav::extend(fn ($nav) => ...) in bootAddon() to add Control Panel nav items.
  • Permissions. Register your own with Permission::register() inside a Permission::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/Listeners are discovered automatically (5.35+). Scheduled tasks go in the provider's schedule() 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.json don't show up. New classes under src/ load straight away, but if you change the addon's autoload section, requirements or extra block, run composer update thewebtier/reading-time in your site so it re-reads the package.
  • Your modifier or tag isn't picked up. Check it's in src/Modifiers or src/Tags and extends the right base class. Auto-discovery only looks in those folders.
  • Settings return null. Check the package name in Addon::get() matches composer.json exactly, and pass a default as the second argument to get().
  • Using boot() instead of bootAddon(). Statamic facades may not be ready yet in boot(), 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 β†’

TWT Staff

TWT Staff

Writes about Programming, tech news, discuss programming topics for web developers (and Web designers), and talks about SEO tools and techniques

Your experience on this site will be improved by allowing cookies Cookie Policy