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_booksin 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_exportersentries have anexporter_friendly_name(shown in progress and errors) and acallback( $email_address, $page ),$pagestarting at 1, that returnsdata(an array of items) anddone(a boolean). -
An export item has
group_id,group_label, an optionalgroup_description,item_idanddata, a list ofname/valuepairs. A URL value becomes a link inindex.html; items with the same group anditem_idmerge. -
wp_privacy_personal_data_erasersentries have aneraser_friendly_nameand acallback( $email_address, $page )that returnsitems_removedanditems_retained(booleans),messages(strings shown to the administrator, for example why data was kept) anddone.
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' );
}
Source: Plugin Handbook: Privacy
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
Source: WP-CLI Commands: wp eval
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"}
Source: WP-CLI Commands: wp eval-file
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.
Source: WordPress News: WordPress 7.1.4
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 ).
Cookies and consent
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 oldlp_recent_bookscookie sent:lp_recent_books=deletedwith a 1970 expiry, which removes it. -
Consent type
optinandwp_consent_preferences=allowwithlp_recent_books=4,6:lp_recent_books=5%2C4%2C6. -
Consent type
optinandwp_consent_preferences=deny, or the API not active: noSet-Cookieat 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.
References
-
Plugin Handbook: Adding the Personal Data Exporter to Your Plugin
-
Plugin Handbook: Adding the Personal Data Eraser to Your Plugin
-
Plugin Handbook: Suggesting text for the site privacy policy
-
Plugin Handbook: Privacy related options, hooks and capabilities
-
WordPress.org Documentation: Tools Erase Personal Data Screen