Privacy and Personal Data

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

When a person asks for a copy of their data or for its deletion, WordPress provides the request workflow and each plugin provides the code that finds, exports and erases its own data, the policy text that says what it collects, and the decisions about how much to collect, how long to keep it and whether to set cookies. This page is for plugin developers. It builds the privacy layer of lumen-catalog, the catalogue plugin of the fictitious publisher Lumen Press, around the wishlist that registered readers keep and a "recently viewed books" cookie. The site owner’s side (Settings > Privacy, Tools > Export Personal Data and Erase Personal Data, analytics consent) is in SEO, Analytics and Privacy.

This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 7.4 minimum and 8.3+ recommended, WP-CLI 2.12.0, WPCS 3.4.1 and the WP Consent API plugin 2.1.0, and checked on 2026-10-28. WordPress 7.2 is in beta and due in December 2026; nothing on this page depends on it.

What the plugin stores

The core tools work by email address: when an administrator processes a request, WordPress calls every registered exporter or eraser with that address. Data that no callback covers is neither exported nor erased, so the work starts with an inventory. Personal data includes email and IP addresses, names, user IDs combined with behaviour (what someone saved, viewed or bought), free text and identifiers in cookies. Core already covers user profiles, comments and uploaded media. For lumen-catalog:

  • Wishlist (book ID and date added), user meta lp_wishlist: exported and erased by the plugin, entries older than the retention period (730 days by default) pruned daily.

  • Recently viewed books, cookie lp_recent_books in the reader’s browser: set only with consent to preference cookies, never stored on the server.

  • Import runs of wp lumen import: log book IDs and counts only, no reader data, so no exporter is needed.

The exporter and eraser contract

Both kinds of callback are registered through a filter that returns an array keyed by a unique slug. The admin screen calls each callback over Ajax, one page per request, until it reports done, which keeps a large export within PHP’s time limit.

  • wp_privacy_personal_data_exporters entries have an exporter_friendly_name (shown in progress and errors) and a callback( $email_address, $page ), $page starting at 1, that returns data (an array of items) and done (a boolean).

  • An export item has group_id, group_label, an optional group_description, item_id and data, a list of name/value pairs. A URL value becomes a link in index.html; items with the same group and item_id merge.

  • wp_privacy_personal_data_erasers entries have an eraser_friendly_name and a callback( $email_address, $page ) that returns items_removed and items_retained (booleans), messages (strings shown to the administrator, for example why data was kept) and done.

The Ajax handlers validate every response and abort the request with errors such as "Expected done (boolean) in response array from exporter: Lumen Press wishlist." Callbacks are addressed by their position in the filtered list, so the list must be identical on every request; an exporter added only under some condition triggers "An exporter has improperly used the registration filter."

The lumen-catalog privacy class

The code lives in Lumen\Catalog\Privacy, loaded by the plugin’s Composer autoloader (see Plugin Fundamentals). The lumen/wishlist block’s REST endpoint stores the wishlist as a list of array( 'book' => 123, 'added' => 1793059200 ) entries, each book at most once. Plugin::boot() calls Privacy::register(); Installer::activate() and Installer::deactivate() call Privacy::schedule() and Privacy::unschedule(); LUMEN_CATALOG_FILE is the main plugin file.

Constants and hooks

<?php
/**
 * Personal data held by Lumen Catalog: the reader's wishlist and the "recently viewed" cookie.
 *
 * @package Lumen\Catalog
 */

namespace Lumen\Catalog;

defined( 'ABSPATH' ) || exit;

/** Exporter, eraser, policy text, retention and consent for Lumen Catalog. */
final class Privacy {

	const META_KEY     = 'lp_wishlist';     // User meta: list of array( 'book' => int, 'added' => int ).
	const COOKIE       = 'lp_recent_books'; // Up to five book IDs, set only with consent.
	const CLEANUP_HOOK = 'lp_catalog_privacy_cleanup';
	const PER_PAGE     = 100;               // Export items per page; keeps each Ajax call short.
	const DEFAULT_DAYS = 730;               // Wishlist entries older than this are pruned.

	/** Registers every hook. Called on plugins_loaded. */
	public static function register(): void {
		add_filter( 'wp_privacy_personal_data_exporters', array( self::class, 'add_exporter' ) );
		add_filter( 'wp_privacy_personal_data_erasers', array( self::class, 'add_eraser' ) );
		add_action( 'admin_init', array( self::class, 'add_policy_text' ) );
		add_action( self::CLEANUP_HOOK, array( self::class, 'prune_old_entries' ) );
		add_action( 'template_redirect', array( self::class, 'remember_recent_book' ) );
		add_action( 'init', array( self::class, 'describe_cookie' ) );

		// Tell the WP Consent API (a community plugin) that this plugin checks consent itself.
		add_filter( 'wp_consent_api_registered_' . plugin_basename( LUMEN_CATALOG_FILE ), '__return_true' );
	}

The exporter

	/**
	 * Adds the wishlist exporter to Tools > Export Personal Data.
	 *
	 * @param array $exporters Exporters keyed by a unique slug.
	 */
	public static function add_exporter( array $exporters ): array {
		$exporters['lumen-catalog-wishlist'] = array(
			'exporter_friendly_name' => __( 'Lumen Press wishlist', 'lumen-catalog' ),
			'callback'               => array( self::class, 'export_wishlist' ),
		);
		return $exporters;
	}

	/**
	 * Returns one page of the reader's wishlist.
	 *
	 * @param string $email Email address from the request.
	 * @param int    $page  Page number, starting at 1.
	 */
	public static function export_wishlist( $email, $page = 1 ): array {
		$user = get_user_by( 'email', $email );
		if ( ! $user ) { // Guests have no stored wishlist.
			return array(
				'data' => array(),
				'done' => true,
			);
		}

		$entries = (array) get_user_meta( $user->ID, self::META_KEY, true );
		$page    = max( 1, (int) $page );
		$items   = array();

		foreach ( array_slice( $entries, ( $page - 1 ) * self::PER_PAGE, self::PER_PAGE ) as $entry ) {
			$book_id = (int) $entry['book'];
			$fields  = array(
				__( 'Book', 'lumen-catalog' )      => get_the_title( $book_id ),
				__( 'ISBN', 'lumen-catalog' )      => get_post_meta( $book_id, 'lp_isbn', true ),
				__( 'Book page', 'lumen-catalog' ) => get_permalink( $book_id ), // A URL becomes a link in index.html.
				__( 'Added on', 'lumen-catalog' )  => wp_date( 'Y-m-d', (int) $entry['added'] ),
			);
			$data    = array();
			foreach ( $fields as $name => $value ) {
				$data[] = compact( 'name', 'value' );
			}
			$items[] = array(
				'group_id'          => 'lumen-wishlist',
				'group_label'       => __( 'Wishlist', 'lumen-catalog' ),
				'group_description' => __( 'Books you saved to your Lumen Press wishlist.', 'lumen-catalog' ),
				'item_id'           => 'lumen-wishlist-' . $book_id,
				'data'              => $data,
			);
		}

		return array(
			'data' => $items,
			'done' => $page * self::PER_PAGE >= count( $entries ),
		);
	}

A visitor without an account has no wishlist, so the exporter answers done with no data; a plugin that stores data for guests (an order, a form entry) looks it up by the email address it saved. done becomes true on the page that reaches the end of the list, so core never asks for an empty extra page. Called directly for the administrator with a 250-entry test wishlist, the exporter returned:

$ wp eval 'for ( $p = 1; $p <= 3; $p++ ) { $r = \Lumen\Catalog\Privacy::export_wishlist( "admin@lumenpress.test", $p ); printf( "page %d: %d items, done=%s\n", $p, count( $r["data"] ), var_export( $r["done"], true ) ); }'
page 1: 100 items, done=false
page 2: 100 items, done=false
page 3: 50 items, done=true

Paging over one meta row limits the size of each response, not the database work: every page loads the whole array. Data that grows without bound belongs in posts or a custom table, where each page is a LIMIT query.

The eraser

add_eraser() registers the eraser under the same slug and an eraser_friendly_name, as add_exporter() does:

	/**
	 * Deletes the reader's wishlist. One page is enough for one meta row.
	 *
	 * @param string $email Email address from the request.
	 * @param int    $page  Page number, starting at 1 (unused).
	 */
	public static function erase_wishlist( $email, $page = 1 ): array {
		$user = get_user_by( 'email', $email );

		return array(
			'items_removed'  => $user && delete_user_meta( $user->ID, self::META_KEY ),
			'items_retained' => false,
			'messages'       => array(),
			'done'           => true,
		);
	}

For each kind of data an eraser deletes it, anonymises it in place (core does this with comments, so threads keep their structure) or retains it with a reason: items_retained true and a message such as "Orders are kept for six years for tax purposes". An eraser that deletes in batches must not use $page as an offset, since each batch shifts the rest; it queries the first batch again until nothing is left. The unused $page (a WPCS warning) stays because core passes it.

Running an export and an erasure

The test site is the standard stack from Local Development Environments with lumen-catalog active, three lp_book posts (IDs 4 to 6) and a subscriber rosa (rosa@example.com) whose wishlist holds all three. WP-CLI 2.12.0 has no privacy command, so the export uses privacy-export.php from SEO, Analytics and Privacy, which creates a confirmed request and drives every exporter through the admin screen’s filter:

$ wp eval-file privacy-export.php rosa@example.com --user=admin
WordPress User, page 1: 1 item(s)
WordPress Comments, page 1: 1 item(s)
WordPress Media, page 1: 0 item(s)
Lumen Press wishlist, page 1: 3 item(s)
Success: Export file: https://lumenpress.test/wp-content/uploads/wp-personal-data-exports/wp-personal-data-file-<random>.zip
$ unzip -l wp-personal-data-file-<random>.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
     2111  10-28-2026 14:50   export.json
     3684  10-28-2026 14:50   index.html
 --------                     -------
     5795                     2 files
$ unzip -p wp-personal-data-file-<random>.zip export.json | jq -c '.[]["lumen-wishlist"].items["lumen-wishlist-5"][]'
{"name":"Book","value":"Salt and Ember"}
{"name":"ISBN","value":"978-1-23456-002-7"}
{"name":"Book page","value":"https://lumenpress.test/books/salt-and-ember/"}
{"name":"Added on","value":"2026-09-28"}

export.json holds one object keyed "Personal Data Export for" plus the address, with the groups about, user, comments and lumen-wishlist; index.html shows them with a table of contents. The ZIP file has a random name in wp-content/uploads/wp-personal-data-exports/. The hourly wp_privacy_delete_old_export_files event deletes export files after three days (filter wp_privacy_export_expiration, default 3 * DAY_IN_SECONDS), and since 7.1 the daily wp_privacy_personal_data_cleanup_requests event marks requests left unconfirmed for a day as failed; before 7.1 that happened only when an administrator opened one of the two screens.

Its erasure counterpart, privacy-erase.php, is the same script with a remove_personal_data request, the callbacks from wp_privacy_personal_data_erasers and the wp_privacy_personal_data_erasure_page filter (arguments $response, $index, $email, $page, $request_id), which completes the request after the last eraser. Each line prints the friendly name and the two flags:

$ wp eval-file privacy-erase.php rosa@example.com --user=admin
WordPress Comments, page 1: removed yes, retained no
Lumen Press wishlist, page 1: removed yes, retained no
Success: Request 9: request-completed
$ wp comment list --fields=comment_ID,comment_author,comment_author_email
comment_ID	comment_author	comment_author_email
2	Anonymous
1	A WordPress Commenter	wapuu@wordpress.example
$ wp eval-file privacy-erase.php rosa@example.com --user=admin
WordPress Comments, page 1: removed no, retained no
Lumen Press wishlist, page 1: removed no, retained no
Success: Request 10: request-completed

After the first run wp user meta get rosa lp_wishlist prints nothing; the second run shows that erasers must be idempotent. The account itself remains; deleting it is a separate step under Users. On completion the erasure filter fires wp_privacy_personal_data_erased, which emails the person; the test container had no mail server, so that email failed with a sendmail error on standard error while the request still completed. Both scripts call admin internals (_wp_privacy_completed_request(), the erasure filter’s contract), so they should be re-run after each major update.

The request screens themselves had a flaw until WordPress 7.1.4: a data removal request could be created in a way that led to stored XSS on the administrator’s Erase Personal Data screen. The 7.1.4 security release (2026-10-28) fixes it, so a site that handles privacy requests must run that release or a later one.

Suggested privacy policy text

Settings > Privacy > Policy Guide collects one section per plugin, added with wp_add_privacy_policy_content(). The text answers the default policy’s questions that apply: what is collected and why, how long it is kept, who receives it. Elements with the class privacy-policy-tutorial explain things to the site owner and are left out when the section is copied.

