Privacy laws such as the GDPR give people the right to a copy of their personal data and, in many cases, the right to have it erased. WordPress has tools for this under Tools > Export Personal Data and Tools > Erase Personal Data, and core already handles users, comments and media. It knows nothing about your custom table. This lesson connects the bookings table to those tools, so a site owner can answer a request in a few clicks instead of running SQL by hand.
How the privacy tools work
An administrator creates a request for an email address. Once it is confirmed, WordPress processes it in the browser through a sequence of AJAX requests:
- It collects every registered exporter (or eraser) from a filter.
- For each one, it calls the callback with the email address and page
1. - It keeps calling with page
2,3and so on until the callback says it isdone. - For exports, it builds a downloadable ZIP with an HTML report. For erasures, it reports what was removed and what was kept.
Paging is the important part. Each call is a separate HTTP request, so your callback should handle a small batch and return quickly, however many bookings the person has.
What data does the plugin hold?
Map it before writing code. For the events plugin:
- Bookings table:
user_idlinks each booking to a person, and with it the event, guest count, status and booking time. - User meta: if you kept
myplugin_rsvpsfrom the WordPress course, that is personal data too. - Elsewhere: logs, queued job arguments, emails stored for reminders. Anything that identifies a person counts.
This lesson handles the bookings table. The same pattern covers the rest.
A repository method for anonymising
The eraser needs to anonymise a booking. Add a method to the repository from lesson 16, next to for_user() and delete():
<?php
// myplugin/src/Bookings/Repository.php (inside the class)
public function anonymise( int $id ): bool {
$updated = $this->wpdb->update(
$this->table(),
array( 'user_id' => 0 ),
array( 'id' => $id ),
array( '%d' ),
array( '%d' )
);
return false !== $updated;
}
Registering an exporter and an eraser
Both registrations are filters that receive an array keyed by an ID you choose:
<?php
// myplugin/src/Privacy/PersonalData.php
namespace MyPlugin\Privacy;
use MyPlugin\Bookings\Repository;
use MyPlugin\Contracts\Hookable;
defined( 'ABSPATH' ) || exit;
final class PersonalData implements Hookable {
private const ID = 'myplugin-bookings';
private const PER_PAGE = 50;
public function __construct( private Repository $bookings ) {}
public function register(): void {
add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'register_exporter' ) );
add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'register_eraser' ) );
add_action( 'admin_init', array( $this, 'add_policy_content' ) );
}
public function register_exporter( array $exporters ): array {
$exporters[ self::ID ] = array(
'exporter_friendly_name' => __( 'Event bookings', 'myplugin' ),
'callback' => array( $this, 'export' ),
);
return $exporters;
}
public function register_eraser( array $erasers ): array {
$erasers[ self::ID ] = array(
'eraser_friendly_name' => __( 'Event bookings', 'myplugin' ),
'callback' => array( $this, 'erase' ),
);
return $erasers;
}
}
Register the service from Plugin::register() with new Privacy\PersonalData( $this->bookings ). Register it on every request, not only in the admin screens: the privacy tools call your callbacks through admin-ajax.php, and some sites trigger them from WP-CLI or custom code.
The exporter callback
WordPress calls the exporter with two arguments, the email address and the page number, and expects an array with exactly two keys:
data: a list of items, each describing one thing, here one booking;done:truewhen there are no more pages.
Each item has a fixed shape:
| Key | Meaning |
|---|---|
group_id | Groups items in the report. Use the same ID for all bookings |
group_label | The heading for the group, translated |
group_description | Optional. One sentence explaining the group |
item_id | Unique within the group, such as booking-42 |
data | A list of array( 'name' => ..., 'value' => ... ) pairs |
<?php
// myplugin/src/Privacy/PersonalData.php (inside the class)
public function export( string $email_address, int $page = 1 ): array {
$user = get_user_by( 'email', $email_address );
if ( ! $user ) {
return array(
'data' => array(),
'done' => true,
);
}
$page = max( 1, $page );
$offset = ( $page - 1 ) * self::PER_PAGE;
$rows = $this->bookings->for_user( $user->ID, self::PER_PAGE, $offset );
$items = array();
foreach ( $rows as $row ) {
$event_id = (int) $row['event_id'];
$items[] = array(
'group_id' => self::ID,
'group_label' => __( 'Event bookings', 'myplugin' ),
'group_description' => __( 'Bookings you made for events on this site.', 'myplugin' ),
'item_id' => 'booking-' . (int) $row['id'],
'data' => array(
array(
'name' => __( 'Event', 'myplugin' ),
'value' => get_the_title( $event_id ),
),
array(
'name' => __( 'Event date', 'myplugin' ),
'value' => (string) get_post_meta( $event_id, 'myplugin_event_date', true ),
),
array(
'name' => __( 'Guests', 'myplugin' ),
'value' => (string) $row['guests'],
),
array(
'name' => __( 'Status', 'myplugin' ),
'value' => (string) $row['status'],
),
array(
'name' => __( 'Booked at (UTC)', 'myplugin' ),
'value' => (string) $row['created_at'],
),
),
);
}
return array(
'data' => $items,
'done' => count( $rows ) < self::PER_PAGE,
);
}
Notes on the details:
- Find the person by email. The request only has an email address. The bookings table stores user IDs, so look up the user first. If you also store guest bookings by email, query those too.
- Paging by offset is safe here because exporting changes nothing: page 2 always starts where page 1 ended, ordered by ID.
donewhen a page is short. If the last page holds exactly 50 rows, one more call returns an emptydataarray anddone => true, which is fine.- Values are plain text. Core prepares the values for its HTML report, so do not add your own markup or escaping.
The eraser callback
The eraser receives the same two arguments and must return four keys:
| Key | Type | Meaning |
|---|---|---|
items_removed | bool | Whether any personal data was removed or anonymised in this call |
items_retained | bool | Whether any personal data was deliberately kept |
messages | array of strings | Explanations shown to the administrator, for example why data was kept |
done | bool | true when there is nothing more to process |
Decide first what erasure means for each record. Deleting a confirmed booking would change the event's headcount, and the organiser still needs to know how many seats are taken. So the eraser deletes cancelled bookings and anonymises the rest: it removes the link to the person, which is what makes the data personal, and keeps the guest count.
<?php
// myplugin/src/Privacy/PersonalData.php (inside the class)
public function erase( string $email_address, int $page = 1 ): array {
$response = array(
'items_removed' => false,
'items_retained' => false,
'messages' => array(),
'done' => true,
);
$user = get_user_by( 'email', $email_address );
if ( ! $user ) {
return $response;
}
// Always read from offset 0: rows handled below no longer match this user.
$rows = $this->bookings->for_user( $user->ID, self::PER_PAGE, 0 );
foreach ( $rows as $row ) {
$id = (int) $row['id'];
$ok = 'cancelled' === $row['status']
? $this->bookings->delete( $id )
: $this->bookings->anonymise( $id );
if ( $ok ) {
$response['items_removed'] = true;
} else {
$response['items_retained'] = true;
/* translators: %d: booking ID. */
$response['messages'][] = sprintf( __( 'Booking %d could not be erased. Please try again.', 'myplugin' ), $id );
}
}
$response['done'] = count( $rows ) < self::PER_PAGE;
return $response;
}
The paging rule is different from the exporter, and getting it wrong is the classic bug. Each call deletes or anonymises the rows it reads, so they drop out of the user's results. If the eraser used ( $page - 1 ) * 50 as its offset, page 2 would skip the 50 rows that moved into the first position, and half the data would survive. Reading from offset 0 every time processes the next batch, whatever the page number. The $page argument is still accepted because WordPress passes it.
A failed update also needs care: a row that fails stays linked to the user, so the next call would read it again. With a persistent failure, done could stay false for ever. In production, stop after a few failures and report them, or exclude failed IDs from the next query.
Sometimes the law requires you to keep data, for example records needed for tax or accounting. Leave those rows alone, set items_retained to true, and add a message saying what was kept and why. Make sure retained rows cannot fill every page: exclude them in the SQL, for example by status, so the eraser still reaches done.
Erasure is irreversible, so test it on a copy of real data first.
Suggesting privacy policy text
Site owners must describe what their site collects. Help them by suggesting text, which appears in the privacy policy guide under Settings > Privacy:
<?php
// myplugin/src/Privacy/PersonalData.php (inside the class)
public function add_policy_content(): void {
if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) {
return;
}
$content = '<p>' . esc_html__( 'When you book an event, we store your user account, the event, the number of guests, the booking status and the time of booking. Organisers use this to manage attendance.', 'myplugin' ) . '</p>';
wp_add_privacy_policy_content( __( 'My Plugin Events', 'myplugin' ), wp_kses_post( $content ) );
}
Keep it factual: what is stored, why, for how long, and whether it is shared with anyone.
Testing
Create a user with a few bookings, then go to Tools > Export Personal Data, add a request for that user's email address and, on a local site, skip the confirmation email so the request is ready immediately. Download the export and check the bookings group. Then run an erasure and check the table.
Automated tests are straightforward, because the callbacks are ordinary methods. With the lesson 18 setup, create 120 bookings for one user, call erase( $email, 1 ), erase( $email, 2 ) and erase( $email, 3 ) in turn, and assert that the user has no bookings left and the last response has done => true. That one test would catch the offset bug described above.
Summary
- Register exporters on
wp_privacy_personal_data_exportersand erasers onwp_privacy_personal_data_erasers, keyed by an ID, with a friendly name and a callback. - Callbacks receive the email address and a page number; look up the user and handle one small batch per call.
- Exporters return
data(items withgroup_id,group_label,item_idandname/valuepairs) anddone. - Erasers return
items_removed,items_retained,messagesanddone; read from offset 0, because erased rows drop out of the results. - Decide per record whether to delete, anonymise or retain, explain retained data in
messages, and suggest privacy policy text withwp_add_privacy_policy_content().