How to log user activity with hooks in PHP in WordPress

Contents

Introduction

This article is a complete, detailed tutorial for logging user activity in WordPress using hooks in PHP. It covers architecture decisions, database schema, a production-ready plugin scaffold, example hook implementations (login, logout, registration, profile updates, failed logins, AJAX, comments, etc.), an admin UI for viewing and exporting logs, alternatives (custom post type, file-based logging), performance, security and GDPR considerations, and advanced patterns (background processing and REST endpoint). Code examples are provided and must be copied exactly into plugin files or mu-plugins each code sample is provided using the required

 wrapper for PHP and other languages where noted.

Why log user activity?

  • Security — detect suspicious behavior, brute-force attempts, or compromised accounts.
  • Auditing — maintain a record of who changed content, user roles, or settings.
  • Support Debugging — understand what actions a user performed before encountering an issue.
  • Analytics — augment analytics about user flows with server-side events.

Design choices and data model

Before implementing, choose where to store logs. The common options:

  • Custom DB table — best for performance, controlled schema, large volume, and indexing.
  • Custom post type — leverages WP APIs, good for small-medium volume, easy UI via admin posts list.
  • File-based — simple, append-only logs for low volume or external ingestion watch file permissions.
  • External service — ELK, Graylog, Sentry, or a cloud logging service for advanced aggregation and alerting.

Recommended schema for a custom table

A compact, indexed schema for high-volume logging:

columntypenotes
idBIGINT unsigned, AUTO_INCREMENT, PRIMARY KEYsurrogate key
timeDATETIMEUTC timestamp
user_idBIGINT unsigned0 for guests
user_loginVARCHAR(60)username or display
ipVARBINARY(16)store IPv4/IPv6 binary or hash
user_agentTEXTtrim length in inserts
actionVARCHAR(100)machine-friendly action name (login, logout, edit_post)
object_typeVARCHAR(50)post, comment, user, option, etc.
object_idBIGINTnullable
metaLONGTEXTJSON-encoded contextual data

Plugin scaffold and table creation

Create a simple plugin main file (e.g., user-activity-logger.php). The following activation code creates the table. Adjust table name using wpdb->prefix and handle charset and collate.

prefix . user_activity_log
}

function ual_activate() {
    global wpdb, user_activity_logger_db_version

    table_name = ual_get_table_name()
    charset_collate = wpdb->get_charset_collate()

    sql = CREATE TABLE {table_name} (
        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        time DATETIME NOT NULL,
        user_id BIGINT UNSIGNED NOT NULL,
        user_login VARCHAR(60) DEFAULT ,
        ip VARBINARY(16) DEFAULT NULL,
        user_agent TEXT,
        action VARCHAR(100) NOT NULL,
        object_type VARCHAR(50) DEFAULT NULL,
        object_id BIGINT DEFAULT NULL,
        meta LONGTEXT DEFAULT NULL,
        PRIMARY KEY (id),
        KEY user_id (user_id),
        KEY time (time),
        KEY action (action)
    ) {charset_collate}

    require_once ABSPATH . wp-admin/includes/upgrade.php
    dbDelta( sql )

    add_option( ual_db_version, user_activity_logger_db_version )
}

Core helper to insert a log

Centralize writes to the log table. Use prepared statements to avoid SQL injection, and JSON-encode the meta. For IP privacy, you can hash or store partial IPs.

 current_time( mysql, true ), // UTC
        user_id    => 0,
        user_login => ,
        ip         => null,
        user_agent => ,
        action     => ,
        object_type=> null,
        object_id  => null,
        meta       => null,
    )

    r = wp_parse_args( args, defaults )

    // Optionally anonymize IP for GDPR (e.g., store hash or truncated)
    ip_raw = r[ip] ?? ual_get_client_ip()
    ip_bin = null
    if ( ! empty( ip_raw ) ) {
        // Store as binary for IPv4/IPv6 efficiency:
        ip_bin = inet_pton( ip_raw )
        if ( ip_bin === false ) {
            ip_bin = null
        }
    }

    meta = r[meta] ? wp_json_encode( r[meta] ) : null

    data = array(
        time        => r[time],
        user_id     => (int) r[user_id],
        user_login  => substr( r[user_login], 0, 60 ),
        ip          => ip_bin,
        user_agent  => substr( r[user_agent], 0, 65535 ),
        action      => substr( r[action], 0, 100 ),
        object_type => r[object_type],
        object_id   => r[object_id] ? (int) r[object_id] : null,
        meta        => meta,
    )

    format = array( %s, %d, %s, %s, %s, %s, %s, %d, %s )

    // Use wpdb->replace when wanting to upsert insert for append-only.
    inserted = wpdb->insert( table, data, format )

    return inserted !== false
}

Hooking common events

Hook into WordPress actions to capture relevant events. Examples below demonstrate key events and how to pass contextual data into ual_insert_log(). Add these to your plugin file.

Registration

 user_id,
        user_login => user->user_login ?? ,
        action     => user_register,
        object_type=> user,
        object_id  => user_id,
        meta       => array(
            display_name => user->display_name ?? ,
            roles        => user->roles ?? array(),
        ),
    ) )
}