	/** Suggests text for Settings > Privacy > Policy Guide. Runs on admin_init. */
	public static function add_policy_text(): void {
		if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) {
			return;
		}
		$content = '<p class="privacy-policy-tutorial">'
			. esc_html__( 'Lumen Catalog stores wishlists for registered readers and, with consent, a cookie listing recently viewed books.', 'lumen-catalog' )
			. '</p><strong class="privacy-policy-tutorial">' . esc_html__( 'Suggested text:', 'lumen-catalog' ) . '</strong> <p>'
			. sprintf(
				/* translators: %d: number of days. */
				esc_html__( 'When you save a book to your wishlist we store the book and the date you saved it with your account. Entries are deleted after %d days, or when you ask us to erase your data. If you accept preference cookies, a cookie named lp_recent_books remembers the last five books you viewed for 30 days. We do not share this data.', 'lumen-catalog' ),
				self::retention_days()
			) . '</p>';

		wp_add_privacy_policy_content( 'Lumen Catalog', wp_kses_post( $content ) );
	}

The function only works in the admin, on or after admin_init; anywhere else it adds nothing and reports _doing_it_wrong() (visible with WP_DEBUG). A plain wp eval therefore finds no Lumen section until it sets an admin screen:

$ wp eval 'require_once ABSPATH . "wp-admin/includes/admin.php"; set_current_screen( "options-privacy" ); do_action( "admin_init" ); foreach ( WP_Privacy_Policy_Content::get_suggested_policy_text() as $s ) { echo $s["plugin_name"], PHP_EOL; }' --user=admin
WordPress
Lumen Catalog

The Policy Guide also flags sections of deactivated plugins. The suggested text is a starting point, not legal wording.

Data minimisation and logging

The cheapest data to protect is data never stored. The wishlist keeps a book ID and a date, not the title, price or IP address; titles are looked up at export time. Guests' "recently viewed" list stays in their browser. The wishlist REST endpoint and the import command log failures with book IDs and HTTP status codes, never with email addresses, request bodies or cookies, and WP_DEBUG_LOG is on only in development (see Debugging, Site Health and Troubleshooting). When a value is needed for abuse prevention or statistics but the person is not, core’s anonymisation helpers reduce it:

$ wp eval 'echo wp_privacy_anonymize_ip( "203.0.113.42" ), PHP_EOL, wp_privacy_anonymize_ip( "2001:db8:85a3::8a2e:370:7334" ), PHP_EOL, wp_privacy_anonymize_data( "email", "rosa@example.com" ), PHP_EOL, wp_privacy_anonymize_data( "url", "https://rosa.example" ), PHP_EOL, wp_privacy_anonymize_data( "text", "Loved it." ), PHP_EOL, wp_privacy_anonymize_data( "longtext", "x" ), PHP_EOL;'
203.0.113.0
2001:db8:85a3::
deleted@site.invalid
https://site.invalid
[deleted]
This content was deleted by the author.

