Skip to main content
WP HealthKit

WordPress GDPR Data Erasure: Right to Be Forgotten Guide

April 30, 202623 min readGDPRBy Jamie

Table of Contents

Introduction

GDPR Article 17, known as the "right to be forgotten," grants users the legal right to request erasure of their personal data under specific circumstances. It's not an absolute right—there are exceptions for legal compliance and legitimate business interests—but it's a powerful user protection that fundamentally changes how websites manage personal data.

Many WordPress administrators are uncertain about what data must be deleted, what can be retained, and how to implement erasure safely without breaking functionality. Unlike data export (which is straightforward), data erasure introduces real risks: delete the wrong data and accounts break; delete too little and you violate GDPR; keep extensive audit trails and you retain more data than necessary.

This guide walks through building a production-ready WordPress GDPR data erasure system. We'll cover the legal framework, how WordPress's data eraser system works via wp_register_personal_data_eraser, the crucial distinction between partial and complete erasure, how to maintain audit trails responsibly, and common edge cases that trip up implementations.

Getting this right is critical. Data erasure requests are taken seriously by regulators, and failures to properly implement them result in significant GDPR penalties. But understanding the mechanics, you can implement erasure that protects both user privacy and your business.

WP HealthKit has audited hundreds of WordPress sites for GDPR compliance. Let's build something that passes scrutiny.

Understanding GDPR Article 17: The Right to Be Forgotten

Before implementing erasure, understand the legal requirements. GDPR Article 17 states that users have the right to erasure when:

  1. Personal data is no longer necessary for the purpose it was collected
  2. The user withdraws consent (if consent was the legal basis)
  3. The user objects to processing for direct marketing
  4. Data is processed illegally
  5. Data must be deleted to comply with EU law
  6. The data subject is a child (stricter standards for processing)

However, Article 17(3) lists exceptions where erasure isn't required:

  • Legal obligations: Data retention required by law (e.g., accounting records)
  • Public interest: Archives and research
  • Freedom of expression: Legitimate speech interests
  • Legal claims: Data needed to establish, exercise, or defend legal actions
  • Health data: Medical record retention requirements
  • Vital interests: Data protection is necessary to protect someone

This means WordPress GDPR data erasure isn't absolute. You might legally retain some data even after an erasure request, provided you document the reason.

The WordPress Personal Data Eraser System

Similar to exporters, WordPress provides an eraser registration system through wp_privacy_personal_data_erasers. Each plugin can register erasers that handle its personal data.

Eraser Registration and Function Signature

<?php
// Register an eraser for your plugin
add_filter('wp_privacy_personal_data_erasers', function($erasers) {
    $erasers['my-plugin-eraser'] = array(
        'eraser_friendly_name' => __('My Plugin Data', 'my-plugin'),
        'callback' => 'my_plugin_erase_personal_data',
    );
    return $erasers;
});

// The eraser callback function
function my_plugin_erase_personal_data($email, $page = 1) {
    // Find user by email
    $user = get_user_by('email', $email);
    
    if (!$user) {
        return array('items_retained' => false, 'items_removed' => 0, 'done' => true);
    }
    
    // Erase user's personal data
    delete_user_meta($user->ID, 'personal_data_field');
    
    return array(
        'items_removed' => 1,
        'items_retained' => false,
        'done' => true,
    );
}
?>

The eraser function receives:

  • $email: Email of the user whose data is being deleted
  • $page: Pagination support for large datasets

It returns an array with:

  • items_removed: Number of items deleted
  • items_retained: Boolean indicating whether some data couldn't be deleted (with reasons)
  • done: Boolean indicating if more pages remain

Declaring Retained Data

If your plugin must retain some personal data due to legal requirements, declare it:

<?php
function my_plugin_erase_personal_data($email, $page = 1) {
    $user = get_user_by('email', $email);
    
    if (!$user) {
        return array('items_removed' => 0, 'items_retained' => false, 'done' => true);
    }
    
    $items_removed = 0;
    $items_retained = false;
    
    // Delete optional data
    if (delete_user_meta($user->ID, 'marketing_preferences')) {
        $items_removed++;
    }
    
    // Retain necessary data with reason
    $account_balance = get_user_meta($user->ID, 'account_balance', true);
    if ($account_balance > 0) {
        $items_retained = array(
            'description' => __('Account balance must be retained for financial reconciliation. Contact support to discuss account settlement.', 'my-plugin'),
            'status' => 'ready', // 'ready' means retained for valid reason
        );
    }
    
    return array(
        'items_removed' => $items_removed,
        'items_retained' => $items_retained,
        'done' => true,
    );
}
?>

Building Complete Data Erasers: Partial vs. Complete Deletion

Not all data should be erased the same way. Consider your e-commerce plugin:

Order history must be kept (for tax/legal compliance, dispute resolution, fraud prevention) Customer contact information can be erased (name, email, address) Payment details should already be deleted (stored in PCI-compliant vault) Reviews can be pseudonymized (keep the review, remove the reviewer's identity)

This is the distinction between partial and complete erasure.

Complete Erasure

Used for data that has no legal retention requirement:

<?php
/**
 * Erase optional user preferences - no legal reason to retain
 */
public function erase_preferences($user_id) {
    delete_user_meta($user_id, 'ecommerce_newsletter_preference');
    delete_user_meta($user_id, 'ecommerce_wishlist');
    delete_user_meta($user_id, 'ecommerce_saved_addresses');
    delete_user_meta($user_id, 'ecommerce_preferred_currency');
}
?>

Partial Erasure

Used for data with legitimate retention requirements:

<?php
/**
 * Pseudonymize order data - keep order for accounting, remove customer identity
 */
public function pseudonymize_orders($user_id) {
    global $wpdb;
    
    // Get all orders for this user
    $orders = $wpdb->get_results(
        $wpdb->prepare(
            "SELECT id FROM {$wpdb->prefix}ecommerce_orders WHERE user_id = %d",
            $user_id
        )
    );
    
    foreach ($orders as $order) {
        // Replace identifying information with generic values
        $wpdb->update(
            $wpdb->prefix . 'ecommerce_orders',
            array(
                'billing_name' => 'Deleted User',
                'billing_email' => '', // Empty, not null (keeps structure)
                'billing_address' => '', // Remove address
                'billing_city' => '',
                'billing_state' => '',
                'billing_zip' => '',
                'billing_phone' => '',
            ),
            array('id' => $order->id),
            array('%s', '%s', '%s', '%s', '%s', '%s', '%s')
        );
    }
}
?>

Complete Eraser Implementation

<?php
/**
 * E-Commerce Plugin - Complete GDPR Data Eraser
 * Handles personal data erasure for customers
 */

class Ecommerce_GDPR_Eraser {
    
    public function __construct() {
        add_filter('wp_privacy_personal_data_erasers', array($this, 'register_erasers'));
    }
    
    /**
     * Register all data erasers
     */
    public function register_erasers($erasers) {
        $erasers['ecommerce_account'] = array(
            'eraser_friendly_name' => __('Customer Account Data', 'ecommerce'),
            'callback' => array($this, 'erase_account_data'),
        );
        
        $erasers['ecommerce_orders'] = array(
            'eraser_friendly_name' => __('Orders and Purchases', 'ecommerce'),
            'callback' => array($this, 'erase_order_data'),
        );
        
        $erasers['ecommerce_reviews'] = array(
            'eraser_friendly_name' => __('Product Reviews', 'ecommerce'),
            'callback' => array($this, 'erase_review_data'),
        );
        
        $erasers['ecommerce_preferences'] = array(
            'eraser_friendly_name' => __('User Preferences', 'ecommerce'),
            'callback' => array($this, 'erase_preferences_data'),
        );
        
        return $erasers;
    }
    
    /**
     * Erase user account data
     */
    public function erase_account_data($email, $page = 1) {
        $user = get_user_by('email', $email);
        
        if (!$user) {
            return array('items_removed' => 0, 'items_retained' => false, 'done' => true);
        }
        
        $items_removed = 0;
        
        // Delete optional profile fields
        if (delete_user_meta($user->ID, 'ecommerce_phone')) {
            $items_removed++;
        }
        if (delete_user_meta($user->ID, 'ecommerce_company')) {
            $items_removed++;
        }
        
        // Note: We do NOT delete user_email or user_login from wp_users table
        // These are linked to posts and comments, so full deletion would break relationships
        // Instead, we'll anonymize in the orders context below
        
        return array(
            'items_removed' => $items_removed,
            'items_retained' => false, // No data needs to be retained for legal reasons
            'done' => true,
        );
    }
    
    /**
     * Erase order data (with legal retention requirements)
     */
    public function erase_order_data($email, $page = 1) {
        $user = get_user_by('email', $email);
        
        if (!$user) {
            return array('items_removed' => 0, 'items_retained' => false, 'done' => true);
        }
        
        global $wpdb;
        
        // Paginate orders (10 per page)
        $items_per_page = 10;
        $offset = ($page - 1) * $items_per_page;
        
        $orders = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT id FROM {$wpdb->prefix}ecommerce_orders 
                 WHERE user_id = %d 
                 ORDER BY id 
                 LIMIT %d OFFSET %d",
                $user->ID,
                $items_per_page,
                $offset
            )
        );
        
        $items_removed = 0;
        
        foreach ($orders as $order) {
            // Pseudonymize order - keep for accounting, remove identifying info
            $wpdb->update(
                $wpdb->prefix . 'ecommerce_orders',
                array(
                    'user_id' => 0, // Disconnect from user account
                    'billing_name' => 'Deleted User',
                    'billing_email' => '', // Empty string, preserves data structure
                    'billing_phone' => '',
                    'billing_address' => '',
                    'billing_city' => '',
                    'billing_state' => '',
                    'billing_zip' => '',
                    'billing_country' => '',
                    'shipping_name' => 'Deleted User',
                    'shipping_address' => '',
                    'shipping_city' => '',
                    'shipping_state' => '',
                    'shipping_zip' => '',
                    'shipping_country' => '',
                ),
                array('id' => $order->id),
                array('%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s')
            );
            
            $items_removed++;
        }
        
        // Check if more orders exist
        $total_orders = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT COUNT(*) FROM {$wpdb->prefix}ecommerce_orders 
                 WHERE user_id = %d",
                $user->ID
            )
        );
        
        $done = (($page * $items_per_page) >= $total_orders);
        
        // Indicate orders are retained for legal reasons
        $items_retained = false;
        if ($items_removed > 0) {
            $items_retained = array(
                'description' => wp_sprintf(
                    __('%d orders have been pseudonymized and retained for tax compliance and dispute resolution per GDPR Article 17(3)(b). Contact support for details.', 'ecommerce'),
                    $items_removed
                ),
                'status' => 'ready',
            );
        }
        
        return array(
            'items_removed' => $items_removed,
            'items_retained' => $items_retained,
            'done' => $done,
        );
    }
    
    /**
     * Erase product reviews (pseudonymize)
     */
    public function erase_review_data($email, $page = 1) {
        $user = get_user_by('email', $email);
        
        if (!$user) {
            return array('items_removed' => 0, 'items_retained' => false, 'done' => true);
        }
        
        global $wpdb;
        
        // Get user's comments (reviews) with pagination
        $items_per_page = 10;
        $offset = ($page - 1) * $items_per_page;
        
        $comments = get_comments(array(
            'user_id' => $user->ID,
            'type' => 'product_review',
            'number' => $items_per_page,
            'offset' => $offset,
            'status' => 'all',
        ));
        
        $items_removed = 0;
        
        foreach ($comments as $comment) {
            // Option 1: Delete reviews entirely (if no business value)
            // wp_delete_comment($comment->comment_ID, true);
            
            // Option 2: Pseudonymize reviews (recommended for e-commerce)
            $wpdb->update(
                $wpdb->comments,
                array(
                    'user_id' => 0,
                    'comment_author' => __('Deleted User', 'ecommerce'),
                    'comment_author_email' => '',
                    'comment_author_url' => '',
                    'comment_author_IP' => '',
                ),
                array('comment_ID' => $comment->comment_ID),
                array('%d', '%s', '%s', '%s', '%s')
            );
            
            $items_removed++;
        }
        
        // Check for more reviews
        $total_reviews = count(get_comments(array(
            'user_id' => $user->ID,
            'type' => 'product_review',
            'count' => true,
        )));
        
        $done = (($page * $items_per_page) >= $total_reviews);
        
        // Reviews are retained as pseudonymized content
        $items_retained = false;
        if ($items_removed > 0) {
            $items_retained = array(
                'description' => wp_sprintf(
                    __('%d reviews have been pseudonymized to preserve community content while protecting your privacy.', 'ecommerce'),
                    $items_removed
                ),
                'status' => 'ready',
            );
        }
        
        return array(
            'items_removed' => $items_removed,
            'items_retained' => $items_retained,
            'done' => $done,
        );
    }
    
    /**
     * Erase user preferences (fully deleted)
     */
    public function erase_preferences_data($email, $page = 1) {
        $user = get_user_by('email', $email);
        
        if (!$user) {
            return array('items_removed' => 0, 'items_retained' => false, 'done' => true);
        }
        
        $items_removed = 0;
        
        // These have no legal retention requirement and can be fully deleted
        if (delete_user_meta($user->ID, 'ecommerce_newsletter_opt_in')) {
            $items_removed++;
        }
        if (delete_user_meta($user->ID, 'ecommerce_marketing_opt_in')) {
            $items_removed++;
        }
        if (delete_user_meta($user->ID, 'ecommerce_wishlist')) {
            $items_removed++;
        }
        if (delete_user_meta($user->ID, 'ecommerce_saved_addresses')) {
            $items_removed++;
        }
        if (delete_user_meta($user->ID, 'ecommerce_preferred_currency')) {
            $items_removed++;
        }
        if (delete_user_meta($user->ID, 'ecommerce_purchase_history')) {
            $items_removed++;
        }
        
        return array(
            'items_removed' => $items_removed,
            'items_retained' => false,
            'done' => true,
        );
    }
}