Login and failed login

 user->ID,
        user_login => user_login,
        action     => wp_login,
        meta       => array(
            method => standard,
        ),
    ) )
}

add_action( wp_login_failed, ual_wp_login_failed )
function ual_wp_login_failed( username ) {
    ual_insert_log( array(
        user_id    => 0,
        user_login => username,
        action     => wp_login_failed,
        meta       => array(
            ip => ual_get_client_ip(),
        ),
    ) )
}

add_action( wp_logout, ual_wp_logout )
function ual_wp_logout() {
    user = wp_get_current_user()
    ual_insert_log( array(
        user_id    => user->ID ?? 0,
        user_login => user->user_login ?? ,
        action     => wp_logout,
    ) )
}

Profile updates and role changes

 old_user_data->display_name ?? ,
        new_display_name => user->display_name ?? ,
        old_roles        => old_user_data->roles ?? array(),
        new_roles        => user->roles ?? array(),
    )

    ual_insert_log( array(
        user_id    => user_id,
        user_login => user->user_login ?? ,
        action     => profile_update,
        meta       => meta,
    ) )
}

Post and comment actions

 user->ID ?? 0,
        user_login => user->user_login ?? ,
        action     => update ? post_updated : post_created,
        object_type=> post,
        object_id  => post_ID,
        meta       => array( post_type => post->post_type, post_status => post->post_status ),
    ) )
}

add_action( delete_post, ual_delete_post )
function ual_delete_post( post_id ) {
    user = wp_get_current_user()
    ual_insert_log( array(
        user_id    => user->ID ?? 0,
        user_login => user->user_login ?? ,
        action     => post_deleted,
        object_type=> post,
        object_id  => post_id,
    ) )
}

add_action( comment_post, ual_comment_post, 10, 3 )
function ual_comment_post( comment_ID, comment_approved, commentdata ) {
    user = wp_get_current_user()
    ual_insert_log( array(
        user_id    => user->ID ?? 0,
        user_login => user->user_login ?? ,
        action     => comment_created,
        object_type=> comment,
        object_id  => comment_ID,
        meta       => array( approved => comment_approved ),
    ) )
}

AJAX and custom actions

Log AJAX calls and custom actions. Always validate nonces for security.

 user->ID ?? 0,
        user_login => user->user_login ?? ,
        action     => ajax_my_special_action,
        meta       => array( request => _REQUEST ),
    ) )

    wp_send_json_success( array( ok => true ) )
}

Admin UI to view and export logs

Provide a simple admin page using add_menu_page() to view logs with pagination and CSV export. The example below is minimal — in production add sanitization, searching, date filters and capability checks.

get_results( wpdb->prepare( SELECT  FROM {table} ORDER BY time DESC LIMIT %d, %d, offset, per_page ), ARRAY_A )
        header( Content-Type: text/csv charset=utf-8 )
        header( Content-Disposition: attachment filename=activity-log.csv )
        output = fopen( php://output, w )
        fputcsv( output, array_keys( rows[0] ?? array() ) )
        foreach ( rows as row ) {
            // Convert binary IP to text
            if ( ! empty( row[ip] ) ) {
                row[ip] = inet_ntop( row[ip] )
            }
            fputcsv( output, row )
        }
        exit
    }

    total = (int) wpdb->get_var( SELECT COUNT() FROM {table} )
    rows = wpdb->get_results( wpdb->prepare( SELECT  FROM {table} ORDER BY time DESC LIMIT %d, %d, offset, per_page ), ARRAY_A )

    echo ltdiv class=wrapgt
    echo lth2gtUser Activity Logslt/h2gt
    echo lta href=?page=ual-logsampexport=csv class=buttongtExport CSV (page)lt/agt
    echo lttable class=wp-list-table widefat fixed stripedgt
    echo lttheadgtlttrgtltthgtTimelt/thgtltthgtUserlt/thgtltthgtActionlt/thgtltthgtObjectlt/thgtltthgtMetalt/thgtlt/trgtlt/theadgt
    echo lttbodygt
    foreach ( rows as row ) {
        ip_display = row[ip] ? @inet_ntop( row[ip] ) : 
        meta = row[meta] ? wp_json_encode( json_decode( row[meta], true ) ) : 
        echo lttrgt
        echo lttdgt . esc_html( row[time] ) . lt/tdgt
        echo lttdgt . esc_html( row[user_login] ) .  ( . esc_html( row[user_id] ) . ) ltsmallgt . esc_html( ip_display ) . lt/smallgtlt/tdgt
        echo lttdgt . esc_html( row[action] ) . lt/tdgt
        echo lttdgt . esc_html( row[object_type] ) .   . esc_html( row[object_id] ) . lt/tdgt
        echo lttdgt . esc_html( meta ) . lt/tdgt
        echo lt/trgt
    }
    echo lt/tbodygtlt/tablegt
    // Simple pagination links
    pages = ceil( total / per_page )
    if ( pages > 1 ) {
        echo ltdiv class=tablenavgt
        for ( i = 1 i lt= pages i   ) {
            class = i === paged ? class=page-numbers current : class=page-numbers
            echo lta  . class .  href=?page=ual-logsamppaged= . i . gt . i . lt/agt 
        }
        echo lt/divgt
    }
    echo lt/divgt
}