The date type returns 0000-00-00 00:00:00, and the wp_privacy_anonymize_data filter changes any result. A truncated IP address still narrows a visitor down to a network; counting unique visitors per day only needs a salted hash whose salt rotates daily. Log files are storage too, with a retention period and a place in the inventory.

Retention with WP-Cron

A retention period stated in the policy needs code that enforces it. The period comes from the settings option lp_catalog_settings (key wishlist_retention_days) and a daily event prunes older entries:

	/** Daily retention job: drops wishlist entries older than the retention period. */
	public static function prune_old_entries(): void {
		$cutoff   = time() - self::retention_days() * DAY_IN_SECONDS;
		$user_ids = get_users(
			array(
				'meta_key' => self::META_KEY, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
				'fields'   => 'ID',
				'number'   => -1,
			)
		);

		foreach ( $user_ids as $user_id ) {
			$entries = (array) get_user_meta( $user_id, self::META_KEY, true );
			$kept    = array();
			foreach ( $entries as $entry ) {
				if ( (int) $entry['added'] >= $cutoff ) {
					$kept[] = $entry;
				}
			}
			if ( ! $kept ) {
				delete_user_meta( $user_id, self::META_KEY );
			} elseif ( count( $kept ) !== count( $entries ) ) {
				update_user_meta( $user_id, self::META_KEY, $kept );
			}
		}
	}

	/** Retention period in days, from the plugin settings. */
	public static function retention_days(): int {
		$settings = (array) get_option( 'lp_catalog_settings', array() );
		$days     = (int) ( $settings['wishlist_retention_days'] ?? self::DEFAULT_DAYS );
		return max( 1, (int) apply_filters( 'lumen_catalog_wishlist_retention_days', $days ) );
	}

	/** Activation: schedule the daily retention job once. */
	public static function schedule(): void {
		if ( ! wp_next_scheduled( self::CLEANUP_HOOK ) ) {
			wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::CLEANUP_HOOK );
		}
	}

	/** Deactivation: remove the job; the data stays until uninstall. */
	public static function unschedule(): void {
		wp_clear_scheduled_hook( self::CLEANUP_HOOK );
	}

wp cron event list shows lp_catalog_privacy_cleanup next to core’s wp_privacy_delete_old_export_files (hourly) and wp_privacy_personal_data_cleanup_requests (daily). With entries from 2024-08-19, 2026-09-28 and 2026-10-27 in Rosa’s wishlist, running the event removes the first:

$ wp cron event run lp_catalog_privacy_cleanup
Executed the cron event 'lp_catalog_privacy_cleanup' in 0.009s.
Success: Executed a total of 1 cron event.
$ wp user meta get rosa lp_wishlist --format=json
[{"book":5,"added":1790553600},{"book":6,"added":1793059200}]

WP-Cron runs only when the site receives traffic unless a system scheduler triggers it (see WP-Cron and Scheduled Tasks). With many readers, the job should handle a bounded batch per run, for example 500 users ordered by ID with the last ID kept in an option. On uninstall, uninstall.php removes the data of all users with delete_metadata( 'user', 0, 'lp_wishlist', '', true ).

