Filter and action reference

Last updatedView as Markdown

Every extension point in Block Alchemy. Twenty-seven in total: twenty-four filters and three actions.

The user-facing ask this plugin was built around was that anyone can extend the conversion without patching it, so the list is deliberately broad — you can add a converter, add a whole new place data lives, change what carries across a replacement, add a validation rule that must not be skippable, or hook the write itself.


Registering work

block_alchemy_converters

The main extension point. Filters the registered converters.

apply_filters( 'block_alchemy_converters', array $converters ): array

Keys are what the converter matches — a block name for post_content, a layout name for post_meta, a shortcode tag for shortcode. Values are a callable, or a definition array:

add_filter( 'block_alchemy_converters', function ( $converters ) {
    $converters['acme/hero'] = 'acme_convert_hero';

    $converters['hero'] = [
        'source'   => 'post_meta',
        'meta_key' => 'page_sections',
        'callback' => 'acme_convert_meta_hero',
        'label'    => 'Hero section',
    ];

    return $converters;
} );

Anything beyond source, callback and label is passed to the source as an argument — that is how meta_key reaches Post_Meta. A malformed entry is rejected and reported by the CLI rather than throwing, so one bad registration cannot take the run down.

keep_meta

A post_meta converter may add 'keep_meta' => true. The field it read is then left in place instead of being deleted on write.

$converters['opening_hours'] = [
    'source'    => 'post_meta',
    'meta_key'  => 'page_sections',
    'callback'  => 'acme_convert_opening_hours',
    'keep_meta' => true,
];

The case for it is a block binding that reads the field on the front end, which the deletion that normally follows a conversion would empty.

It is not a way to put off converting a layout. A converter that wraps a legacy template part in a shortcode or core/html block and keeps the field for it to read is a passthrough, not a conversion: the page renders the same and is no more editable than before. A layout with no real-block converter should have no converter at all, so the post stays on its field until it has one.

It applies per meta key, not per row — a flexible field is one meta value, so there is no way to delete the rows that converted cleanly and keep the one that still needs the field. If any layout present on that post declares it, the whole key stays. A keep_meta converter registered for a layout the post does not use keeps nothing, which matters: the alternative would leave every fully converted post in the site holding a populated field for the theme to render a second time.

Keeping the field means something else must stop rendering it. The conversion wrote those rows into the content as blocks; if the theme still loops the field, each one appears twice. The kept key is reported on every run, recorded in the conversion log, and counted by wp block-alchemy log — treat it as an open item, because the field group cannot be retired while any post still has one.

block_alchemy_builtin_converters

Filters the converters that ship with the plugin. Return [] to disable all of them and register only your own.

apply_filters( 'block_alchemy_builtin_converters', array $builtin ): array

Registered at priority 5, so your own registrations at the default 10 can override or unset any of them.

