The Site Editor module exposes its resolved entities through the ap.visual-editor.* filter family. These filters are how the optional artisanpack-ui/visual-editor package consumes cms-framework's templates, patterns, global styles, and menus.
Added in 2.0.0.
All filter registrations are gated behind class_exists(ArtisanPackUI\VisualEditor\VisualEditor::class) so cms-framework boots cleanly when visual-editor isn't installed.
Filter registrations
| Filter name | Shape | Source |
|---|---|---|
ap.visual-editor.templates |
array<string, ResolvedEntity> keyed by slug |
TemplateResolver::all() |
ap.visual-editor.template-parts |
array<string, ResolvedEntity> keyed by slug |
TemplatePartResolver::all() |
ap.visual-editor.patterns |
array<string, array> keyed by storage slug |
PatternResolver::toFilterMap() |
ap.visual-editor.global-styles |
?ResolvedGlobalStyles (singleton, nullable) |
GlobalStylesResolver::resolve() |
ap.visual-editor.navigation |
array<string, array> keyed by location |
MenuResolver::all() |
ap.visual-editor.resources |
array<class-string, array> of registered content types |
Auto from HasBlockContent adopters |
Merge behavior
For the keyed-map filters (templates, template-parts, patterns, navigation), cms-framework's resolved map merges under the existing map so app-level config or earlier filter contributors win on key collision.
For the singleton global-styles filter, cms-framework replaces the value (returning its own ResolvedGlobalStyles when one resolves and falling through otherwise).
For resources, cms-framework merges its content-type registrations into the existing array.
Schema guards
All filter callbacks check Schema::hasTable() for their backing tables before resolving. This lets cms-framework boot during CI bootstrap and fresh installs where migrations haven't run yet. When a table is missing, the callback returns the existing filter value unchanged.
Adding your own filter consumers
The filters are dispatched via the artisanpack-ui/hooks package. Listen to them with addFilter():
use function addFilter;
addFilter('ap.visual-editor.patterns', function (array $patterns): array {
// Add a runtime-generated pattern to the editor inserter
$patterns['my-app/dynamic-pattern'] = [
'name' => 'my-app/dynamic-pattern',
'title' => 'Dynamic Pattern',
'description' => 'A pattern generated by app code.',
'content' => '<!-- wp:paragraph --><p>Hi</p><!-- /wp:paragraph -->',
'categories' => ['featured'],
'block_types' => [],
'source' => 'app',
'synced' => false,
];
return $patterns;
});
Blade directives
@cmsGlobalStyles
Renders a <style id="cms-global-styles"> block with the resolved global-styles CSS. Themes opt in by including this directive in their root layout:
<head>
@cmsGlobalStyles
</head>
The output is cached on a content-hash key; the cache busts on GlobalStyles model saves and deletes via a model observer wired in SiteEditorServiceProvider.
Framework filters
| Filter | Arguments | Since | Purpose |
|---|---|---|---|
ap.cmsFramework.globalStyles.css |
(string $css, ResolvedGlobalStyles $resolved) |
2.15.0 | Filters the emitted global-styles CSS. Runs on every GlobalStylesEmitter::emit() call, after the cache, so whatever a callback adds is never frozen into the cached CSS. Feeds @cmsGlobalStyles and GET /api/v1/global-styles/css. Use it to append rules the emitter doesn't produce, such as block style variations on hosts without visual-editor. |
ap.cmsFramework.siteEditor.blockContent |
(array $blocks, ?array $storedBlocks, string $context) |
2.15.0 | Filters the block tree a Site Editor API write is about to save. Runs on every create, update and upsert in TemplatesController, TemplatePartsController, BlocksController and BlockPatternsController, and (since 2.15.1) when PatternResolver::cloneToUser() copies a theme pattern into a user pattern (context block-pattern, with the theme file's blocks as both $blocks and $storedBlocks), before the row is written. $storedBlocks is the record's current block content (decoded; [] when it has none). When the write creates the first DB override of a theme file (template, template part or theme pattern), it is that file's parsed blocks, the version the Site Editor showed; otherwise a create passes null. $context is template, template-part, block or block-pattern. visual-editor 1.15 uses it to enforce its visual-editor.edit-block-css ability on per-block Custom CSS. |
ap.cmsFramework.abilities.siteEditor.read |
(string $ability) |
2.15.0 | Ability every Site Editor API route requires. Default manage-content. |
ap.cmsFramework.abilities.siteEditor.structure |
(string $ability) |
2.15.0 | Ability for writes to templates, template parts, global styles and menus. Default manage-themes. |
ap.cmsFramework.abilities.siteEditor.content |
(string $ability) |
2.15.0 | Ability for writes to patterns and synced blocks. Default manage-content. |
See API Reference: Authorization for how the abilities combine.
use ArtisanPackUI\CMSFramework\Modules\SiteEditor\Resolution\ResolvedGlobalStyles;
addFilter( 'ap.cmsFramework.globalStyles.css', function ( string $css, ResolvedGlobalStyles $resolved ): string {
return $css . "\n\n:root :where(.wp-block-group.is-style-card) { padding: 2rem; }";
} );
Every callback receives both arguments. A callback that returns something other than a string is ignored. The appended CSS is printed inside the directive's <style> element, so keep it to CSS you control: the filter output isn't validated the way theme.json values are.
Filtering block content before it is saved
ap.cmsFramework.siteEditor.blockContent lets a host veto or rewrite parts of a block tree that comes in through the Site Editor API, the way visual-editor's CustomCssGuard keeps only the Custom CSS already stored on a record:
addFilter( 'ap.cmsFramework.siteEditor.blockContent', function ( array $blocks, ?array $storedBlocks, string $context ): array {
if ( auth()->user()?->can( 'visual-editor.edit-block-css' ) ) {
return $blocks;
}
// Drop incoming Custom CSS on top-level blocks. A full guard would walk
// innerBlocks and restore each block's value from $storedBlocks instead.
return array_map( function ( array $block ): array {
unset( $block['attrs']['artisanpackEffects']['customCss'] );
return $block;
}, $blocks );
} );
Every callback receives all three arguments. A callback that returns something other than an array is ignored and the incoming blocks are saved as sent.
The filter runs in two places:
- API writes. A Site Editor API write runs the filter only when the request carries a
block_contentarray; a write that leaves the blocks alone, or sets them tonull, skips it. The first write that overrides a theme-file template, template part or pattern passes that file's parsed blocks (from the same resolver the index and show endpoints use) as$storedBlocks, so Custom CSS the theme ships survives the first save. Blade templates are read-only and never reach the filter. - Theme pattern clones. Since 2.15.1,
PatternResolver::cloneToUser()always runs the theme pattern's blocks through the filter, with the same blocks as$storedBlocksandblock-patternas$context. No request is involved, so a model-level guard such as visual-editor's doesn't strip the theme's Custom CSS from the clone.
Framework actions
| Action | Arguments | Since | Purpose |
|---|---|---|---|
ap.cmsFramework.siteEditor.reverted |
(string $type, ?string $slug, string $theme) |
2.16.0 | Fires after a revert deletes a DB override for the active theme. $type is template, template-part, global-styles or menu-location. $slug is the template or part slug, or the menu location key; it is null for global-styles, which has one row per theme. $theme is the theme slug the row belonged to. |
The action fires from TemplateResolver::revert(), TemplatePartResolver::revert(), GlobalStylesResolver::revert() and Menus::unassign() (which MenuResolver::revert() calls), so it covers the DELETE API endpoints as well as reverts from console commands, jobs and direct calls. It fires only when a row was actually deleted. Nothing fires when there was no override to revert, or when a deleting listener cancels the delete by returning false; in both cases the method returns false.
Use it to invalidate caches the same way you do on save:
addAction( 'ap.cmsFramework.siteEditor.reverted', function ( string $type, ?string $slug, string $theme ): void {
Cache::forget( "my-app.site-editor.{$theme}.{$type}." . ( $slug ?? 'global' ) );
} );
Model events
Since 2.16.0 (#423), reverts delete the Template, TemplatePart, GlobalStyles and MenuLocationAssignment row through the model, so eloquent.deleting and eloquent.deleted fire for reverts just as eloquent.saved does for saves. Before 2.16.0 they used a query-level delete and fired no model event.
The GlobalStyles model has a registered observer (in SiteEditorServiceProvider::boot()) that:
- On
saved— invalidates theGlobalStylesEmittercache so the next render re-emits CSS. - On
deleted— same. Since 2.16.0 this includesGlobalStylesResolver::revert()andDELETE /api/v1/global-styles.
This is the only event-driven cache invalidation in the module; the other entities resolve fresh on each request (and lean on per-request request-local caches where appropriate).