Core sets few cookies (authentication and test cookies for logged-in users, comment author cookies only after the visitor ticks the opt-in box) and never asks for consent; a banner comes from a plugin or the site’s own code. A plugin that sets a cookie should make it conditional, describe it, and remove it when consent is withdrawn:

	/** Remembers the current book in a cookie, only with preferences consent. */
	public static function remember_recent_book(): void {
		if ( ! is_singular( 'lp_book' ) ) {
			return;
		}
		$options = array(
			'path'     => COOKIEPATH,
			'domain'   => COOKIE_DOMAIN,
			'secure'   => is_ssl(),
			'httponly' => true,
			'samesite' => 'Lax',
		);

		if ( ! self::has_preferences_consent() ) {
			if ( isset( $_COOKIE[ self::COOKIE ] ) ) { // Consent withdrawn: remove the old cookie.
				setcookie( self::COOKIE, '', array( 'expires' => time() - YEAR_IN_SECONDS ) + $options );
			}
			return;
		}

		$ids = isset( $_COOKIE[ self::COOKIE ] )
			? array_map( 'absint', explode( ',', sanitize_text_field( wp_unslash( $_COOKIE[ self::COOKIE ] ) ) ) )
			: array();
		array_unshift( $ids, get_queried_object_id() );
		$ids = array_slice( array_unique( array_filter( $ids ) ), 0, 5 );

		setcookie( self::COOKIE, implode( ',', $ids ), array( 'expires' => time() + 30 * DAY_IN_SECONDS ) + $options );
	}

	/** Describes the cookie to consent banners that read the WP Consent API. */
	public static function describe_cookie(): void {
		if ( function_exists( 'wp_add_cookie_info' ) ) {
			wp_add_cookie_info( self::COOKIE, 'Lumen Catalog', 'preferences', __( '30 days', 'lumen-catalog' ), __( 'Remembers the last five books viewed.', 'lumen-catalog' ), __( 'Book IDs only', 'lumen-catalog' ) );
		}
	}

	/** Whether the visitor accepted preference cookies. False without a consent API. */
	private static function has_preferences_consent(): bool {
		$allowed = function_exists( 'wp_has_consent' ) && wp_has_consent( 'preferences' );
		return (bool) apply_filters( 'lumen_catalog_recent_books_cookie_allowed', $allowed );
	}
}

The cookie is set on template_redirect, before any output, with the array form of setcookie() (PHP 7.3+) so that SameSite can be given. describe_cookie() runs on init because translating strings earlier triggers the just-in-time translation notice added in 6.7. wp_has_consent(), wp_add_cookie_info() and the wp_consent_api_registered_{$plugin} filter belong to the WP Consent API, a community plugin listed on WordPress.org, not part of core; the code checks function_exists() and treats a missing API as no consent. A banner plugin that supports the API sets the consent type for the visitor’s region and stores each choice in a wp_consent_<category> cookie. The trap: with the API active and no banner setting a consent type, wp_has_consent() returns true for every category. Requests for https://lumenpress.test/books/salt-and-ember/ (book ID 5) produced these Set-Cookie headers:

  • API active, no banner (consent type empty): lp_recent_books=5, 30 days, secure; HttpOnly; SameSite=Lax.

  • Consent type optin, no choice yet, an old lp_recent_books cookie sent: lp_recent_books=deleted with a 1970 expiry, which removes it.

  • Consent type optin and wp_consent_preferences=allow with lp_recent_books=4,6: lp_recent_books=5%2C4%2C6.

  • Consent type optin and wp_consent_preferences=deny, or the API not active: no Set-Cookie at all.

Whether a "recently viewed" list needs consent depends on the law that applies to the site’s readers; the code defaults to strict and lets the site owner decide through the lumen_catalog_recent_books_cookie_allowed filter. Embeds and tracking scripts must wait for consent in the browser as well. Plugins in the WordPress.org directory must not track users without consent (guideline 7; see Publishing to the Plugin Directory).

Multisite

Personal data requests are per site (each site has its own user_request posts; only super admins hold the privacy capabilities), but user meta is shared by the network, so an erasure on one site removes a meta-based wishlist everywhere. Per-site user data belongs in update_user_option(), which prefixes the key with the site’s table prefix (see Multisite Networks).

Older books

The older books treat privacy from the site owner’s side (a policy page, a cookie banner plugin). None shows how a plugin registers its data with the export and erasure tools, suggests policy text or enforces a retention period, and one still describes IP anonymisation as an analytics setting to switch on, which does not apply to Google Analytics 4. Other corrections are listed in Where Older Books and Tutorials Are Outdated. Personal data in shop orders (WooCommerce has its own exporters, erasers and order retention settings) is covered in Store Setup and Launch.