// Initialize on plugin load
new Ecommerce_GDPR_Eraser();
?>

Maintaining Audit Trails Without Retaining Data

GDPR compliance requires proving you deleted data when requested, but you must balance this with data minimization—keeping the minimum data necessary. Here's the paradox: to prove erasure, you need logs of what was deleted, but logs contain metadata that might be considered personal data.

The solution is maintaining audit logs that:

  1. Record that erasure was requested and completed
  2. Do not store the personal data itself
  3. Do store metadata: timestamp, reason, confirmation
<?php
/**
 * GDPR Erasure Audit Trail
 */

class GDPR_Erasure_Audit {
    const TABLE_NAME = 'gdpr_erasure_log';
    
    /**
     * Create audit table on plugin activation
     */
    public static function create_table() {
        global $wpdb;
        $table_name = $wpdb->prefix . self::TABLE_NAME;
        
        $sql = $wpdb->prepare(
            "CREATE TABLE IF NOT EXISTS {$table_name} (
                id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
                request_id VARCHAR(255) NOT NULL UNIQUE,
                email_hash VARCHAR(64) NOT NULL,
                requested_date DATETIME NOT NULL,
                completed_date DATETIME NULL,
                reason VARCHAR(255) NOT NULL,
                status VARCHAR(32) NOT NULL DEFAULT 'pending',
                items_removed INT UNSIGNED DEFAULT 0,
                items_retained LONGTEXT,
                confirmation_token VARCHAR(255),
                token_expiry DATETIME,
                INDEX (email_hash),
                INDEX (request_id),
                INDEX (requested_date)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
        );
        
        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        dbDelta($sql);
    }
    
