Skip to content

Fatal TypeError: array_diff() on string in remove_connected_id() when relationship meta is stored as multiple rows #1363

Description

@eleshar

Summary

lsx\legacy\Admin::remove_connected_id() calls array_diff() on the result of get_post_meta( $remote_id, $meta_key, true ) without checking that it is an array. Where the reciprocal relationship meta is stored as multiple single-value rows (one row per connected post ID) rather than one serialised array, get_post_meta( …, true ) returns the first row's value as a string, and array_diff() throws.

This is a hard fatal on wp_insert_post(), so the editor save fails and WordPress emails the site admin its "Your Site is Experiencing a Technical Issue" recovery-mode notice.

Error

PHP Fatal error: Uncaught TypeError: array_diff(): Argument #1 ($array) must be of type array, string given
  in includes/classes/legacy/class-admin.php:253
Stack trace:
#0 includes/classes/legacy/class-admin.php(253): array_diff()
#1 includes/classes/legacy/class-admin.php(216): lsx\legacy\Admin->remove_connected_id()
#2 wp-includes/class-wp-hook.php(341): lsx\legacy\Admin->cpt_relations()
   … CMB2_Field->save_field() → CMB2->save_fields() → CMB2_Hookup->save_post()
   → wp_insert_post() → wp_update_post() → edit_post()

Steps to reproduce

  1. Have a post whose relationship meta key (e.g. destination_to_accommodation) exists as several separate wp_postmeta rows, each holding a single ID. This is the storage shape produced by older versions and by importers.
  2. Edit a post connected to it and remove one of the connections.
  3. Save. cpt_relations() → remove_connected_id() → fatal.

Root cause

Three defects in the same code path:

1. Missing array guard (the fatal), class-admin.php:249-255

public function remove_connected_id( $remote_id, $connected_id, $meta_key ) {
	$prev = get_post_meta( $remote_id, $meta_key, true );
	if ( ! empty( $prev ) ) {
		$diff = array_diff( $prev, array( $connected_id ) ); // $prev can be a string
		update_post_meta( $remote_id, $meta_key, $diff, $prev );
	}
}

The sibling add_connected_id() has an is_array() guard; the remove path does not. cpt_relations() normalises its own side with if ( ! is_array( $previous_values ) ) but never the remote side it writes to.

2. Silent data corruption via the $prev_value argument

Even once the fatal is guarded, update_post_meta( $remote_id, $meta_key, $diff, $prev ) is wrong for multi-row data. Per wp-includes/meta.php, a non-empty $prev_value adds meta_value = $prev_value to the WHERE clause, so only the row matching the old value is rewritten. On a key with N rows, one row is replaced with a serialised array and the other N-1 rows survive untouched — the disconnect half-applies and the key ends up in a mixed state.

Passing an empty $prev_value is not a fix either: update_metadata() then loops every $meta_id for that key and writes the same value to all of them, duplicating the array across N rows.

3. foreach over a scalar, class-admin.php:196-202

In the "all connections removed" branch, $previous_values is iterated without the is_array() normalisation that the else branch performs, warning on foreach() argument must be of type array|object and silently skipping the cleanup.

Impact

Any site with legacy multi-row relationship meta. On one affected install, over half of the ~20k relationship meta rows are in the scalar shape across destination_to_accommodation, destination_to_review, tour_to_accommodation, tour_to_destination and accommodation_to_destination — so most destination and tour saves that drop a connection hit it.

PHP 8 turned what used to be an array_diff(): Argument #1 must be of type array warning into a TypeError, which is why this surfaces as a fatal now rather than a silently failed save.

Proposed fix

Read and write relationship meta through a single normalising helper rather than assuming a shape:

  • Read with get_post_meta( $id, $key, false ) so every row is seen, flattening any row that is itself a serialised array.
  • Normalise IDs to unique, non-empty, reindexed values.
  • Write by deleting the key and adding one row holding a single serialised array, so a mixed key self-heals to the canonical shape on the next save.
  • Guard the scalar foreach in cpt_relations().

Affects main and every branch that touches this file (#682, #810, #1148) — all carry the identical unguarded call.

Activity

  1. added
    status:needs-devEarly execution signal (triage queue for engineering)
    on Sep 9, 2026
  2. linear-code commented on Sep 9, 2026

    @linear-code
  3. eleshar commented on Sep 9, 2026

    @eleshar
    CollaboratorAuthor

    On "legacy": how the old shape actually gets cleared

    Two separate things, worth keeping apart:

    1. New writes are always canonical. After this fix every write goes through Relationship_Meta::save_ids(), which deletes the existing rows for a key and writes one row holding a serialised array. So the multi-row shape can no longer be created, and any key that is touched by a save is repaired on the way through. That is the self-healing part, and it is lazy: a key is only fixed when a post that references it is saved.

    2. Existing rows are not touched until something saves them. On a large site that could be indefinitely — a destination nobody edits keeps its legacy rows forever. It is no longer a fatal (the read side flattens both shapes), but the mixed storage stays.

    To actually clear it rather than wait, this PR adds a WP-CLI command:

    wp tour-operator normalise-relationships --dry-run
    wp tour-operator normalise-relationships
    wp tour-operator normalise-relationships --key=tour_to_destination
    

    It finds the post/key pairs holding more than one row, flattens and de-duplicates them, and writes each back as a single serialised array. --dry-run reports what would change without writing.

    Once that has run, "legacy multi-row relationship meta" stops being a category that exists on the site: the read path still tolerates it for anything restored from an old backup or written by an older importer, but nothing in normal operation produces it any more.

    Worth noting that the multi-row rows carry duplicates — on one install a key with 15 rows held 11 distinct IDs — so the conversion also de-duplicates as a side effect.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Fields

Priority

None yet

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions