WordPress GDPR Privacy Tools: Register Custom Personal Data Exporters and Erasers

WordPress 4.9.6 (released May 2018) added a built-in suite of GDPR compliance tools: Tools → Export Personal Data lets administrators trigger a ZIP of a user’s personal data, and Tools → Erase Personal Data anonymises or deletes it. Both tools are powered by a registry of exporters and erasers — each registered as a PHP callback. WordPress itself registers exporters for user profile data, comments, and media. Any plugin that stores personal data should register its own exporters and erasers so that the built-in admin tools cover all data, without the site administrator needing to manually identify every custom table or option. An exporter callback receives a user email address and returns an associative array of data items grouped by category. An eraser callback receives the same email and deletes or anonymises the relevant records, reporting back whether it handled everything or needs another pass. This article shows the complete implementation pattern for a plugin that stores form submission data tied to user emails.

Problem: Your plugin stores contact form submissions — name, email, message — in a custom wp_form_submissions table. When a user requests data export or erasure under GDPR, their form submissions do not appear in WordPress's built-in export ZIP and are not erased by the Erasure tool.

Solution: Register a personal data exporter with wp_privacy_register_personal_data_exporter and an eraser with wp_privacy_register_personal_data_eraser. Each callback receives a user email and returns a structured response array.

<?php
// ── Register exporter and eraser ──────────────────────────────────────
add_filter( 'wp_privacy_personal_data_exporters', 'register_form_submission_exporter' );

function register_form_submission_exporter( $exporters ) {
    $exporters['my-plugin-forms'] = [
        'exporter_friendly_name' => __( 'Contact Form Submissions', 'my-plugin' ),
        'callback'               => 'export_form_submissions_data',
    ];
    return $exporters;
}

add_filter( 'wp_privacy_personal_data_erasers', 'register_form_submission_eraser' );

function register_form_submission_eraser( $erasers ) {
    $erasers['my-plugin-forms'] = [
        'eraser_friendly_name' => __( 'Contact Form Submissions', 'my-plugin' ),
        'callback'             => 'erase_form_submissions_data',
    ];
    return $erasers;
}

// ── Exporter callback ────────────────────────────────────────────────────
function export_form_submissions_data( $email_address, $page = 1 ) {
    global $wpdb;

    $per_page = 500;
    $offset   = ( $page - 1 ) * $per_page;
    $table    = $wpdb->prefix . 'form_submissions';

    $rows = $wpdb->get_results(
        $wpdb->prepare(
            "SELECT * FROM $table WHERE email = %s LIMIT %d OFFSET %d",
            $email_address, $per_page, $offset
        )
    );

    $export_items = [];
    foreach ( $rows as $row ) {
        $export_items[] = [
            'group_id'    => 'form-submissions',
            'group_label' => __( 'Contact Form Submissions', 'my-plugin' ),
            'item_id'     => 'submission-' . $row->id,
            'data'        => [
                [ 'name' => __( 'Submitted Date', 'my-plugin' ), 'value' => $row->created_at ],
                [ 'name' => __( 'Name',           'my-plugin' ), 'value' => $row->name ],
                [ 'name' => __( 'Email',          'my-plugin' ), 'value' => $row->email ],
                [ 'name' => __( 'Message',        'my-plugin' ), 'value' => $row->message ],
            ],
        ];
    }

    $done = count( $rows ) < $per_page;
    return [ 'data' => $export_items, 'done' => $done ];
}

// ── Eraser callback ──────────────────────────────────────────────────────
function erase_form_submissions_data( $email_address, $page = 1 ) {
    global $wpdb;
    $table = $wpdb->prefix . 'form_submissions';

    $deleted = $wpdb->delete( $table, [ 'email' => $email_address ], [ '%s' ] );

    return [
        'items_removed'  => (int) $deleted,
        'items_retained' => 0,
        'messages'       => [],
        'done'           => true,
    ];
}

NOTE: The exporter callback supports pagination via the $page parameter. Set 'done' => false and WordPress will call the callback again with $page + 1 until you return 'done' => true. For tables with potentially large datasets, always implement pagination — WordPress will time out the request if a single callback takes too long. The eraser's items_retained field is for cases where legal obligations (e.g. financial records) prevent deletion; return a message explaining why, and WordPress will include it in the erasure report.