    /**
     * Log erasure request
     */
    public static function log_erasure_request($email, $reason) {
        global $wpdb;
        
        $request_id = wp_generate_uuid4();
        $confirmation_token = wp_generate_password(32, false);
        
        $wpdb->insert(
            $wpdb->prefix . self::TABLE_NAME,
            array(
                'request_id' => $request_id,
                'email_hash' => hash('sha256', strtolower($email)), // Store hash, not email
                'requested_date' => current_time('mysql'),
                'reason' => sanitize_text_field($reason),
                'status' => 'pending',
                'confirmation_token' => wp_hash_password($confirmation_token),
                'token_expiry' => date('Y-m-d H:i:s', time() + 7 * DAY_IN_SECONDS),
            ),
            array('%s', '%s', '%s', '%s', '%s', '%s', '%s')
        );
        
        return $request_id;
    }
    
    /**
     * Log erasure completion
     */
    public static function log_erasure_completion($request_id, $items_removed, $items_retained) {
        global $wpdb;
        
        $wpdb->update(
            $wpdb->prefix . self::TABLE_NAME,
            array(
                'status' => 'completed',
                'completed_date' => current_time('mysql'),
                'items_removed' => (int) $items_removed,
                'items_retained' => wp_json_encode($items_retained),
            ),
            array('request_id' => $request_id),
            array('%s', '%s', '%d', '%s')
        );
    }
    
    /**
     * Delete erasure logs older than 2 years (safe retention period)
     */
    public static function cleanup_old_logs() {
        global $wpdb;
        
        $two_years_ago = date('Y-m-d H:i:s', time() - (2 * 365 * DAY_IN_SECONDS));
        
        $wpdb->query(
            $wpdb->prepare(
                "DELETE FROM {$wpdb->prefix}" . self::TABLE_NAME . " 
                 WHERE status = 'completed' AND completed_date < %s",
                $two_years_ago
            )
        );
    }
}

// Register cleanup
add_action('wp_scheduled_event_gdpr_cleanup', array('GDPR_Erasure_Audit', 'cleanup_old_logs'));
?>

Schedule the cleanup task:

<?php
// On plugin activation
register_activation_hook(__FILE__, function() {
    if (!wp_next_scheduled('wp_scheduled_event_gdpr_cleanup')) {
        wp_schedule_event(time(), 'monthly', 'wp_scheduled_event_gdpr_cleanup');
    }
});

// On plugin deactivation
register_deactivation_hook(__FILE__, function() {
    wp_clear_scheduled_hook('wp_scheduled_event_gdpr_cleanup');
});
?>

Key principles:

  • Store email hash, not email: Protects privacy while allowing request lookups
  • Don't store deleted personal data: Log only metadata
  • Include timestamps: Proves when erasure occurred
  • Auto-delete logs: Remove audit logs after 2-3 years
  • Document rationale: Note why data was kept if retained

Testing Your WordPress GDPR Data Erasure Implementation

Comprehensive testing ensures your erasers work correctly and don't break functionality:

Unit Tests

<?php
/**
 * Tests for data erasure functionality
 */

class Test_GDPR_Erasure extends WP_UnitTestCase {
    
    public function test_account_data_is_erased() {
        $user_id = $this->factory->user->create(array(
            'user_email' => '[email protected]',
        ));
        
        add_user_meta($user_id, 'ecommerce_phone', '555-0123');
        add_user_meta($user_id, 'ecommerce_company', 'Acme Corp');
        
        $eraser = new Ecommerce_GDPR_Eraser();
        $result = $eraser->erase_account_data('[email protected]', 1);
        
        // Verify metadata was deleted
        $this->assertEmpty(get_user_meta($user_id, 'ecommerce_phone', true));
        $this->assertEmpty(get_user_meta($user_id, 'ecommerce_company', true));
        
        // Verify result structure
        $this->assertArrayHasKey('items_removed', $result);
        $this->assertArrayHasKey('items_retained', $result);
        $this->assertArrayHasKey('done', $result);
        $this->assertEquals(2, $result['items_removed']);
    }
    
    public function test_orders_are_pseudonymized() {
        global $wpdb;
        
        $user_id = $this->factory->user->create(array(
            'user_email' => '[email protected]',
        ));
        
        // Create test order
        $wpdb->insert(
            $wpdb->prefix . 'ecommerce_orders',
            array(
                'user_id' => $user_id,
                'order_number' => 'ORD-001',
                'billing_name' => 'John Doe',
                'billing_email' => '[email protected]',
                'billing_address' => '123 Main St',
                'created_at' => current_time('mysql'),
                'status' => 'completed',
                'total' => 99.99,
            ),
            array('%d', '%s', '%s', '%s', '%s', '%s', '%s', '%f')
        );
        
        $eraser = new Ecommerce_GDPR_Eraser();
        $result = $eraser->erase_order_data('[email protected]', 1);
        
        // Verify order was pseudonymized
        $order = $wpdb->get_row(
            $wpdb->prepare(
                "SELECT * FROM {$wpdb->prefix}ecommerce_orders WHERE order_number = %s",
                'ORD-001'
            )
        );
        
        $this->assertEquals(0, $order->user_id); // Disconnected from user
        $this->assertEquals('Deleted User', $order->billing_name); // Pseudonymized
        $this->assertEmpty($order->billing_email); // Cleared
        $this->assertEmpty($order->billing_address); // Cleared
        
        // Order exists for tax compliance
        $this->assertNotNull($order->id);
        $this->assertNotEmpty($order->order_number);
        
        // Result shows retention reason
        $this->assertNotEmpty($result['items_retained']);
        $this->assertEquals('ready', $result['items_retained']['status']);
    }
    
    public function test_reviews_are_pseudonymized() {
        $user_id = $this->factory->user->create(array(
            'user_email' => '[email protected]',
            'user_login' => 'reviewer',
        ));
        
        $post_id = $this->factory->post->create(array(
            'post_type' => 'product',
        ));
        
        $comment_id = $this->factory->comment->create(array(
            'user_id' => $user_id,
            'comment_post_ID' => $post_id,
            'comment_type' => 'product_review',
            'comment_author' => 'Reviewer Name',
            'comment_author_email' => '[email protected]',
        ));
        
        $eraser = new Ecommerce_GDPR_Eraser();
        $result = $eraser->erase_review_data('[email protected]', 1);
        
        $comment = get_comment($comment_id);
        
        $this->assertEquals(0, $comment->user_id);
        $this->assertEquals('Deleted User', $comment->comment_author);
        $this->assertEmpty($comment->comment_author_email);
        
        $this->assertEquals(1, $result['items_removed']);
    }
    
