Install
openclaw skills install @michael-stokoe/statamic-devExpertise in managing Statamic CMS content files, collections, blueprints, fieldsets, and writing Bard field content in YAML format.
openclaw skills install @michael-stokoe/statamic-devThis skill covers how to work with the Statamic CMS layer: content structure, blueprints, fieldsets, and writing content files.
All content lives in content/ as Markdown files with YAML front matter. The front matter holds all field data; the body after --- is typically empty (Bard fields are stored in the front matter, not the Markdown body).
---
id: unique-id
blueprint: page
title: My Page
some_field: value
---
Every entry must have an id (unique across the site) and a blueprint that determines which fields are available.
Collections live in content/collections/. Each collection has a config YAML at content/collections/{handle}.yaml and entries as .md files in content/collections/{handle}/.
# content/collections/{handle}.yaml
title: Articles
template: articles/show # Default Antlers template
layout: layout # Layout wrapper
route: '/articles/{slug}' # URL pattern
sort_dir: desc
revisions: true
structure: # Only for structured collections
root: true # true = can have entries at root level
taxonomies:
- categories # Attach taxonomy terms
preview_targets:
-
label: Entry
url: '{permalink}'
refresh: true
The pages collection is a default Statamic structured collection using {parent_uri}/{slug} routing and a tree structure.
Blueprints define the field schema for entries. They live in resources/blueprints/collections/{collection}/{blueprint}.yaml.
title: Page
tabs:
main:
display: Main
sections:
-
display: Content
fields:
-
handle: title
field:
type: text
required: true
validate:
- required
-
import: my_fieldset # Import a fieldset
seo:
display: SEO
sections:
-
fields:
-
import: common/seo # Import from subfolder
sidebar:
display: Sidebar
sections:
-
fields:
-
handle: slug
field:
type: slug
localizable: true
validate: 'max:200'
Tabs organise the CP editing UI. The sidebar tab renders in the right sidebar. Use import: to pull in reusable fieldsets.
Reusable field groups live in resources/fieldsets/. They are imported into blueprints and other fieldsets with import: {path}.
Fieldsets in subfolders are referenced with dot or slash notation (e.g. import: common/seo or import: common.seo).
title: My Fieldset
fields:
-
handle: heading
field:
type: text
display: Heading
-
handle: content
field:
type: bard
display: Content
buttons:
- h2
- h3
- bold
- italic
- unorderedlist
- orderedlist
- quote
- link
Bard fields store content as a ProseMirror document structure in YAML. This is an array of nodes, not raw HTML or Markdown. Understanding this structure is essential when writing or editing content files by hand.
A Bard field value is an array of block-level nodes. Each node has a type and usually content (an array of inline nodes):
my_bard_field:
-
type: paragraph
content:
-
type: text
text: 'Plain paragraph text.'
The most common node. Contains inline text nodes:
-
type: paragraph
content:
-
type: text
text: 'This is a paragraph.'
A paragraph with text alignment uses attrs:
-
type: paragraph
attrs:
textAlign: center
content:
-
type: text
text: 'Centered paragraph text.'
Headings use the heading type with a level attribute:
-
type: heading
attrs:
level: 2
content:
-
type: text
text: 'This is an H2'
Valid levels: 1 through 6 (corresponding to h1–h6). Only use levels that are enabled in the Bard field's buttons config.
Bold, italic, and other inline styles are applied via marks on text nodes. Multiple marks can be combined on a single text node:
Bold:
-
type: text
marks:
-
type: bold
text: 'Bold text'
Italic:
-
type: text
marks:
-
type: italic
text: 'Italic text'
Bold + Italic:
-
type: text
marks:
-
type: bold
-
type: italic
text: 'Bold and italic'
Underline:
-
type: text
marks:
-
type: underline
text: 'Underlined text'
Strikethrough:
-
type: text
marks:
-
type: strike
text: 'Struck through'
Inline code:
-
type: text
marks:
-
type: code
text: 'some_code()'
Superscript / Subscript:
-
type: text
marks:
-
type: superscript
text: '2'
Small text:
-
type: text
marks:
-
type: small
text: 'Fine print'
Links are a mark with attrs containing the href:
-
type: text
marks:
-
type: link
attrs:
href: 'https://example.com'
text: 'Click here'
Link with target and rel attributes:
-
type: text
marks:
-
type: link
attrs:
href: 'https://example.com'
target: _blank
rel: 'noopener noreferrer'
text: 'External link'
Links to Statamic entries use the entry:: prefix:
-
type: text
marks:
-
type: link
attrs:
href: 'entry::some-entry-id'
text: 'Internal link'
A paragraph often contains a mix of plain and formatted text. Each run of text with different formatting is a separate node in the content array:
-
type: paragraph
content:
-
type: text
text: 'This is '
-
type: text
marks:
-
type: bold
text: 'important'
-
type: text
text: ' information.'
Unordered list:
-
type: bulletList
content:
-
type: listItem
content:
-
type: paragraph
content:
-
type: text
text: 'First item'
-
type: listItem
content:
-
type: paragraph
content:
-
type: text
text: 'Second item'
Ordered list:
-
type: orderedList
attrs:
start: 1
content:
-
type: listItem
content:
-
type: paragraph
content:
-
type: text
text: 'Step one'
-
type: listItem
content:
-
type: paragraph
content:
-
type: text
text: 'Step two'
-
type: blockquote
content:
-
type: paragraph
content:
-
type: text
text: 'This is a quoted passage.'
-
type: horizontalRule
-
type: codeBlock
attrs:
language: php
content:
-
type: text
text: 'echo "Hello World";'
Images in Bard reference assets by their path:
-
type: image
attrs:
src: 'assets::images/photo.jpg'
alt: 'Description of the image'
-
type: table
content:
-
type: tableRow
content:
-
type: tableHeader
content:
-
type: paragraph
content:
-
type: text
text: 'Column 1'
-
type: tableHeader
content:
-
type: paragraph
content:
-
type: text
text: 'Column 2'
-
type: tableRow
content:
-
type: tableCell
content:
-
type: paragraph
content:
-
type: text
text: 'Cell value'
-
type: tableCell
content:
-
type: paragraph
content:
-
type: text
text: 'Another value'
An empty Bard field can be represented as an empty array or omitted entirely:
story: []
Global sets store site-wide data. Configs live at content/globals/{handle}.yaml, values at content/globals/default/{handle}.yaml, and blueprints at resources/blueprints/globals/{handle}.yaml.
{{ settings:website_name }}
{{ settings:primary_contact_tel }}
{{ settings:socials }}
{{ name }} — {{ link }}
{{ /settings:socials }}
Taxonomies live in content/taxonomies/. Each taxonomy has a config YAML and term files in a subfolder.
Terms are YAML files in content/taxonomies/{handle}/:
# content/taxonomies/categories/my-term.yaml
title: My Term
In the collection config:
taxonomies:
- categories
In the blueprint, add a terms field:
-
handle: category
field:
type: terms
taxonomies:
- categories
display: Category
mode: select
max_items: 1
In content files, reference terms by slug:
category:
- my_term
Navigation menus live in content/navigation/. Each has a config YAML and a tree YAML in content/trees/navigation/.
# content/navigation/{handle}.yaml
title: Primary
collections:
- pages
max_depth: 1
Navigation items can have custom fields via blueprints at resources/blueprints/navigation/{handle}.yaml.
{{ nav:primary }}
<a href="{{ url }}">{{ title }}</a>
{{ /nav:primary }}
Statamic's built-in form system handles user submissions. Form configs live at resources/forms/{handle}.yaml and blueprints at resources/blueprints/forms/{handle}.yaml.
title: 'Contact Form'
honeypot: honeypot
store: true
email:
-
to: '{{ site:contact_email }}'
from: '{{ email }}'
reply_to: '{{ email }}'
subject: 'New Contact Submission'
html: emails/contact
{{ form:contact }}
{{ if errors }}
{{ errors }}
<p>{{ value }}</p>
{{ /errors }}
{{ /if }}
{{ if success }}
<p>Thank you!</p>
{{ else }}
<input type="text" name="name" value="{{ old:name }}" />
<!-- more fields -->
<button type="submit">Submit</button>
{{ /if }}
{{ /form:contact }}
Asset containers are configured at content/assets/{handle}.yaml. The main container is assets. Assets are referenced by their path relative to the container:
featured_image: images/photo.jpg
gallery:
- images/photo1.jpg
- images/photo2.jpg
In Bard fields, assets use the assets:: prefix:
-
type: image
attrs:
src: 'assets::images/photo.jpg'
alt: 'A photo'
Replicators allow content editors to build flexible content by stacking predefined sets of fields. They appear in blueprints, fieldsets, and globals.
items:
-
id: item-1
type: my_set
value: 'Some value'
label: 'Some label'
enabled: true
-
id: item-2
type: my_set
value: 'Another value'
label: 'Another label'
enabled: true
Each item needs id, type (matching the set handle), and enabled. The remaining fields come from the set's field definitions.
Statamic's link fieldtype can store various link types:
# Entry link
cta_link: 'entry::some-entry-id'
# URL
button_url: 'https://example.com'
# Relative path
cta_link: /some-page
The entries fieldtype stores references to other entries by their ID:
# Single entry
related: b031fb38-4392-42b8-9b72-4554d1fed7c5
# Multiple entries
team_members:
- person-entry-id-1
- person-entry-id-2
Statamic addons are Laravel packages that extend Statamic's functionality. They live in addons/{vendor}/{name}/ during local development and are registered as path repositories in the root composer.json.
A Statamic addon follows this layout:
addons/{vendor}/{name}/
├── composer.json # Package definition + Statamic/Laravel metadata
├── package.json # Node dependencies (if addon has CP JS)
├── vite.config.js # Vite config for building CP assets
├── phpunit.xml # Test configuration
├── config/
│ └── {name}.php # Publishable config file
├── resources/
│ ├── js/
│ │ ├── cp.js # CP JavaScript entry point
│ │ └── components/ # Vue components for the CP
│ ├── views/ # Blade views (for CP pages)
│ └── dist/ # Built assets (committed for distribution)
├── routes/
│ ├── api.php # Public/API routes
│ └── cp.php # Control Panel routes
├── src/
│ ├── ServiceProvider.php # Main addon service provider
│ ├── Http/
│ │ ├── Controllers/ # Route controllers
│ │ └── Middleware/ # Custom middleware
│ ├── Support/ # Helper/utility classes
│ ├── Policies/ # Authorization policies
│ ├── Exceptions/ # Custom exception classes
│ └── Tools/ # Domain-specific classes
│ └── Contracts/ # Interfaces
└── tests/
├── TestCase.php # Base test case
├── Unit/ # Unit tests
├── Integration/ # Integration tests
└── Property/ # Property-based tests (optional)
The addon's composer.json defines the package, its autoloading, and Statamic/Laravel metadata:
{
"name": "{vendor}/{name}",
"autoload": {
"psr-4": {
"{Vendor}\\{Name}\\": "src"
}
},
"autoload-dev": {
"psr-4": {
"{Vendor}\\{Name}\\Tests\\": "tests"
}
},
"require": {
"statamic/cms": "^6.0"
},
"require-dev": {
"orchestra/testbench": "^10.8"
},
"config": {
"allow-plugins": {
"pixelfear/composer-dist-plugin": true
}
},
"extra": {
"statamic": {
"name": "My Addon",
"description": "What the addon does"
},
"laravel": {
"providers": [
"{Vendor}\\{Name}\\ServiceProvider"
]
}
},
"minimum-stability": "dev",
"prefer-stable": true
}
Key points:
extra.statamic provides the addon name and description for the Statamic marketplace/CP.extra.laravel.providers registers the service provider for auto-discovery.pixelfear/composer-dist-plugin must be allowed for Statamic's asset distribution.In the root composer.json, add a path repository and require the addon:
{
"repositories": [
{
"type": "path",
"url": "addons/{vendor}/{name}"
}
],
"require": {
"{vendor}/{name}": "*@dev"
}
}
Then run composer update to symlink the addon into vendor/.
Every addon has a service provider that extends Statamic\Providers\AddonServiceProvider. This is the central registration point for everything the addon provides.
<?php
namespace {Vendor}\{Name};
use Statamic\Facades\CP\Nav;
use Statamic\Providers\AddonServiceProvider;
class ServiceProvider extends AddonServiceProvider
{
// Namespace for Blade views: used as '{namespace}::view.name'
protected $viewNamespace = '{name}';
// Vite configuration for CP JavaScript/CSS assets
protected $vite = [
'input' => [
'resources/js/cp.js',
],
'publicDirectory' => 'resources/dist',
'hotFile' => __DIR__.'/../resources/dist/hot',
];
public function register(): void
{
parent::register();
// Merge default config so config('{name}.key') works immediately
$this->mergeConfigFrom(__DIR__.'/../config/{name}.php', '{name}');
// Register singletons that need to be available early
$this->app->singleton(SomeRepository::class);
}
public function bootAddon(): void
{
// Publish config file: php artisan vendor:publish --tag={name}-config
$this->publishes([
__DIR__.'/../config/{name}.php' => config_path('{name}.php'),
], '{name}-config');
// Register CP navigation items
$this->bootCp();
// Conditionally load routes, bindings, etc.
if (! config('{name}.enabled')) {
return; // Addon is invisible when disabled
}
$this->loadRoutesFrom(__DIR__.'/../routes/api.php');
// Register additional singletons
$this->app->singleton(SomeService::class, function ($app) {
return new SomeService($app);
});
}
protected function bootCp(): void
{
Nav::extend(function ($nav) {
$nav->tools('My Addon')
->route('{name}.settings.index')
->icon('some-icon');
});
}
}
Key patterns:
register() runs first — merge config and bind early singletons here.bootAddon() is the Statamic-specific boot method (not boot()). Load routes, publish assets, and register CP nav here.$vite property tells Statamic where to find the addon's built JS/CSS assets.$viewNamespace property sets the Blade view namespace.config('{name}.enabled')) to make the addon completely invisible when disabled — no routes, no bindings.AddonServiceProvider provides several properties you can set to register things declaratively:
// Register route files
protected $routes = [
'cp' => __DIR__.'/../routes/cp.php',
'web' => __DIR__.'/../routes/web.php',
'actions' => __DIR__.'/../routes/actions.php',
];
// Register event listeners
protected $listen = [
SomeEvent::class => [SomeListener::class],
];
// Register custom fieldtypes
protected $fieldtypes = [MyFieldtype::class];
// Register custom tags
protected $tags = [MyTag::class];
// Register custom modifiers
protected $modifiers = [MyModifier::class];
// Register custom widgets (CP dashboard)
protected $widgets = [MyWidget::class];
// Register custom actions (bulk actions in listings)
protected $actions = [MyAction::class];
// Register custom scopes (query scopes for listings)
protected $scopes = [MyScope::class];
// Register custom policies
protected $policies = [
SomeModel::class => SomePolicy::class,
];
// Register publishable assets
protected $publishables = [
__DIR__.'/../public' => 'vendor/{name}',
];
// Register scheduled commands
protected $commands = [MyCommand::class];
// Register middleware
protected $middlewareGroups = [
'statamic.cp.authenticated' => [MyMiddleware::class],
];
// Register update scripts
protected $updateScripts = [UpdateSomething::class];
Addon config files live in config/ within the addon and are publishable to the app's config/ directory.
// config/{name}.php
<?php
return [
'enabled' => env('MY_ADDON_ENABLED', false),
'some_setting' => env('MY_ADDON_SETTING', 'default'),
'nested' => [
'option' => env('MY_ADDON_NESTED_OPTION', true),
],
];
In the service provider, merge it in register():
$this->mergeConfigFrom(__DIR__.'/../config/{name}.php', '{name}');
And publish it in bootAddon():
$this->publishes([
__DIR__.'/../config/{name}.php' => config_path('{name}.php'),
], '{name}-config');
Users publish with: php artisan vendor:publish --tag={name}-config
Addon routes are standard Laravel route files. API routes and CP routes are typically separate.
API routes (routes/api.php):
<?php
use Illuminate\Support\Facades\Route;
use {Vendor}\{Name}\Http\Controllers\MyController;
use {Vendor}\{Name}\Http\Middleware\MyMiddleware;
Route::prefix('{name}')
->middleware([MyMiddleware::class])
->group(function () {
Route::post('/action', [MyController::class, 'action']);
Route::get('/status', [MyController::class, 'status']);
});
CP routes (routes/cp.php):
<?php
use Illuminate\Support\Facades\Route;
use {Vendor}\{Name}\Http\Controllers\SettingsController;
Route::prefix('{name}')->group(function () {
Route::get('/settings', [SettingsController::class, 'index'])->name('{name}.settings.index');
Route::post('/settings', [SettingsController::class, 'update'])->name('{name}.settings.update');
});
CP routes are automatically wrapped in Statamic's CP middleware (authentication, etc.). Load API routes manually in bootAddon() with $this->loadRoutesFrom(). CP routes can be declared via the $routes property or loaded manually.
Addon CP pages use Blade views that extend Statamic's layout and mount Vue components.
Blade view (resources/views/settings/index.blade.php):
@extends('statamic::layout')
@section('title', 'My Addon Settings')
@section('content')
<my-settings-component
:settings='@json($settings)'
update-url="{{ cp_route('{name}.settings.update') }}"
csrf-token="{{ csrf_token() }}"
></my-settings-component>
@endsection
JS entry point (resources/js/cp.js):
import MySettings from './components/MySettings.vue';
Statamic.booting(() => {
Statamic.$components.register('my-settings-component', MySettings);
});
Vue components are registered via Statamic.$components.register() during the Statamic.booting() lifecycle hook. The component name in JS must match the tag name used in the Blade view.
CP controllers extend Statamic\Http\Controllers\CP\CpController and can use Statamic's user/permission system:
<?php
namespace {Vendor}\{Name}\Http\Controllers;
use Illuminate\Http\Request;
use Statamic\Facades\User;
use Statamic\Http\Controllers\CP\CpController;
class SettingsController extends CpController
{
public function index()
{
abort_unless(User::current()?->isSuper(), 403);
$settings = config('{name}');
return view('{name}::settings.index', compact('settings'));
}
public function update(Request $request)
{
abort_unless(User::current()?->isSuper(), 403);
$validated = $request->validate([
'enabled' => 'boolean',
'some_setting' => 'nullable|string',
]);
// Persist settings (to YAML, database, etc.)
if ($request->wantsJson()) {
return response()->json(['message' => 'Settings saved.']);
}
return redirect()->back()->with('success', 'Settings saved.');
}
}
Addons use Vite with the @statamic/cms vite plugin and laravel-vite-plugin:
// vite.config.js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import statamic from '@statamic/cms/vite-plugin';
export default defineConfig({
plugins: [
statamic(),
laravel({
input: ['resources/js/cp.js'],
refresh: true,
publicDirectory: 'resources/dist',
hotFile: 'resources/dist/hot',
}),
],
});
package.json:
{
"private": true,
"scripts": {
"dev": "rm -rf resources/dist/build && vite",
"build": "vite build"
},
"devDependencies": {
"@statamic/cms": "file:./vendor/statamic/cms/resources/dist-package",
"laravel-vite-plugin": "^1.3.0",
"vite": "^6.4.1"
}
}
The @statamic/cms dependency points to the local Statamic dist package within the addon's vendor directory. Run pnpm install (or npm install) from within the addon directory, then pnpm run build to compile assets into resources/dist/build/.
Addon tests use Orchestra Testbench with Statamic's AddonTestCase:
Base TestCase (tests/TestCase.php):
<?php
namespace {Vendor}\{Name}\Tests;
use {Vendor}\{Name}\ServiceProvider;
use Statamic\Testing\AddonTestCase;
abstract class TestCase extends AddonTestCase
{
protected string $addonServiceProvider = ServiceProvider::class;
}
PHPUnit config (phpunit.xml):
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
backupGlobals="false"
bootstrap="vendor/autoload.php"
colors="true"
processIsolation="false"
stopOnFailure="false">
<testsuites>
<testsuite name="Test Suite">
<directory suffix="Test.php">./tests</directory>
</testsuite>
</testsuites>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_KEY" value="base64:ybcI9MKuhLnESRSuWDfnJQuohOXMBaynfbTC5Y5i1FE="/>
<env name="CACHE_DRIVER" value="array"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
</php>
</phpunit>
Run tests from the addon directory: ./vendor/bin/phpunit
Tests have their own composer.json dependencies (e.g. orchestra/testbench) and their own vendor/ directory, separate from the root project.
Custom middleware follows standard Laravel patterns. Register it in route groups or via the service provider:
<?php
namespace {Vendor}\{Name}\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class MyMiddleware
{
public function handle(Request $request, Closure $next): Response
{
// Pre-processing logic (auth, validation, etc.)
if ($someConditionFails) {
return response()->json(['error' => 'Unauthorized'], 401);
}
return $next($request);
}
}
Create the directory structure:
addons/{vendor}/{name}/
├── composer.json
├── src/
│ └── ServiceProvider.php
└── config/
└── {name}.php
Set up composer.json with the extra.statamic and extra.laravel.providers keys.
Create the ServiceProvider extending AddonServiceProvider.
Register the addon in the root composer.json as a path repository and require it.
Run composer update from the project root.
If the addon has CP assets:
package.json and vite.config.js.resources/js/cp.js as the entry point.pnpm install and pnpm run build from the addon directory.If the addon has config:
config/{name}.php.register() and publish it in bootAddon().php artisan vendor:publish --tag={name}-config.If the addon has routes:
routes/cp.php and/or routes/api.php.If the addon has tests:
orchestra/testbench to require-dev.tests/TestCase.php extending AddonTestCase.phpunit.xml.composer install from the addon directory to set up its own vendor.When modifying an addon:
src/ take effect immediately (the addon is symlinked via Composer path repository).php artisan config:clear or republishing.pnpm run build from the addon directory.php artisan cache:clear.composer update from both the addon directory and the root project.