Extension Points Since 3.5.0
Extensions interact with Total CMS through the ExtensionContext object passed to register() and boot(). This page covers every available extension point.
Naming Conventions
Section titled “Naming Conventions”To avoid collisions with core T3 functions and other extensions, always prefix your Twig functions, filters, and CLI commands with your vendor name:
- Twig functions:
vendor_functionname(e.g.acme_seo_title) - Twig filters:
vendor_filtername(e.g.acme_readinglevel) - CLI commands:
vendor:commandname(e.g.acme:generate-sitemap)
If a name collision is detected at boot time, a warning is logged to extensions.log and the last registered function/filter wins. Prefixing with your vendor name prevents this.
Twig Functions
Section titled “Twig Functions”Add custom functions available in all Twig templates.
use Twig\TwigFunction;
public function register(ExtensionContext $context): void{ $context->addTwigFunction( new TwigFunction('seo_title', function (string $title, ?string $suffix = null): string { return $suffix ? "{$title} | {$suffix}" : $title; }) );}Capability: twig:functions
Usage in templates:
<title>{{ seo_title(post.title, 'My Site') }}</title>Twig Filters
Section titled “Twig Filters”Add custom filters for transforming values in templates.
use Twig\TwigFilter;
public function register(ExtensionContext $context): void{ $context->addTwigFilter( new TwigFilter('reading_level', function (string $text): string { $words = str_word_count($text); return match (true) { $words < 100 => 'Quick read', $words < 500 => 'Medium read', default => 'Long read', }; }) );}Capability: twig:filters
Usage in templates:
<span class="reading-level">{{ post.content|reading_level }}</span>Twig Globals
Section titled “Twig Globals”Add global variables accessible in all templates.
public function register(ExtensionContext $context): void{ $context->addTwigGlobal('seo', new SeoHelper());}Capability: twig:globals
Usage in templates:
{{ seo.metaTags(post) }}CLI Commands
Section titled “CLI Commands”Register custom commands for the tcms CLI tool. Commands must use namespaced names (vendor:command).
use Symfony\Component\Console\Command\Command;use Symfony\Component\Console\Input\InputInterface;use Symfony\Component\Console\Output\OutputInterface;
public function register(ExtensionContext $context): void{ $context->addCommand(new class extends Command { protected function configure(): void { $this->setName('acme:generate-sitemap'); $this->setDescription('Generate an XML sitemap'); }
protected function execute(InputInterface $input, OutputInterface $output): int { $output->writeln('Sitemap generated.'); return Command::SUCCESS; } });}Capability: cli:commands
Usage:
tcms acme:generate-sitemapRoutes
Section titled “Routes”Extensions can register three types of routes, each with different authentication and URL prefixes.
API Routes
Section titled “API Routes”Register authenticated API routes under /ext/{vendor}/{name}/.
use Slim\Routing\RouteCollectorProxy;
public function register(ExtensionContext $context): void{ $context->addRoutes(function (RouteCollectorProxy $group): void { $group->get('/status', StatusAction::class); $group->post('/analyze', AnalyzeAction::class); });}Capability: routes:api
The routes above are accessible at:
/ext/acme/seo-pro/status/ext/acme/seo-pro/analyze
Admin Routes
Section titled “Admin Routes”Register routes under /admin/ext/{vendor}/{name}/. These routes are protected by admin authentication middleware. Templates can extend admin-dashboard.twig for the admin layout.
use Slim\Routing\RouteCollectorProxy;
public function register(ExtensionContext $context): void{ $context->addAdminRoutes(function (RouteCollectorProxy $group): void { $group->get('/dashboard', MyDashboardAction::class); $group->get('/settings', MySettingsAction::class); });}Capability: routes:admin
The routes above are accessible at:
/admin/ext/acme/seo-pro/dashboard/admin/ext/acme/seo-pro/settings
Public Routes
Section titled “Public Routes”Register unauthenticated routes under /ext/{vendor}/{name}/. These routes have no authentication — use for webhooks, embeds, and endpoints that must be accessible without credentials.
use Slim\Routing\RouteCollectorProxy;
public function register(ExtensionContext $context): void{ $context->addPublicRoutes(function (RouteCollectorProxy $group): void { $group->post('/webhook', WebhookAction::class); $group->get('/embed/{id}', EmbedAction::class); });}Capability: routes:public
The routes above are accessible at:
/ext/acme/seo-pro/webhook/ext/acme/seo-pro/embed/{id}
Admin Navigation
Section titled “Admin Navigation”Add items to the admin sidebar.
use TotalCMS\Domain\Extension\Data\AdminNavItem;
public function register(ExtensionContext $context): void{ $context->addAdminNavItem(new AdminNavItem( label: 'SEO Pro', icon: 'seo', url: '/ext/acme/seo-pro/dashboard', permission: 'admin', priority: 50, ));}Capability: admin:nav
The priority field controls ordering (lower numbers appear first). The permission field controls visibility (admin = admin users only).
Dashboard Widgets
Section titled “Dashboard Widgets”Add widgets to the admin home screen.
use TotalCMS\Domain\Extension\Data\DashboardWidget;
public function register(ExtensionContext $context): void{ $context->addDashboardWidget(new DashboardWidget( id: 'seo-score', label: 'SEO Score', template: 'widgets/seo-score.twig', position: 'main', priority: 30, ));}Capability: admin:widgets
The template path is relative to the extension’s templates/ directory. Position is main or sidebar.
Custom Field Types
Section titled “Custom Field Types”Register new field types for use in collection schemas.
public function register(ExtensionContext $context): void{ $context->addFieldType('colorpicker', Acme\Fields\ColorPickerField::class, 'color');}Capability: fields
The class must extend TotalCMS\Domain\Admin\FormField\FormField. The third
argument declares the default schema property type used when an author leaves
the property’s type blank — should be one of SchemaData::PROPERTY_TYPES
(e.g. string, color, array). Defaults to string if omitted.
Once registered, the field type can be used in schemas:
{ "properties": { "accentColor": { "field": "colorpicker" } }}Event Listeners
Section titled “Event Listeners”Subscribe to content events. See Events for the full event reference.
public function register(ExtensionContext $context): void{ $context->addEventListener('object.created', function (array $payload): void { // $payload contains: collection, id $this->notifyWebhook($payload); });}Capability: events:listen
Container Definitions
Section titled “Container Definitions”Register services in the DI container for dependency injection.
public function register(ExtensionContext $context): void{ $context->addContainerDefinition( SeoAnalyzer::class, fn () => new SeoAnalyzer() );}Capability: container
Page Middleware
Section titled “Page Middleware”Register middleware that builder pages can opt into via their middleware field. Useful for auth gates, rate limits, geo redirects, A/B splits, or anything else that needs to run before a page renders. The middleware can short-circuit (return a response — auth redirect, 429, etc.) or pass through (return null) and let the page render.
use Psr\Http\Message\ResponseInterface;use Psr\Http\Message\ServerRequestInterface;use TotalCMS\Domain\Builder\Data\PageData;use TotalCMS\Domain\Builder\PageMiddleware\PageMiddlewareInterface;
class GeoRedirect implements PageMiddlewareInterface{ public function __construct(private readonly GeoIPService $geo) {}
public function handle(ServerRequestInterface $request, PageData $page): ?ResponseInterface { if ($this->geo->countryFor($request) === 'EU') { return (new \Nyholm\Psr7\Factory\Psr17Factory()) ->createResponse(302) ->withHeader('Location', '/eu' . $request->getUri()->getPath()); }
return null; }}public function register(ExtensionContext $context): void{ $context->addContainerDefinition(GeoRedirect::class, fn ($c) => new GeoRedirect( $c->get(GeoIPService::class), )); $context->addPageMiddleware('geo-redirect', GeoRedirect::class);}Once registered, geo-redirect shows up in the page form’s middleware multiselect. Admins choose which pages use it; the runner invokes it in the order listed on each page.
Naming: lowercase letters, digits, hyphens (e.g. geo-redirect, staff-only, rate-limit). Names are stable contract — once shipped, a rename will break sites that have it in their page records.
Failure modes:
- An unknown name in a page’s middleware list (typo, uninstalled extension) is logged and silently skipped.
- A middleware that throws causes the runner to return a 500 — fail-closed for security.
Capability: page-middleware
See the Page Middleware section in the Builder overview for the user-facing perspective.
Form Actions
Section titled “Form Actions”Register custom form action types that fire after a form save. The JavaScript form processor dispatches the action to an extension-owned API route — no core JS changes needed per provider.
use TotalCMS\Domain\Extension\Data\FormAction;
public function register(ExtensionContext $context): void{ // Register the action type — tells core "slack" is a valid form action $context->addFormAction('slack', new FormAction( name: 'slack', route: '/ext/acme/slack-notify/send', label: 'Slack Notification', ));
// Register the API route that handles the action $context->addRoutes(function ($group): void { $group->post('/send', SlackNotifyAction::class); });}Capability: form-actions
Edition requirement: Extension form actions require the Pro edition. On lower editions, actions matching extension-registered types are silently filtered from the form’s action list.
The action handler receives a POST with { data: <formData>, ...actionConfig } — the form data plus all properties from the action’s JSON config. For example, a collection’s .meta.json might define:
{ "formSettings": { "newActions": [ { "action": "slack", "channel": "#orders", "message": "New order from {{ data.name }}" } ] }}The handler receives data (the saved form fields), channel, message, and any other properties the operator configured. Parse what you need, ignore the rest.
Naming: Use your vendor name or a distinctive slug (e.g. slack, discord, ntfy). The name appears in collection action configs and must be stable once shipped.
See the bundled Pushover extension for a complete working example.
Assets (CSS / JS)
Section titled “Assets (CSS / JS)”Extensions can register CSS or JavaScript files for the admin interface and/or public pages. Each surface has its own registration method and capability:
public function register(ExtensionContext $context): void{ // Admin interface only $context->addAdminAsset('css', 'styles/admin.css'); $context->addAdminAsset('js', 'scripts/admin.js');
// Public pages only $context->addFrontendAsset('css', 'styles/widget.css'); $context->addFrontendAsset('js', 'scripts/widget.js');}Capabilities: admin:assets, frontend:assets
Paths are relative to the extension’s assets/ directory. For example, the CSS file above would resolve to tcms-data/extensions/acme/seo-pro/assets/styles/admin.css and be served from /ext/acme/seo-pro/assets/styles/admin.css with an mtime-based cache-busting query string.
Optional parameters
Section titled “Optional parameters”Both methods accept the same set of options:
$context->addFrontendAsset( type: 'js', path: 'scripts/widget.js', position: 'body', // 'head' | 'body' | null — null uses the default module: true, // load as <script type="module"> (default true) preload: true, // emit a <link rel="modulepreload"> hint in the head version: null, // override the cache-bust query string (default: file mtime));Defaults: CSS goes in the head, JS goes in the body, module scripts emit type="module", no preload, mtime-based cache busting.
Where they render
Section titled “Where they render”Extension assets are merged with Total CMS core assets and emitted by these Twig helpers in your templates:
| Surface | Helper | Typical placement |
|---|---|---|
| Public pages | {{ cms.assetsHead() }} | inside <head> |
| Public pages | {{ cms.assetsBody() }} | just before </body> |
| Admin pages | {{ cms.adminAssetsHead() }} | inside <head> (already wired by core admin templates) |
| Admin pages | {{ cms.adminAssetsBody() }} | just before </body> (already wired) |
For the admin interface there’s nothing to do — core admin templates already call the helpers. For public pages, your theme template needs to call cms.assetsHead() / cms.assetsBody() for extension frontend assets to render.
Within each helper, output ordering is:
- Stylesheets first.
- Preload hints (
<link rel="preload">/<link rel="modulepreload">) — always emitted in the head regardless of the asset’s ownposition. - Script tags last.
Settings
Section titled “Settings”Read per-extension settings. Settings are stored in tcms-data/.system/extension-settings/.
public function boot(ExtensionContext $context): void{ $apiKey = $context->setting('api_key', ''); $allSettings = $context->settings();}Settings access is not a separate capability — $context->setting() and $context->settings() are always available regardless of permission state.
Define a settings_schema in your manifest to enable a settings form in the admin UI. Settings are managed by admins through the extension settings page in the dashboard.
Service Resolution (Boot Phase)
Section titled “Service Resolution (Boot Phase)”During boot(), resolve any service from the DI container:
public function boot(ExtensionContext $context): void{ $config = $context->get(\TotalCMS\Support\Config::class); $cache = $context->get(\TotalCMS\Domain\Cache\CacheManager::class);}Only use $context->get() in boot(), never in register(). The container is not fully built during registration.
Logging
Section titled “Logging”Extensions can write to the shared extensions.log file (tcms-data/logs/extensions.log) using the same logger Total CMS uses internally. Get it from the context with $context->logger() — it returns a Psr\Log\LoggerInterface.
public function register(ExtensionContext $context): void{ $logger = $context->logger();
$context->addEventListener('object.created', function (array $payload) use ($logger): void { $logger->info('[acme/starter] object.created', $payload); });}
public function boot(ExtensionContext $context): void{ $context->logger()->debug('[acme/starter] booted');}PSR-3 levels are available: debug, info, notice, warning, error, critical, alert, emergency. Pass a context array as the second argument for structured fields — Monolog appends them to the formatted line.
Logger access is not a separate capability — $context->logger() is always available, in both register() and boot().
Prefix log messages with your extension id (e.g. [acme/starter]) so multi-extension logs stay readable. All extensions share the extensions channel and the same rotating log file.
MCP Prompts
Section titled “MCP Prompts”Register prompts that the MCP server exposes to AI agents via prompts/list and prompts/get. Use when the extension ships prompts as PHP code rather than relying on operator-authored objects in the mcp-prompt collection.
use Mcp\Schema\Content\PromptMessage;use Mcp\Schema\Content\TextContent;use Mcp\Schema\Enum\Role;use Mcp\Schema\Prompt;
public function register(ExtensionContext $context): void{ $context->registerMcpPrompt( new Prompt( name: 'acme_audit_links', description: 'Audit broken links on any page.', arguments: [ new \Mcp\Schema\PromptArgument('url', 'The URL to audit', required: true), ], ), handler: fn (array $arguments = []): array => [ new PromptMessage( Role::User, new TextContent('Check all links on: ' . ($arguments['url'] ?? '')), ), ], access: 'admin', );}Capability: mcp:prompts
Access: The optional third argument $access sets visibility — 'admin' (default), 'authenticated', or 'public'. The default keeps prompts private to admin-persona callers. Choose 'public' only when the prompt body contains no site-private data.
Collision policy: If a prompt name collides with a collection-stored prompt, the collection-stored version wins — the extension’s prompt is logged and skipped.
Search Providers
Section titled “Search Providers”Register a custom search provider that replaces or supplements T3’s built-in text search. The provider handles indexing (on object create/update/delete events) and querying (from MCP search tools, future REST endpoints, or site-wide search).
use TotalCMS\Domain\Search\Service\SearchProvider;use TotalCMS\Domain\Search\Data\SearchQuery;use TotalCMS\Domain\Search\Data\SearchResult;
class MySearchProvider implements SearchProvider{ public function id(): string { return 'my-search'; } public function label(): string { return 'My Search'; }
public function search(SearchQuery $query): array { // Query your search backend, return list<SearchResult> return []; }
public function index(string $collection, string $id, array $data): void { // Index one object in your backend }
public function delete(string $collection, string $id): void { // Remove one object from your backend }
public function isAvailable(): bool { // Fast health check — cache with short TTL (on the hot path) return true; }}public function register(ExtensionContext $context): void{ $context->registerSearchProvider(new MySearchProvider());}Capability: mcp:search
Provider ids must be unique across all extensions + the built-in text provider. The registrar logs and skips collisions during boot. When isAvailable() returns false, SearchService silently falls back to text search. Throwing from search() also triggers the fallback. Throwing from index() or delete() enqueues a retry job.
See the bundled Algolia Search extension for a complete working example.