    public function test_erasure_with_pagination() {
        global $wpdb;
        
        $user_id = $this->factory->user->create(array(
            'user_email' => '[email protected]',
        ));
        
        // Create 25 orders
        for ($i = 0; $i < 25; $i++) {
            $wpdb->insert(
                $wpdb->prefix . 'ecommerce_orders',
                array(
                    'user_id' => $user_id,
                    'order_number' => 'ORD-' . $i,
                    'created_at' => current_time('mysql'),
                    'status' => 'completed',
                    'total' => 50.00,
                ),
                array('%d', '%s', '%s', '%s', '%f')
            );
        }
        
        $eraser = new Ecommerce_GDPR_Eraser();
        
        // Page 1: 10 orders
        $result = $eraser->erase_order_data('[email protected]', 1);
        $this->assertEquals(10, $result['items_removed']);
        $this->assertFalse($result['done']);
        
        // Page 2: 10 orders
        $result = $eraser->erase_order_data('[email protected]', 2);
        $this->assertEquals(10, $result['items_removed']);
        $this->assertFalse($result['done']);
        
        // Page 3: 5 orders
        $result = $eraser->erase_order_data('[email protected]', 3);
        $this->assertEquals(5, $result['items_removed']);
        $this->assertTrue($result['done']);
    }
}
?>

Manual Testing Checklist

  1. Create test user with data in each category (account, orders, reviews, preferences)
  2. Go to WordPress Tools → Delete Personal Data
  3. Submit erasure request for test user
  4. Verify confirmation email sent
  5. Click confirmation link
  6. Verify data is actually deleted/pseudonymized from database
  7. Check that business-critical data is retained (orders, payment records)
  8. Check that audit logs record the erasure
  9. Test with edge cases:
    • User with no data
    • User with thousands of data items (tests pagination)
    • User with sensitive data
    • Multiple erasure requests (verify idempotency)

Verify with WP HealthKit

Upload your plugin to WP HealthKit for an automated compliance audit. We check:

  • All erasers properly registered
  • Data is actually deleted (not just marked)
  • Sensitive data handling
  • Pagination implementation
  • Audit trail configuration
  • /upload for detailed analysis.

Edge Cases and Implementation Gotchas

Handling User Deletion

When WordPress admin deletes a user, should personal data automatically be erased?

<?php
// Option 1: Auto-erase when user deleted (strict privacy)
add_action('delete_user', function($user_id) {
    $user = get_user_by('ID', $user_id);
    
    // Run through all erasers
    do_action('wp_privacy_personal_data_erased', $user->user_email);
});

// Option 2: Don't auto-erase (safer for accountability)
// Require explicit GDPR erasure request via Tools menu
?>

Handling Order Deletions

Orders contain data that should usually be retained. If a customer deletes their account but has active orders, what happens?

<?php
// Best practice: Pseudonymize, not delete
// User can delete account, but orders remain pseudonymized for business operations

public function erase_with_exception($email, $page = 1) {
    $user = get_user_by('email', $email);
    
    if (!$user) {
        return array('items_removed' => 0, 'items_retained' => false, 'done' => true);
    }
    
    global $wpdb;
    
    // Check if user has unpaid/unfulfilled orders
    $unpaid_orders = $wpdb->get_var(
        $wpdb->prepare(
            "SELECT COUNT(*) FROM {$wpdb->prefix}ecommerce_orders 
             WHERE user_id = %d AND status NOT IN ('completed', 'cancelled')",
            $user->ID
        )
    );
    
    if ($unpaid_orders > 0) {
        // Retain order data, cannot erase yet
        return array(
            'items_removed' => 0,
            'items_retained' => array(
                'description' => wp_sprintf(
                    __('%d active orders prevent erasure. Settle orders or contact support.', 'ecommerce'),
                    $unpaid_orders
                ),
                'status' => 'ready',
            ),
            'done' => true,
        );
    }
    
    // Otherwise proceed with erasure
    $this->pseudonymize_orders($user->ID);
    // ... continue
}
?>

Idempotency

What if the same erasure request is submitted twice?

<?php
public function erase_data($email, $page = 1) {
    $user = get_user_by('email', $email);
    
    if (!$user) {
        return array('items_removed' => 0, 'items_retained' => false, 'done' => true);
    }
    
    // Check if already erased (user_id is 0, indicates prior anonymization)
    $already_erased = get_user_meta($user->ID, '_gdpr_erasure_completed', true);
    
    if ($already_erased) {
        // Idempotent: return success without re-erasing
        return array('items_removed' => 0, 'items_retained' => false, 'done' => true);
    }
    
    // Proceed with erasure logic...
    
    // Mark as erased
    update_user_meta($user->ID, '_gdpr_erasure_completed', current_time('mysql'));
}
?>