Alternative: use a custom post type

For small-to-moderate volume, storing logs as a private custom post type reduces custom UI work and benefits from WPs revision and meta APIs. Downsides: performance and table bloat.

 User Logs,
        public      => false,
        show_ui     => true,
        supports    => array( custom-fields ),
        capability_type => post,
        map_meta_cap    => true,
    ) )
}

// To create a log:
function ual_cpt_insert_log( args = array() ) {
    post = array(
        post_type    => user_log,
        post_title   => args[action] ?? log,
        post_status  => private,
        post_content => wp_json_encode( args[meta] ?? array() ),
        meta_input   => array(
            time => current_time( mysql, true ),
            user_id => args[user_id] ?? 0,
        ),
    )
    return wp_insert_post( post, true )
}

File-based logging (simple)

If you prefer to append events to a rotating file, ensure safe file locations, permissions, rotation and avoid writing from many PHP processes simultaneously without locking.


Performance and scaling

  • Index common lookup columns (user_id, time, action).
  • Batch writes — if you have high throughput, accumulate events and insert in batches using a background job (Action Scheduler or wp_cron).
  • Avoid heavy work in hooks — keep the hook handler lightweight and defer heavy processing.
  • Retention — prune old logs with scheduled cleanup to keep the table manageable.
  • Compression — export and archive old logs externally if needed.

Example: schedule background insert via transient queue


Security, privacy, and GDPR considerations

  • Use proper SQL preparation and the wpdb API to prevent injection.
  • Escape HTML when outputting logs in the admin (esc_html(), esc_attr()).
  • Limit who can view logs (manage_options or custom capability).
  • Minimize personally identifiable information (PII) — consider hashing IPs or storing only truncated versions.
  • Implement retention policies and deletion APIs in line with GDPR: allow manual deletion and scheduled purge.
  • Document logging in your privacy policy and provide mechanisms to export or delete personal data.
  • Be careful with user_agent and request payloads — they may contain sensitive tokens or data.

REST API endpoint (secure)

Expose logs via a REST route only to authorized users (e.g., users with manage_options or a specific capability), and sanitize output.

 GET,
        callback => ual_rest_get_logs,
        permission_callback => function ( request ) {
            return current_user_can( manage_options )
        },
    ) )
} )

function ual_rest_get_logs( request ) {
    global wpdb
    table = ual_get_table_name()
    limit = min( 200, (int) ( request->get_param( per_page ) ?? 50 ) )
    offset = max( 0, (int) ( request->get_param( offset ) ?? 0 ) )

    rows = wpdb->get_results( wpdb->prepare( SELECT  FROM {table} ORDER BY time DESC LIMIT %d, %d, offset, limit ), ARRAY_A )
    foreach ( rows as amprow ) {
        if ( ! empty( row[ip] ) ) {
            row[ip] = @inet_ntop( row[ip] )
        }
        row[meta] = row[meta] ? json_decode( row[meta], true ) : null
    }
    return rest_ensure_response( rows )
}

Testing and debugging

  • Test hooks by simulating actions (create user, login, update profile, create post, comment, AJAX).
  • Use WP_DEBUG_LOG to capture plugin errors avoid writing debug to production logs.
  • Load test your logging under expected concurrency to ensure table writes remain performant.
  • Verify that logs do not contain secrets (API keys, passwords). Never log raw passwords.

Best practices checklist

  1. Define the events you need to log avoid logging everything indiscriminately.
  2. Prefer a custom table for volume and indexing use CPT for convenience if volume is low.
  3. Sanitize and prepare all data before inserting and escape on output.
  4. Provide a retention policy and mechanisms to delete/export data for privacy compliance.
  5. Keep hook handlers lightweight defer heavy work to background jobs.
  6. Restrict access to logs to authorized roles only and log access to the logs themselves if needed.
  7. Document the system and include logging practices in your privacy policy.

Notes on integration and further enhancements

  • Action Scheduler or WP Background Processing can be used for reliable background processing at scale.
  • Consider shipping logs to an external aggregator for centralized analysis and alerting.
  • Add rich search and filters (by date range, user, action) to the admin UI for easier triage.
  • Use prepared JSON schemas for meta to make processing easier downstream.
  • Record user impersonation events (if using admin impersonation plugins) to preserve audit trails.

References

For function references and best practices, consult the WordPress Developer resources like developer.wordpress.org. Also consider PHP functions inet_pton/inet_ntop for IP handling and wp_json_encode/wp_json_decode for robust JSON handling within WordPress.

Summary

Logging user activity via WordPress hooks is straightforward: decide on storage, create a safe insertion helper, attach to relevant hooks, and build a minimal admin UI. Prioritize security, privacy and performance. The supplied code examples form a baseline plugin that you can extend with filtering, export, advanced UI and background processing to match production requirements.



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *