---
title: Filter and action reference
---

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.

```php
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:

```php
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.

```php
$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.

```php
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:

```php
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.

```php
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`.

```php
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.

```php
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.

```php
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.

```php
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`.

```php
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-`.

```php
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`.

```php
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.

```php
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.

```php
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 `[embed]`. 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.

```php
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.

```php
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()`.

```php
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.

```php
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:

```php
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.

```php
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.

```php
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.

```php
// 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.

```php
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.

```php
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.**

```php
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.

```php
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`.

```php
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.

```php
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.

```php
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.

```php
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:

```php
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.

```php
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:

```php
// 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**.

```php
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.

```php
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.

```php
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.