GDPR Article 17 Compliance Checklist

Create documentation to prove GDPR compliance.

Documentation of your data erasure implementation serves multiple purposes. It provides evidence for regulatory audits proving that you take user rights seriously. It helps your development team understand the rationale behind erasure decisions—why certain data is pseudonymized rather than deleted. It serves as onboarding material for new developers joining the project. Good documentation reduces the risk of regressions where a code change accidentally breaks erasure functionality.

Maintaining a compliance checklist isn't just bureaucratic overhead—it's a practical tool for ensuring your plugin implementation remains compliant as it evolves. When you add new data collection, you explicitly decide how it should be handled under Article 17. When you refactor code, you verify that erasure paths still work correctly. The checklist keeps compliance as an active consideration rather than something you implement once and forget about.

GDPR Article 17 (Right to Be Forgotten) Compliance Checklist
Plugin: My E-commerce Plugin
Last Reviewed: 2026-03-18

DATA COLLECTION & RETENTION BASIS:
- Account info: Legitimate interest (account functionality) + Consent (marketing)
- Orders: Legal obligation (tax), Legitimate interest (fraud prevention)
- Reviews: Legitimate interest (community engagement)
- Preferences: Consent (newsletter)

ERASURE IMPLEMENTATION:
✓ Account data eraser registered
✓ Order data eraser registered (with pseudonymization)
✓ Review data eraser registered (pseudonymization)
✓ Preferences eraser registered

RETENTION JUSTIFICATIONS:
✓ Orders retained: Legal obligation (Articles 17(3)(b)) - Tax compliance for 7 years
✓ Pseudonymized reviews: Legitimate interest - Community content preservation
✓ Account data: No retention - All metadata deleted upon request

EXCEPTIONS DOCUMENTED:
- Article 17(3)(a): Legal obligations (tax records, 7-year retention)
- Article 17(3)(b): Legitimate interests in fraud detection (hashed IP, timestamps)
- No claims under other exceptions

AUDIT TRAIL:
✓ Erasure logged (email hash, timestamp, reason)
✓ Logs deleted after 2 years (data minimization)
✓ No personal data in logs

TESTING:
✓ All erasers tested
✓ Pagination works
✓ Edge cases handled
✓ Order pseudonymization verified
✓ Audit logs created

EVIDENCE FOR COMPLIANCE:
- Code review: Erasers properly implemented
- Database tests: Data actually deleted/pseudonymized
- Documentation: Retention justifications clear
- Audit logs: Timestamp of erasure requests/completions

Additional Resources

Broader Context and Best Practices

GDPR compliance in WordPress plugins is not a one-time checkbox exercise but an ongoing commitment to responsible data handling. The regulation's principles of data minimization, purpose limitation, and storage limitation require continuous attention as plugins evolve and add new features. Every new form field, analytics tracker, or integration point potentially introduces new data processing that must be evaluated against GDPR requirements. Developers who build privacy-by-design into their development process find compliance far easier to maintain than those who treat it as an afterthought to be addressed during audits.

The technical implementation of GDPR requirements in WordPress benefits greatly from the privacy tools built into WordPress core since version 4.9.6. The personal data exporter and eraser frameworks provide standardized interfaces that plugins can hook into, ensuring consistent user experiences across the entire WordPress stack. Plugin developers who implement these hooks properly enable site administrators to respond to data subject requests efficiently and completely. Failing to implement these hooks doesn't just create a compliance gap. It creates a technical debt that becomes increasingly expensive as regulatory enforcement intensifies.

Data processing transparency is both a legal requirement and a competitive advantage for WordPress plugins. Users increasingly evaluate plugins based on their privacy practices, and the WordPress.org review team has become more stringent about data handling disclosures. Plugins that clearly document what data they collect, why they collect it, and how long they retain it build user trust and avoid review delays. This transparency extends to third-party services. If your plugin sends data to external APIs, users have a right to know, and GDPR requires explicit documentation of these data transfers.

The intersection of GDPR and WordPress plugin security deserves special attention because data breaches trigger some of GDPR's most severe consequences. Article 33 requires breach notification within 72 hours, and Article 34 requires direct notification to affected individuals when the breach poses a high risk. For plugin developers, this means that security vulnerabilities leading to data exposure can trigger regulatory consequences for every site running the plugin. This cascading liability makes security testing an essential component of GDPR compliance, not just a nice-to-have technical practice.