Two ship today: core/freeform (classic content) and genesis-blocks/gb-columns (Genesis Blocks' Advanced Columns, to core/columns). Each matches by block name, so on a site without that content it does nothing. To turn off one and keep the other:

add_filter( 'block_alchemy_builtin_converters', function ( $builtin ) {
    unset( $builtin['genesis-blocks/gb-columns'] );

    return $builtin;
} );

block_alchemy_sources

Filters the available sources. Add one when a migration's data lives somewhere the built-in three do not reach — a custom table, a serialized option, another plugin's storage.

apply_filters( 'block_alchemy_sources', array $sources ): array

Each entry implements Block_Alchemy\Core\Source. Everything else in the pipeline — refusals, notices, validation, backups, the read-back — applies to a custom source unchanged. Add a matching clause to block_alchemy_selection_clauses or nothing will select the posts it applies to.

block_alchemy_source_order

Filters the order sources run in. Defaults to post_content, shortcode, post_meta.

apply_filters( 'block_alchemy_source_order', array $order, array $registered ): array

Order is behaviour, not preference: each source sees the content the previous one produced. post_content runs first so the shortcode source sees the blocks a converter produced rather than the markup it replaced; post_meta runs last because it appends.


Shaping a conversion

block_alchemy_post_content_data

Filters the data handed to a converter for a block in post content.

apply_filters( 'block_alchemy_post_content_data', array $data, array $block, string $name ): array

By default a converter receives attrs['data'] if present (the field-plugin convention) and otherwise attrs itself. Use this when a block stores its values somewhere else — in its inner HTML, or spread across attributes that need combining first.

block_alchemy_preserved_attributes

Filters the attributes carried from a replaced block onto its replacement.

apply_filters( 'block_alchemy_preserved_attributes', array $keys, string $name, array $source ): array

Defaults to blockVisibility, metadata, lock, anchor, style, backgroundColor, textColor. Add to this when a plugin in play stores something on the block that decides how it renders.

Removing an entry is almost always a mistake. An attribute that is not preserved is silently discarded, and a visibility attribute is the worst thing to lose — dropping one publishes a section somebody deliberately hid, which nothing downstream will flag.

The engine applies this list to the block it replaces. A converter that consumes child blocks of its own — the Genesis columns converter consumes each gb-column — applies the same filter to them itself, with the child's block name as $name, so an attribute you add here survives on the children too.

block_alchemy_converted_block

Filters a converted block just before it is spliced into the content.

apply_filters( 'block_alchemy_converted_block', array $block, string $name, array $source ): array

The last chance to adjust output without editing a converter — useful for a project-wide tweak, and for asserting in tests that a conversion produced what you expected.

block_alchemy_post_meta_position

Filters where a converted meta field's blocks are placed: append (default) or prepend.

apply_filters( 'block_alchemy_post_meta_position', string $position, string $meta_key, Subject $subject ): string

Which is correct depends on where the theme rendered the field relative to the_content. A template that output it above the content needs prepend, or the page reorders the moment the meta stops rendering.

block_alchemy_uuid_salt

Filters the salt used to derive stable UUIDs. Defaults to block-alchemy-.

apply_filters( 'block_alchemy_uuid_salt', string $salt ): string

Only change this with intent. It changes every UUID the converters produce, so already-converted content will not match a re-run — which turns an idempotent command into one that rewrites every post it has already done.

block_alchemy_gap_support

Filters how the active theme renders a block's gap: block, fallback or none.

apply_filters( 'block_alchemy_gap_support', string $support ): string

Detected from the theme, and read by Builder\Layout::gap_support(). It matters because on some themes a gap set on one block is not rendered at all:

  • block — the theme sets settings.spacing.blockGap (to true or false; core tests it with isset()), or leaves it unset with appearanceTools: true, which opts it in. A per-block gap renders.
  • fallback — the setting resolves to null: core's default, for a theme that sets neither it nor appearanceTools. A flex or grid block gets its block type's default gap — 2em for core/columns — and a blockGap attribute is saved, shown nowhere, and ignored.
  • none — the theme declares disable-layout-styles, and no gap renders.

A converter carrying a non-default gap onto a fallback theme has silently changed the layout, so the Genesis columns converter refuses one there instead. A fixture runs with no theme and gets fallback. Return block in a test to exercise the other path; anything unrecognised is treated as fallback, never as support.


HTML conversion

block_alchemy_known_shortcodes

Filters the shortcodes treated as real even when their plugin is inactive. Defaults to the ones WordPress itself registers.

apply_filters( 'block_alchemy_known_shortcodes', array $known ): array

Add every shortcode the content uses, active or not. A tag that is neither listed nor registered is treated as bracketed prose and left as text — the right call for [at], [dot] and [8226], and the wrong one for a real shortcode from a deactivated plugin.

Checked alongside shortcode_exists() so conversion is deterministic. Relying on registration alone would make output depend on which plugins happen to be active, giving different results locally and in production.

block_alchemy_shortcode_block

Filters the block for a shortcode standing on its own in WYSIWYG content.

apply_filters( 'block_alchemy_shortcode_block', array $block, string $tag, string $text ): array

$block is a core/shortcode block, or core/embed for core's own . Return the plugin's block for its shortcode when it has one, so an editor gets the plugin's controls rather than raw shortcode text; return $block to keep it. $text is the whole shortcode, with any body and closing tag. Only a shortcode that is a whole paragraph reaches this — one inline in a sentence stays text.

add_filter( 'block_alchemy_shortcode_block', function ( $block, $tag, $text ) {
    if ( 'flipbook' !== $tag ) {
        return $block;
    }

    $atts = shortcode_parse_atts( trim( substr( $text, 9, -1 ) ) );

    return Blocks::leaf( 'acme/flipbook', [ 'id' => (string) ( $atts['id'] ?? '' ) ], $text );
}, 10, 3 );

block_alchemy_html_verbatim

Filters the blocks for markup the converter can only keep as raw HTML.

apply_filters( 'block_alchemy_html_verbatim', array $blocks, string $html ): array

Called wherever Html would otherwise emit a core/html block: an element it has no block for, an embed standing alone, a table, and a whole field value that holds a <script>. $blocks is that core/html block. Return the blocks for what the markup actually is — core/embed for an iframe whose provider you know, a plugin's block for its widget — or $blocks to keep it, which is what reports the notice.

A script value arrives whole and before wpautop(), because a snippet's script and the element it mounts on cannot be told apart from the rest of the field. Swap the snippet for its block and send whatever surrounds it back through Html::to_blocks().

add_filter( 'block_alchemy_html_verbatim', function ( $blocks, $html ) {
    if ( preg_match( '#^<iframe src="https://maps\.example\.com/embed/(\w+)"[^>]*></iframe>$#', $html, $m ) ) {
        return [ Blocks::embed( 'https://maps.example.com/m/' . $m[1], '', 'rich' ) ];
    }

    return $blocks;
}, 10, 2 );

block_alchemy_pre_html_element

Short-circuits the conversion of one block-level element.

apply_filters( 'block_alchemy_pre_html_element', null, DOMElement $node, callable $children ): ?array

Return parsed blocks to use them instead — an empty array drops the element — or null to let Html convert it as usual. Called after wpautop(), for every block-level element; inline elements belong to a paragraph and never reach it.

The usual case is a theme's own wrapper pasted back into a field from a rendered page. Html keeps a <div class="…"> as raw HTML, because it cannot know whether the class matters; your theme does. $children( $node ) converts the element's contents exactly as Html would, for a group to hold:

add_filter( 'block_alchemy_pre_html_element', function ( $blocks, $node, $children ) {
    if ( 'div' !== $node->tagName || 'description-text' !== $node->getAttribute( 'class' ) ) {
        return $blocks;
    }

    return [ Blocks::group( 'description-text', $children( $node ) ) ];
}, 10, 3 );

block_alchemy_paste_artefact_classes

Filters the patterns identifying classes an editor left behind when pasting.

apply_filters( 'block_alchemy_paste_artefact_classes', array $patterns ): array

Each entry is a full regular expression tested against a single class name; a class matching any of them is dropped. Defaults to /^whitespace-/ and /^break-words$/.

block_alchemy_list_indent

Filters the left padding of a list that was indented with wrapper items.

apply_filters( 'block_alchemy_list_indent', string $padding, int $depth ): string

Editors indent a list by wrapping it in an item of its own — TinyMCE writes <li style="list-style-type: none;">, sometimes several deep. The conversion collapses that ladder into one list rather than leave an empty bullet per level, and passes how many levels it removed as $depth. Return a CSS length to keep the indent, or an empty string — the default — for none.

// One Foundation list indent per level that was removed.
add_filter( 'block_alchemy_list_indent', function ( $padding, $depth ) {
    return ( 1.25 * $depth ) . 'rem';
}, 10, 2 );

The padding is saved as the list block's own style.spacing.padding.left, so it stays editable in the block editor. A wrapper that sits among other items is never padded: it becomes the sub-list of the item before it, or its items join the list, and padding the whole list would move the items beside it too.


Resolving references

block_alchemy_resolve_attachment

Filters a resolved attachment.

apply_filters( 'block_alchemy_resolve_attachment', array $resolved, mixed $value, string $size ): array

$resolved is [ 'id' => int, 'url' => string, 'alt' => string ]. Useful when attachments moved during the migration — an offloaded media library, a domain change, IDs remapped by an import.

A populated field that cannot be resolved returns an id with an empty url. That distinction matters: a converter must treat it as a reason to refuse, not as an absent image.

block_alchemy_resolve_terms

Filters resolved term names.

apply_filters( 'block_alchemy_resolve_terms', array $names, mixed $value ): array

The validation gate

block_alchemy_validation_errors

Filters the validation failures for a conversion. Anything in the returned array blocks the write.

apply_filters( 'block_alchemy_validation_errors', array $errors, string $updated, string $original, array $in_scope ): array

The right place for a project-specific invariant that must be impossible to skip rather than remembered — a class that must survive, a block that must never appear, a count that must match.

add_filter( 'block_alchemy_validation_errors', function ( $errors, $updated ) {
    if ( false === strpos( $updated, 'wp-block-group' ) ) {
        $errors[] = 'the section wrapper is missing';
    }

    return $errors;
}, 10, 2 );

The built-in checks always run: round-trip stability, no in-scope block left behind unaccounted for, no new classic block, and nothing unrelated lost.


Persistence

block_alchemy_backup_meta_key

Filters the meta key holding a post's pre-conversion content. Defaults to _block_alchemy_backup.

apply_filters( 'block_alchemy_backup_meta_key', string $key ): string

Change this only before the first write. Changing it afterwards orphans every backup already taken, and rollback will report that posts it converted have nothing to restore.

block_alchemy_selection_clauses

Filters the SQL clauses used to find convertible posts.

apply_filters( 'block_alchemy_selection_clauses', array $matcher, array $converters ): array

$matcher is [ 'clauses' => string[], 'values' => mixed[] ]. Clauses are OR-ed together and passed through $wpdb->prepare(), so use %s/%d placeholders and add the matching values in the same order. The posts table is aliased p.

A custom source must add a clause here, or nothing will select the posts its converters apply to.

block_alchemy_fixture_paths

Filters the directories searched for converter fixtures.

apply_filters( 'block_alchemy_fixture_paths', array $paths ): array

A site registering its own converters keeps their fixtures in its own repository, next to the converters and the content they were recorded from, and adds the directory here.


The block editor panel

Both of these are read once per request by REST\Controller. Neither is consulted by WP-CLI, so the editor button can be made more conservative than the command without narrowing what a migration can do.

block_alchemy_rest_capability

Filters the capability required to convert or roll back a post from the block editor.

apply_filters( 'block_alchemy_rest_capability', string $capability, int $post_id ): string

Defaults to edit_post, checked against the post — the same permission the editor already required in order to open it. Tighten it to manage_options on a site where converting should be an administrator's decision rather than an author's:

add_filter( 'block_alchemy_rest_capability', fn () => 'manage_options' );

The check runs in the route's permission_callback, so it gates the preview, the write and the rollback alike.

block_alchemy_editor_converters

Filters which converters the editor panel is allowed to run.

apply_filters( 'block_alchemy_editor_converters', array $only ): array

An empty array — the default — means every registered converter, matching wp block-alchemy convert with no --converters. Return a list of names to restrict the panel:

// Let editors convert classic content themselves; leave the rest to a migration.
add_filter( 'block_alchemy_editor_converters', fn () => [ 'core/freeform' ] );

This also narrows what the panel reports. A post whose only convertible content is excluded here scans as having nothing to convert, so the panel does not appear on it at all.


Actions

block_alchemy_written

Fires after a post's converted content has been written and verified.

do_action( 'block_alchemy_written', int $post_id, string $content, string $original, Result $result );

After the read-back comparison, so this only fires for a write that actually landed intact.

block_alchemy_restored

Fires after a post has been restored to its pre-conversion state.

do_action( 'block_alchemy_restored', int $post_id );

block_alchemy_side_effect

Fires for a side effect the plugin does not handle itself. A custom source recording its own side-effect type applies it here.

do_action( 'block_alchemy_side_effect', array $effect, int $post_id );

$effect is [ 'type' => string, 'key' => string, 'note' => string ]. The plugin handles delete_meta itself; anything else reaches this action.

Side effects run after the content write has been read back and confirmed, because they are destructive — deleting the meta a section was built from. Doing them first would mean a failed write leaves the data gone and the content unchanged.

Have a question about this page?

Linchpin assistant

Was this helpful?