ArtisanPack UI
DocsVisual EditorHooks and Events

Hooks and Events

Visual Editor v1.18.0

The visual editor exposes extension points through four mechanisms: - PHP filters — for value transformation (resource map, site-editor entities...

The visual editor exposes extension points through four mechanisms:

  • PHP filters — applyFilters('ap.visualEditor.*', ...) for value transformation (resource map, site-editor entities, server-rendered block HTML, etc.)
  • PHP actions — doAction('ap.visualEditor.*', ...) for side effects
  • JavaScript filters — addFilter('ap.visualEditor.*', ...) via @wordpress/hooks for editor-side extension (document panels, background controls, canvas styles, Query Loop post types)
  • Browser events — CustomEvent dispatched on window for client-side integration (autosave, change, save)

All PHP hooks use the global helpers from artisanpack-ui/hooks. All browser events are plain CustomEvent instances dispatched on window.

Hook rename (v1.5.0, #664). Every visual-editor hook now uses camelCase. The old kebab-case names (ap.visual-editor.*, ap.icons.register-icon-sets, visual_editor.pre_publish_checks) remain functional through deprecateHook() aliases that route callbacks to the canonical name, but log a deprecation notice the first time each alias resolves per process. New code should use the camelCase names documented below. See src/Support/HookAliases.php for the full rename table.


PHP filters

ap.visualEditor.resources

Register slug → Eloquent model class mappings used by /visual-editor/api/{resource}/{id}/content.

Signature: array $resources -> array

addFilter('ap.visualEditor.resources', function (array $resources): array {
    return array_merge([
        'posts' => App\Models\Post::class,
    ], $resources);
});

Filter contributions are merged with config('artisanpack.visual-editor.resources') the first time a request resolves the map (since v1.17.1). Static config wins on key collision — host-app overrides always take precedence over package contributions.

Models must use ArtisanPackUI\VisualEditor\Concerns\HasBlockContent. Invalid entries surface as InvalidArgumentException on first request, not at boot — contributor packages standalone never trip host boot.

See Content Model#2-the-resource-map for the full contract.


ap.visualEditor.templates / templateParts / patterns / navigation

Register site-editor entities at runtime. Each filter slug merges into the matching config('artisanpack.visual-editor.site-editor.*') array.

Signature: array $entries -> array

addFilter('ap.visualEditor.templates', function (array $templates): array {
    return array_merge([
        'single' => [
            'slug'    => 'single',
            'title'   => 'Single Post',
            'content' => ['raw' => '...', 'blocks' => [...]],
            'source'  => 'theme',
        ],
    ], $templates);
});

Static config wins on key collision. Since v1.17.1 each filter runs the first time its resolver is used in a request, not at boot, so requests that never touch the site editor skip it; callbacks added in any provider's boot() are still seen, and long-lived workers such as Octane pick up changes on the next request. cms-framework uses these filters to expose its Template, TemplatePart, Pattern, and Menu models to the editor.

Entity shape contracts are documented in config/visual-editor.php and Configuration#site-editor.


ap.visualEditor.registerFontSources

Register Font Library sources at runtime. The filter receives the FontSourceRegistry instance and must return it after registering any FontProvider implementations.

Signature: FontSourceRegistry $registry -> FontSourceRegistry

use ArtisanPackUI\VisualEditor\Fonts\Registries\FontSourceRegistry;

addFilter('ap.visualEditor.registerFontSources', function (FontSourceRegistry $registry): FontSourceRegistry {
    $registry->register(new FontshareProvider());

    return $registry;
});

The filter is layered over the registry with extend(), so it fires the first time the registry resolves — after every provider has booted, regardless of provider order. A non-registry return value is ignored. See Font Providers for the full contract and a worked example, and Fonts for the user-facing Font Library.


ap.visualEditor.loginout.envelope

Rewrite the resolved envelope emitted by the artisanpack/loginout block before render. Useful for swapping in URL::signedRoute(), per-tenant routes, or SSO redirects.

Signature: array $envelope, array $context -> array

addFilter('ap.visualEditor.loginout.envelope', function (array $envelope, array $context): array {
    if ($context['action'] === 'logout') {
        $envelope['url'] = URL::signedRoute('logout.get');
    }
    return $envelope;
}, 10, 2);

ap.icons.registerIconSets

The editor chrome resolves icons through artisanpack-ui/icons. Register additional icon sets in a service provider.

addFilter('ap.icons.registerIconSets', function (IconSetRegistration $registry) {
    $registry->addSet(__DIR__ . '/../../resources/icons', 'mypackage');
    return $registry;
});

See the artisanpack-ui/icons docs for the full contract.


ap.visualEditor.renderedBlock

Last-mile PHP filter applied to the rendered HTML of every block (static or dynamic) inside packages/visual-editor-renderer-blade's BlockRenderer::renderBlock(). Runs on the server, at render-time, on every request that emits post content — not at save-time and not in JavaScript. Callbacks decorate output without each host having to fork the renderer.

Signature: string $html, string $blockName, array $attributes -> string

addFilter('ap.visualEditor.renderedBlock', function (string $html, string $name, array $attributes): string {
    if ('artisanpack/group' !== $name) {
        return $html;
    }
    // …wrap, sanitize, inject data-* attributes, etc.
    return $html;
}, 10, 3);

Recursion: the renderer walks inner blocks through the same code path, so the filter fires once per block at every level of the tree. A callback that wraps a container block will also wrap every descendant unless it gates on $name / $attributes.

Attributes shape: $attributes is post-normalization and may already carry package-internal _resolved* side-channel keys (site-meta, loginout). Treat those as read-only; they're not stable public attributes.


ap.visualEditor.beforeRender

PHP filter applied inside BlockRenderer::renderBlock() before a block renders, giving hosts a chance to mutate the block's attributes. Fires after site-meta / loginout stamping so the resolved _resolved* keys are already present. Sibling of rendered-block (which runs after render).

Signature: array $attributes, string $blockName -> array

addFilter('ap.visualEditor.beforeRender', function (array $attributes, string $name): array {
    if ('core/paragraph' !== $name) {
        return $attributes;
    }
    $attributes['content'] = strtoupper($attributes['content'] ?? '');
    return $attributes;
}, 10, 2);

Non-array returns are ignored so a misbehaving callback can't blank the block.


ap.visualEditor.blockRegistered

PHP action fired at the end of block-type registration, from BlockTypeRegistry::register(). Covers both block.json-driven registration and programmatic VisualEditor::registerBlockType() calls.

Signature: string $name, array $config -> void

addAction('ap.visualEditor.blockRegistered', function (string $name, array $config): void {
    // e.g. register block variations, per-block binding sources,
    // per-block category assignment, etc.
}, 10, 2);

ap.visualEditor.postSaved / ap.visualEditor.postPublished

PHP actions fired by WpEntityController after every POST/PUT persistence (postSaved) and additionally when the current save transitions the record's status into the WP-canonical publish value (postPublished). Fire on both create-with-publish and non-publish → publish updates; re-saves of an already-published record fire postSaved only.

Signature: int|string $postId, array $blocks -> void

addAction('ap.visualEditor.postPublished', function ($postId, array $blocks): void {
    // e.g. queue a search-index rebuild, send a webhook, purge a CDN, …
}, 10, 2);

$blocks reflects the tree the client submitted in the current request under content.blocks; missing / malformed payloads collapse to [].


ap.visualEditor.editorConfig

PHP filter applied by VisualEditorComponent on the assembled editor config before the Blade template emits its data-* attributes. $screen identifies the surface — 'post' for the current component; a future site-editor surface would pass 'site'.

Signature: array $config, string $screen -> array

addFilter('ap.visualEditor.editorConfig', function (array $config, string $screen): array {
    if ('post' !== $screen) {
        return $config;
    }
    $config['apiBase'] = '/custom/api';
    return $config;
}, 10, 2);

Only known keys are re-hydrated back onto the component's typed props — extra keys are ignored and non-array returns leave the assembled config intact.

Since v1.15.0, the config also carries canEditBlockCss (bool, the result of the visual-editor.edit-block-css gate for the current user), customCssAllowUrls (bool, from effects.custom_css_allow_urls) and blockStyles (the theme block style variations for the Styles picker). Overriding canEditBlockCss only shows or hides the Custom CSS field; the server still enforces the gate on save.


ap.visualEditor.query.postTypes

Since v1.14. PHP filter that supplies the options for the Query Loop block's Post type picker (#847). The result is stamped onto both the post-editor and site-editor mounts as data-query-post-types, so hosts can offer their own content types without shipping a JS bundle. The JavaScript filter of the same name (below) runs on top of this list.

Signature: array $options -> array, where each option is ['value' => string, 'label' => string].

addFilter('ap.visualEditor.query.postTypes', function (array $options): array {
    $options[] = ['value' => 'case-study', 'label' => __('Case Studies')];

    return $options;
});

The filter receives the defaults (Posts, Pages). Entries without a non-empty string label, or whose value isn't 1–64 letters, digits, - or _, are dropped. Duplicate values keep the first entry, so relabel a default by mapping over the list instead of appending a second row. A non-array return, or a list with no valid entries, falls back to the defaults.

A queryPostTypes value set through ap.visualEditor.editorConfig goes through the same rules.

The picker only controls what authors can choose. Whatever postType a saved block carries is resolved by the bound QueryResolverContract on the front end and in the canvas preview.


ap.visualEditor.localeData

Since v1.15.1 (#896). Supplies or adjusts the Jed locale data the editor installs for its artisanpack-visual-editor text domain. Callbacks receive the data resolved from lang/vendor/visual-editor/{locale}.json (or null) and the locale, and return locale data, a wp i18n make-json file, or null for none. See Translations.

addFilter( 'ap.visualEditor.localeData', function ( ?array $data, string $locale ): ?array {
    $path = resource_path( "i18n/editor-{$locale}.json" );

    return $data ?? ( is_file( $path ) ? json_decode( file_get_contents( $path ), true ) : null );
} );

ap.visualEditor.patternRender

PHP filter applied by PatternAdapter::toArray() on the rendered raw content of a pattern before it ships to the editor / consumer. Runs on every read.

Signature: string $html, string $slug, array $context -> string

addFilter('ap.visualEditor.patternRender', function (string $html, string $slug, array $context): string {
    // $context carries: source, synced, categories, block_types, post_types
    if ('user' !== $context['source']) {
        return $html;
    }
    return str_replace('{{year}}', (string) date('Y'), $html);
}, 10, 3);

Non-string returns are ignored so the underlying rawContent survives.

ap.visualEditor.patternPreviewCacheVary

Since v1.13. PHP filter that adds vary data to the pattern preview cache key (PatternPreviewCache). The key already covers the pattern's content, the active theme, the viewer (user class and id), the locale, the request host, the global-styles version and the package version. Return extra scalars when previews depend on something else, such as a role or a tenant.

Signature: array $vary -> array

addFilter('ap.visualEditor.patternPreviewCacheVary', function (array $vary): array {
    $vary['role'] = auth()->user()?->role;

    return $vary;
});

The filter receives an empty array. A non-array or empty return adds nothing. See Patterns.

ap.visualEditor.businessInfo

Since v1.9. PHP filter that supplies the business envelope consumed by the artisanpack/business-hours, artisanpack/business-address, artisanpack/business-phone, and artisanpack/business-email blocks (#761). Fires at render time and in the GET /visual-editor/api/business-info REST endpoint.

Signature: array $info, array $context -> array

See docs/blocks/Business-Info.md for the envelope shape and the map-URL fallback chain.

ap.visualEditor.reviews.collectReviews

Since v1.9. PHP filter that collects review payloads for the artisanpack/reviews block (#763). Every host / package / plugin / theme can contribute by hooking the filter; the block itself ships zero reviews.

Signature: array $reviews, array $attrs -> array

Each returned entry is normalized to {reviewer, quote, rating, source, url, date, avatar_url}; url and avatar_url are scheme-allowlisted to http, https, and mailto. The block memoises the filter result per instance (keyed on source + limit) so a repeated Reviews reference on the same page invokes each host resolver only once.

The block emits no Review / AggregateRating JSON-LD by design — self-serving review markup on the reviewed entity is ineligible for Google's rich result. See docs/blocks/Reviews.md for the payload shape.

ap.visualEditor.terms

Since v1.15.2 (#960). PHP filter that supplies the terms the artisanpack/categories (Terms List) block lists for a taxonomy other than category and post_tag, which the block reads from cms-framework itself. Fires at render time with the block's taxonomy attribute.

Signature: null $terms, string $taxonomy, array $attrs -> array|null

Return a list, in display order, of ['id' => int, 'name' => string, 'url' => string, 'count' => int, 'parentId' => int|null] (count defaults to 0, parentId to null), or leave the null default for a taxonomy you don't own. count should count published records only. Entries without an integer id, a name or a relative / http(s) url are dropped. The block applies showEmpty, showOnlyTopLevel and showHierarchy (via parentId) itself. A null or empty answer renders the block's empty shell.

addFilter( 'ap.visualEditor.terms', function ( $terms, string $taxonomy, array $attrs ) {
    if ( 'package-category' !== $taxonomy ) {
        return $terms;
    }

    return PackageCategory::withCount( 'publishedPackages' )->orderBy( 'name' )->get()
        ->map( fn ( $term ) => [
            'id'       => $term->id,
            'name'     => $term->name,
            'url'      => url( '/packages?package-category=' . $term->slug ),
            'count'    => $term->published_packages_count,
            'parentId' => $term->parent_id,
        ] )->all();
} );

The item whose URL matches the request (same path, and the same value for every query parameter the URL carries, ignoring page, paged and query-{id}-page) gets current-cat and aria-current="page".

ap.visualEditor.terms.allUrl

Since v1.15.2 (#960). PHP filter that supplies the link of the Terms List block's "All" item (showAll). For category and post_tag the default is the posts index (visual-editor.categories.posts_index_url, /blog by default, resolved through url()); for any other taxonomy it is null, which drops the item.

Signature: string|null $url, string $taxonomy, array $attrs -> string|array|null

Return a URL, or ['url' => string, 'count' => int] to also give the item's count. For category / post_tag the count defaults to every published post. Other taxonomies have no default count, because summing the term counts double-counts records with several terms: return the total of published records yourself, or the item shows no count. The item is current when no term is, the request is on its URL, and the request carries none of the query parameters the terms' URLs filter by.

ap.visualEditor.bindings.allowedAttributes

Since v1.15.0 (#869). PHP filter that adjusts which attributes of a related model block bindings may read when the opt-in allowlist is enforced (bindings.enforce_allowlist). It runs on the list merged from the model's DefinesBindableAttributes::bindableAttributes() and the bindings.allowed_attributes config, and its result is final. It isn't called while enforcement is off.

Signature: array $allowed, Model $model, string $path -> array

addFilter('ap.visualEditor.bindings.allowedAttributes', function (array $allowed, Model $model, string $path): array {
    if ($model instanceof \App\Models\User) {
        $allowed[] = 'avatar_url';
    }

    return $allowed;
}, 10, 3);

$path is the full binding path (for example author.avatar_url). Return ['*'] to allow every attribute on the model. Non-string and empty entries are dropped, and a non-array return allows nothing. See Block Bindings.

ap.visualEditor.blockStyles

Since v1.15.0 (#876). PHP filter applied to the merged list of block style variations before they reach the editor's Styles picker, the canvas and the front-end CSS. The list is merged from, lowest precedence first, theme.json styles.blocks.*.variations, the styles/*.json partials (the theme's and block_styles.directories), and VisualEditor::registerBlockStyle() registrations. A later source has already replaced an earlier one on the same block + slug, and a code registration without a style keeps the theme's style and only relabels it.

Signature: array $styles -> array, where each entry is:

[
    'block'     => 'core/button',   // block name
    'slug'      => 'outline-pill',  // becomes `.is-style-outline-pill`
    'label'     => 'Outline pill',  // Styles picker label
    'style'     => [ /* theme.json style node: color, border, elements, blocks, … */ ],
    'isDefault' => false,
]
addFilter('ap.visualEditor.blockStyles', function (array $styles): array {
    return array_values(array_filter(
        $styles,
        fn (array $style): bool => 'core/quote' !== $style['block'],
    ));
});

The result is sanitized again: entries without a valid block or slug are dropped, an empty label falls back to one built from the slug, and the last entry for a block + slug pair wins. A non-array return keeps the unfiltered list. See Block style variations.

ap.themes.editorStyles

Since v1.15.0 (#879), consumed. cms-framework's filter on a theme's Theme::editorStyles() entries. The editor canvas CSS endpoint (GET /visual-editor/api/global-styles/css) and pattern previews run the active theme's entries through it, with the same arguments cms-framework's @themeEditorStyles directive uses, before inlining them.

Signature: array $entries, string $themeSlug -> array

Only entries that resolve to a .css file inside the theme's assets/ directory reach the canvas. Absolute, root-relative and CDN URLs are skipped there by design, and so is an entry whose media isn't a safe media query. Handles dedupe as the directive does: the first entry claims its handle, even when the canvas skips it.

ap.cmsFramework.siteEditor.blockContent

Since v1.15.0, consumed (the filter ships with cms-framework 2.15.0). cms-framework runs every block tree its Site Editor API is about to store (templates, template parts, synced blocks and block patterns, plus theme-file copies such as PatternResolver::cloneToUser()) through this filter. Visual-editor hooks it to apply the Custom CSS guard (CustomCssGuard::filter()) for users without the visual-editor.edit-block-css ability.

Signature: array $blocks, ?array $storedBlocks, string $context -> array

$storedBlocks is the record's stored tree, or the theme file's blocks when the save copies or first overrides a theme file. Custom CSS found there is kept, and vouched for so the model-level guard keeps it too. $context is template, template-part, block or block-pattern. Older cms-framework versions don't fire the filter; the model-level guard still covers their saves. See Access Gate.

TocResolver heading-anchor pre-render pass

Since v1.9. Not a filter, but observable: TocResolver walks the block tree before render and stamps a stable, unique slug into the anchor attribute of every core/heading / artisanpack/heading that lacks one (existing author-set anchors are preserved; duplicate slugs receive a -1, -2, … suffix), plus a _resolvedItems payload onto every artisanpack/toc block filtered by its minLevel / maxLevel range. Hosts that traverse the tree after resolution will see the stamped anchors.

Since v1.15.0, an auto slug is reduced to the characters every renderer keeps in an id ([A-Za-z0-9_:.-], runs of - collapsed) before the uniqueness check, so the TOC never links to an id the renderer doesn't print. A heading whose letters all drop out (for example 日本語) falls back to heading, then heading-1, heading-2, …. This changes some existing auto anchors, such as creme--x → creme-x. Author-set anchors are unchanged.


JavaScript filters

Editor-side filters run through @wordpress/hooks inside the browser. Register callbacks with addFilter(name, namespace, callback) at editor bootstrap (the moment the app imports your package's entry module is enough — the editor evaluates each filter on every relevant render).

ap.visualEditor.backgroundControls

Contribute panels to the shared background / appearance area of any block that opts into a background support (supports.background for image / gradient backgrounds, or supports.color.background for the color background). Applied by the built-in editor.BlockEdit HOC so external packages don't have to enumerate target blocks or wrap BlockEdit themselves — the target-block decision lives in the editor.

Signature: BackgroundControl[] -> BackgroundControl[]

type BackgroundControl = {
    id: string           // stable, namespaced (e.g. 'liquid-glass')
    label: string        // panel heading (translate before returning)
    priority?: number    // sort key, default 10, lower first
    render: () => ReactNode
}

type BackgroundControlContext = {
    // Frozen — call setAttributes to modify.
    attributes: Readonly<Record<string, unknown>>
    setAttributes: (attrs: Record<string, unknown>) => void
    clientId: string
    blockName: string
    // Deep-cloned from the block-type registry — treat as read-only.
    blockSupports: Readonly<Record<string, unknown>>
}

Example:

import { addFilter } from '@wordpress/hooks'

addFilter(
    'ap.visualEditor.backgroundControls',
    'artisanpack-ui/liquid-glass',
    (controls, { attributes, setAttributes, blockSupports }) => {
        // The HOC gates on either `supports.background` (image / gradient
        // background) or `supports.color.background` (color background),
        // so a block with only text-color support won't fire this filter.
        // Narrow further to the shape your control cares about:
        const hasBackground =
            Boolean(blockSupports.background) ||
            (typeof blockSupports.color === 'object' &&
                blockSupports.color !== null &&
                (blockSupports.color as Record<string, unknown>).background !==
                    false)

        if (! hasBackground) {
            return controls
        }

        return [
            ...controls,
            {
                id: 'liquid-glass',
                label: 'Liquid Glass',
                priority: 20,
                render: () => (
                    <LiquidGlassPanel
                        value={attributes.liquidGlass}
                        onChange={(liquidGlass) => setAttributes({ liquidGlass })}
                    />
                ),
            },
        ]
    },
)

Controls are deduped by id before sorting — a later addFilter at the same id overrides an earlier one regardless of priority, mirroring how @wordpress/hooks composes filters. Surviving controls are then sorted by priority (default 10, lower first) with ties falling back to registration order.

If a filter callback throws, the exception is caught, logged to console.error, and the block renders without any contributed panels for that render — one buggy third-party filter can't trip Gutenberg's per-block crash boundary. Callbacks with a non-numeric priority are silently rejected.

Attributes are declared the standard Gutenberg way (blocks.registerBlockType filter). For static blocks, save-side rendering continues to go through blocks.getSaveContent.extraProps; for dynamic blocks, use the block's PHP render or the ap.visualEditor.renderedBlock PHP filter (see the PHP filters section above).


ap.visualEditor.documentPanels

Contribute panels to the Document tab of the inspector sidebar. Runs against an empty descriptor list at inspector render time; the return value replaces it.

Signature: DocumentPanelSpec[] -> DocumentPanelSpec[]

type DocumentPanelSpec = {
    id: string
    title: string
    initialOpen?: boolean
    order?: number       // sort key, default 100, lower first
    render: () => ReactNode
}

Descriptors are sorted by order (default 100, lower first) with ties falling back to registration order and deduped by id (last-wins). The slot-fill companion <PluginDocumentSettingPanel> mirrors the WordPress component of the same name for panels rendered as part of the editor tree instead of registered at bootstrap.


ap.visualEditor.query.postTypes

Since v1.14. Contribute options to the Query Loop block's Post type picker (#847). Applied every time the inspector renders, over the server-provided list from the PHP filter of the same name (or Posts / Pages when the mount carries no list).

Signature: QueryPostTypeOption[] -> QueryPostTypeOption[] where QueryPostTypeOption = { label: string; value: string }.

import { addFilter } from '@wordpress/hooks'
import { __ } from '@wordpress/i18n'

addFilter(
    'ap.visualEditor.query.postTypes',
    'my-plugin/case-studies',
    (options) => [
        ...options,
        { label: __('Case Studies', 'my-plugin'), value: 'case-study' },
    ],
)

Entries are validated the same way as on the server, and duplicate values keep the first entry. A non-array or empty result falls back to the unfiltered list. A callback that throws is logged to console.error and the unfiltered list is used.

If a block's saved postType isn't in the final list (for example, a type that was renamed or removed), it's appended as <slug> (unavailable). The block keeps its saved value instead of switching to post when it's opened. New Query Loop blocks default to post, so if you remove Posts from the list, new blocks show post (unavailable) until the author picks another type.


ap.visualEditor.canvasStyles

Contribute additional stylesheets injected into the block canvas iframe. Applied once at module-load time inside the editor bundle and frozen for the session — register your filter before importing the editor entry module.

Signature: CanvasStyle[] -> CanvasStyle[] where CanvasStyle = { css: string }.

Entries whose css isn't a string are dropped. Non-array return values fall back to the built-in list.


Browser events

The editor dispatches three CustomEvents on window whenever content state changes.

Event When detail shape
ve:editor:change Debounce window closes, right before autosave fires. { resource, id, blocks }
ve:editor:autosave Debounce-triggered save returns 200. { resource, id, blocks, updatedAt }
ve:editor:save Explicit save (⌘S or topbar) returns 200. { resource, id, blocks, updatedAt }

resource and id match the Blade component's data-resource / data-id attributes, so a single listener can disambiguate multiple editors on the same page.

Listening from JavaScript

window.addEventListener('ve:editor:autosave', (event) => {
    const { resource, id, blocks, updatedAt } = event.detail;
    console.log(`Autosaved ${resource}/${id} at ${updatedAt}`);
});

Listening from Alpine / Livewire

<div
    wire:ignore
    @ve:editor:change.window="$wire.set('dirty', true)"
    @ve:editor:autosave.window="$wire.handleAutosaved($event.detail)"
    @ve:editor:save.window="$wire.handleSaved($event.detail)"
>
    <x-visual-editor :model="$post" />
</div>

See post-editor/Livewire Integration for the full Livewire recipe and post-editor/Inertia Integration for the Inertia equivalent.


apve_query_variant_match_<name>

Resolve a custom-kind matcher on an artisanpack/post-variant block (see blocks/Post Variants). The filter name is composed from the variant's matcher.value of the form callback:<name>.

Signature: bool $matches, object $post, array $context -> bool

use function ArtisanPackUI\Hooks\addFilter;

addFilter('apve_query_variant_match_premium', function (bool $matches, object $post, array $context): bool {
    // $context => ['index' => int (0-based loop position), 'total' => int]
    return true === ($post->is_premium ?? false);
}, 10, 3);

Return true to make the variant match the current post. Variant precedence is fixed (instance > position > pattern > meta > custom > base) so a callback can be overridden by a higher-tier rule.


Authorization gates

The site editor's access surface is controlled by a single boot-time contract:

namespace ArtisanPackUI\VisualEditor\SiteEditor\Gates;

interface SiteEditorAccessGate
{
    public function check(Request $request): ?Response;
}

Return null to allow; return a Response to short-circuit. Bind a custom implementation in AppServiceProvider::register():

$this->app->bind(SiteEditorAccessGate::class, App\SiteEditor\MyGate::class);

See site-editor/Access Gate for the contract and bundled implementations.

Post-editor content-authoring endpoints (the icon picker, the SVG sanitizer and pattern previews) check the visual-editor.edit-content Gate ability instead. The package defines it only when the host hasn't, so Gate::define('visual-editor.edit-content', …) replaces the default. See Access Gate.

Since v1.15.0 (#881), writing a block's Custom CSS (Effects panel) checks the visual-editor.edit-block-css Gate ability (BlockCssAccess::ABILITY). The package's default denies everyone who lacks the effects.custom_css_capability capability (default edit_block_css), and it is defined only when the host hasn't defined the ability itself. Without it the editor hides the field and every save keeps only the Custom CSS already stored on the record. See Access Gate.


See also