Broader Context and Best Practices

GDPR compliance in WordPress plugins is not a one-time checkbox exercise but an ongoing commitment to responsible data handling. The regulation's principles of data minimization, purpose limitation, and storage limitation require continuous attention as plugins evolve and add new features. Every new form field, analytics tracker, or integration point potentially introduces new data processing that must be evaluated against GDPR requirements. Developers who build privacy-by-design into their development process find compliance far easier to maintain than those who treat it as an afterthought to be addressed during audits.

The technical implementation of GDPR requirements in WordPress benefits greatly from the privacy tools built into WordPress core since version 4.9.6. The personal data exporter and eraser frameworks provide standardized interfaces that plugins can hook into, ensuring consistent user experiences across the entire WordPress stack. Plugin developers who implement these hooks properly enable site administrators to respond to data subject requests efficiently and completely. Failing to implement these hooks doesn't just create a compliance gap. It creates a technical debt that becomes increasingly expensive as regulatory enforcement intensifies.

Frequently Asked Questions

Can I keep data for "just in case" reasons?

No. GDPR requires deletion unless you have a specific, documented legal basis for retention. "Just in case" isn't sufficient.

What if the user is wrong about what data I have?

Provide them a data export first (Article 20) so they understand what's actually stored. Some users assume you have data you don't actually collect.

Can I delete data but keep anonymized aggregates?

Yes. Aggregated, non-identifiable data is no longer personal data. You can keep aggregate statistics (e.g., "average order value: $99") even after erasing individual data.

What about payment processor records?

If you don't store payment data (use Stripe, Square, etc.), you have nothing to erase. Just note that payment processors are separate data controllers with their own GDPR obligations.

Can I retain data if the user is abusive?

Moderation concerns are a "legitimate interest" under Article 17(3)(f). You can keep records of abusive behavior for account security and fraud prevention, but document this rationale.

Should I delete data immediately or wait?

GDPR gives 30 days to respond. You could queue erasures for batch processing at the end of the 30-day window, but the sooner you delete, the better your compliance posture.

Best Practices for WordPress GDPR Data Erasure

Pseudonymization > Deletion

When possible, pseudonymize instead of delete. Removes identity but preserves data integrity:

<?php
// Pseudonymize: Better than deletion
$wpdb->update($table, array(
    'user_id' => 0,
    'name' => 'Deleted User',
    'email' => '',
), $where);

// vs. deletion: Breaks relationships and auditing
$wpdb->delete($table, $where);
?>

Document Everything

For every retention decision, document the legal basis:

<?php
// Clear, defensible justification
$items_retained = array(
    'description' => 'Orders retained per GDPR Article 17(3)(b) - legal obligation to retain tax records for 7 years',
    'status' => 'ready',
);
?>

Test Regularly

Add erasure testing to your QA process:

  • Erasure with test data before each release
  • Verify audit logs are created
  • Verify deleted data is gone
  • Verify retained data is documented

Communicate with Users

Make erasure clear and transparent:

<?php
// In your privacy policy:
"Upon request, we will erase your personal data including name, email, 
address, and preferences. Orders are pseudonymized to remove your identity 
while retaining records for tax compliance (7 years, per EU law)."
?>

Conclusion

WordPress GDPR data erasure is both a legal requirement and a user trust signal. When you implement it properly, you demonstrate that you take privacy seriously and respect user autonomy.

The key distinctions:

  • Erasure ≠ deletion: Pseudonymize where you have retention reasons
  • Audit trails ≠ data hoarding: Log completion, not the deleted data
  • Legal exceptions are valid: Document them and stand behind them
  • Idempotency matters: Handle repeat requests gracefully

Getting erasure right requires understanding both the legal framework (what you must delete) and the technical implementation (how to delete safely). This guide gives you both.

Your implementation should pass GDPR audits, survive regulatory scrutiny, and give users confidence that their data is handled responsibly. Upload your plugin to WP HealthKit for an automated Article 17 compliance audit. We verify that erasers are properly implemented, sensitive data is truly deleted, retention justifications are documented, and your audit trail is appropriate.


Ensure your plugin meets GDPR Article 17 requirements. Upload to WP HealthKit for an automated compliance audit of your data erasure implementation.

Ready to audit your plugin?

WP HealthKit checks for all the issues in this article and 40+ more across 49 verification layers.

Comments

WordPress GDPR Data Erasure: Right to Be Forgotten Guide | WP HealthKit