How to create an editorial notes system per post with JS and PHP in WordPress

Contents

Overview

This tutorial explains, in full detail, how to build an editorial notes system per post in WordPress using PHP and JavaScript. The result is a notes panel attached to each post (visible in the post editor for users with appropriate capabilities) where editors, authors, and other team members can add, edit, delete, and view notes. The tutorial provides two storage approaches: using post meta (simple, good for low volume) and using a custom database table (scalable, recommended for many notes or heavy use).

What youll get

  • Plugin structure and installation instructions
  • Meta box UI in the post editor
  • AJAX handlers (admin-ajax.php) in PHP to add and remove notes securely
  • Example code using postmeta (simpler) and an alternative using a custom table
  • JavaScript code to interact with the server and update the UI live
  • Security, capability checks, sanitization, escaping, and performance guidance

Prerequisites

  • WordPress 5.x or higher (works with Gutenberg and Classic Editor because it adds a server-side meta box)
  • Basic familiarity with creating WP plugins or modifying functions.php (recommended to create a plugin)
  • Ability to add PHP and JS files to your WP installation

Design decisions and data model

Each note should contain at least:

  • Unique note ID
  • Post ID (the post the note belongs to)
  • User ID of the author of the note
  • Note content
  • Timestamp
  • Optional: type (e.g. suggestion, request, resolved), visibility flags, attachments

Two storage options:

  1. Postmeta – store an array of note objects in one postmeta entry (eg. _editorial_notes). Pros: easy to implement, no DB changes. Cons: not great for large numbers of notes and concurrent edits.
  2. Custom table – create a dedicated table (eg. wp_editorial_notes). Pros: scalable, simple queries, indexes. Cons: requires activation hook to create table and slightly more complex code.

Plugin skeleton (single-file example)

Create a plugin file such as editorial-notes.php in wp-content/plugins/editorial-notes/ and place the following header. This header is minimal — use a proper plugin name/description in real use.

lt?php
/
Plugin Name: Editorial Notes
Description: Per-post editorial notes system (simple example)
Version: 1.0
Author: Your Name
/
if ( ! defined( ABSPATH ) ) {
    exit
}

// We will add code in subsequent sections (meta box, AJAX handlers, scripts).

Approach A — Postmeta storage (simple)

1) Register meta box and enqueue admin scripts

This registers a meta box on the post edit screen and enqueues the JavaScript that manages adding and deleting notes. The meta box markup is rendered server-side, and the JS uses AJAX to call PHP handlers.

// In your plugin file (continuation)
add_action( add_meta_boxes, enote_add_meta_box )
function enote_add_meta_box() {
    add_meta_box(
        enote_meta_box,
        Editorial Notes,
        enote_meta_box_callback,
        post,            // change or add more post types as needed
        side,
        default
    )
}

add_action( admin_enqueue_scripts, enote_admin_assets )
function enote_admin_assets( hook ) {
    // Only load on post edit screens
    if ( hook !== post.php  hook !== post-new.php ) {
        return
    }

    wp_enqueue_script(
        enote-admin,
        plugin_dir_url( __FILE__ ) . js/enote-admin.js,
        array(),
        1.0,
        true
    )

    wp_enqueue_style(
        enote-admin-css,
        plugin_dir_url( __FILE__ ) . css/enote-admin.css,
        array(),
        1.0
    )

    wp_localize_script( enote-admin, ENOTE,
        array(
            ajax_url => admin_url( admin-ajax.php ),
            nonce    => wp_create_nonce( enote_nonce ),
        )
    )
}

2) Meta box output

The meta box prints the existing notes (if any) and an input area to add a new note. All HTML markup lives inside the PHP callback below. JavaScript will progressively enhance and send requests to server handlers.

function enote_meta_box_callback( post ) {
    // Fetch existing notes (stored as array in post meta)
    notes = get_post_meta( post->ID, _editorial_notes, true )
    if ( ! is_array( notes ) ) {
        notes = array()
    }

    // Nonce for form actions (AJAX will check this nonce)
    wp_nonce_field( enote_meta_box, enote_meta_box_nonce )

    // Render notes list and a textarea to add a new note
    echo ltdiv id=enote-container data-post-id= . esc_attr( post->ID ) . gt

    echo ltdiv id=enote-listgt
    if ( empty( notes ) ) {
        echo ltp class=enote-emptygtNo notes yet.lt/pgt
    } else {
        foreach ( notes as note ) {
            // Each note should be an associative array: id, author_id, content, created
            note_id = isset( note[id] ) ? esc_attr( note[id] ) : 
            author  = isset( note[author_id] ) ? intval( note[author_id] ) : 0
            author_name = author ? esc_html( get_the_author_meta( display_name, author ) ) : Unknown
            created = isset( note[created] ) ? esc_html( note[created] ) : 
            content = isset( note[content] ) ? esc_textarea( note[content] ) : 

            echo ltdiv class=enote-item data-note-id= . note_id . gt
            echo ltdiv class=enote-metagtltstronggt . author_name . lt/stronggt ltspan class=enote-dategt . created . lt/spangtlt/divgt
            echo ltdiv class=enote-contentgt . nl2br( esc_html( content ) ) . lt/divgt
            // Delete button: JS will wire it up and call AJAX to remove the note
            echo ltbutton class=enote-delete button data-note-id= . note_id .  type=buttongtDeletelt/buttongt
            echo lt/divgt
        }
    }
    echo lt/divgt // enote-list

    echo ltdiv id=enote-addgt
    echo lttextarea id=enote-new-content rows=4 style=width:100% placeholder=Add a note… gtlt/textareagt
    echo ltbutton id=enote-add-button class=button button-primary type=buttongtAdd Notelt/buttongt
    echo lt/divgt

    echo lt/divgt // enote-container
}

3) AJAX handlers (server-side)

We add two handlers: one to add a note and one to delete a note. They verify nonces and user capabilities and then update the postmeta. Notes are stored as an array in the _editorial_notes meta key.

// Add note
add_action( wp_ajax_enote_add_note, enote_ajax_add_note )
function enote_ajax_add_note() {
    // Verify nonce
    check_ajax_referer( enote_nonce, nonce )

    post_id = isset( _POST[post_id] ) ? intval( _POST[post_id] ) : 0
    content = isset( _POST[content] ) ? wp_kses_post( wp_unslash( _POST[content] ) ) : 

    if ( ! post_id  ! current_user_can( edit_post, post_id ) ) {
        wp_send_json_error( array( message => Invalid permissions or post. ), 403 )
    }

    if ( empty( trim( content ) ) ) {
        wp_send_json_error( array( message => Empty content. ), 400 )
    }

    // Build note
    user_id = get_current_user_id()
    note_id = uniqid( enote_ )
    note = array(
        id        => note_id,
        author_id => user_id,
        content   => sanitize_textarea_field( content ),
        created   => current_time( mysql ),
    )

    // Get existing notes
    notes = get_post_meta( post_id, _editorial_notes, true )
    if ( ! is_array( notes ) ) {
        notes = array()
    }

    // Append and update
    notes[] = note
    update_post_meta( post_id, _editorial_notes, notes )

    // Prepare response: we can return author display name and created time
    response = array(
        note => note,
        author_name => get_the_author_meta( display_name, user_id ),
    )

    wp_send_json_success( response )
}

// Delete note
add_action( wp_ajax_enote_delete_note, enote_ajax_delete_note )
function enote_ajax_delete_note() {
    check_ajax_referer( enote_nonce, nonce )

    post_id = isset( _POST[post_id] ) ? intval( _POST[post_id] ) : 0
    note_id = isset( _POST[note_id] ) ? sanitize_text_field( wp_unslash( _POST[note_id] ) ) : 

    if ( ! post_id  ! note_id  ! current_user_can( edit_post, post_id ) ) {
        wp_send_json_error( array( message => Invalid request. ), 403 )
    }

    notes = get_post_meta( post_id, _editorial_notes, true )
    if ( ! is_array( notes ) ) {
        notes = array()
    }

    found = false
    foreach ( notes as idx = notes ) {} // noop to satisfy strict references (well use a loop below)
    // Actually remove matching note
    foreach ( notes as index => note ) {
        if ( isset( note[id] )  note[id] === note_id ) {
            unset( notes[ index ] )
            found = true
            break
        }
    }

    if ( found ) {
        // Reindex and update
        notes = array_values( notes )
        update_post_meta( post_id, _editorial_notes, notes )
        wp_send_json_success( array( message => Deleted ) )
    } else {
        wp_send_json_error( array( message => Note not found. ), 404 )
    }
}

4) Admin JavaScript

Place the following code in js/enote-admin.js. It handles click events, sends AJAX requests to add and delete notes, and updates the DOM live.

( function() {
    document.addEventListener( DOMContentLoaded, function() {
        var container = document.getElementById( enote-container )
        if ( ! container ) return

        var postId = container.getAttribute( data-post-id )
        var addButton = document.getElementById( enote-add-button )
        var textarea = document.getElementById( enote-new-content )
        var list = document.getElementById( enote-list )

        function createNoteElement( note, author_name ) {
            var wrap = document.createElement( div )
            wrap.className = enote-item
            wrap.setAttribute( data-note-id, note.id )

            var meta = document.createElement( div )
            meta.className = enote-meta
            var strong = document.createElement( strong )
            strong.textContent = author_name  Unknown
            var dateSpan = document.createElement( span )
            dateSpan.className = enote-date
            dateSpan.textContent = note.created  

            meta.appendChild( strong )
            meta.appendChild( document.createTextNode(   ) )
            meta.appendChild( dateSpan )

            var contentDiv = document.createElement( div )
            contentDiv.className = enote-content
            contentDiv.innerHTML = note.content.replace(/n/g, ltbrgt)

            var delBtn = document.createElement( button )
            delBtn.className = enote-delete button
            delBtn.setAttribute( data-note-id, note.id )
            delBtn.type = button
            delBtn.textContent = Delete

            wrap.appendChild( meta )
            wrap.appendChild( contentDiv )
            wrap.appendChild( delBtn )

            return wrap
        }

        // Add note
        if ( addButton ) {
            addButton.addEventListener( click, function() {
                var content = textarea.value.trim()
                if ( ! content ) {
                    alert( Please enter a note. )
                    return
                }

                var data = new FormData()
                data.append( action, enote_add_note )
                data.append( nonce, ENOTE.nonce )
                data.append( post_id, postId )
                data.append( content, content )

                fetch( ENOTE.ajax_url, {
                    method: POST,
                    credentials: same-origin,
                    body: data
                } ).then( function( resp ) {
                    return resp.json()
                } ).then( function( json ) {
                    if ( json.success ) {
                        var note = json.data.note
                        var author_name = json.data.author_name
                        var el = createNoteElement( note, author_name )
                        // Remove No notes yet. placeholder
                        var empty = list.querySelector( .enote-empty )
                        if ( empty ) empty.remove()
                        list.appendChild( el )
                        textarea.value = 
                    } else {
                        alert( json.data  json.data.message ? json.data.message : Error )
                    }
                } ).catch( function() {
                    alert( Network or server error. )
                } )
            } )
        }

        // Delete note (event delegation)
        list.addEventListener( click, function( e ) {
            var target = e.target
            if ( target  target.classList.contains( enote-delete ) ) {
                var noteId = target.getAttribute( data-note-id )
                if ( ! noteId ) return
                if ( ! confirm( Delete this note? ) ) return

                var data = new FormData()
                data.append( action, enote_delete_note )
                data.append( nonce, ENOTE.nonce )
                data.append( post_id, postId )
                data.append( note_id, noteId )

                fetch( ENOTE.ajax_url, {
                    method: POST,
                    credentials: same-origin,
                    body: data
                } ).then( function( resp ) {
                    return resp.json()
                } ).then( function( json ) {
                    if ( json.success ) {
                        // Remove DOM element
                        var item = list.querySelector( .enote-item[data-note-id=   noteId   ] )
                        if ( item ) item.remove()
                        // If list is empty, show placeholder
                        if ( list.children.length === 0 ) {
                            var p = document.createElement( p )
                            p.className = enote-empty
                            p.textContent = No notes yet.
                            list.appendChild( p )
                        }
                    } else {
                        alert( json.data  json.data.message ? json.data.message : Error deleting note )
                    }
                } ).catch( function() {
                    alert( Network or server error. )
                } )
            }
        } )

    } )
} )()

5) Simple CSS (optional)

Put the CSS in css/enote-admin.css to style the meta box. This is optional but useful for clarity.

#enote-list { margin-bottom: 10px }
.enote-item { border: 1px solid #ddd padding: 8px margin-bottom: 8px background: #fff }
.enote-meta { font-size: 12px color: #666 margin-bottom: 6px }
.enote-content { font-size: 13px margin-bottom: 6px }
.enote-delete { float: right }
.enote-empty { color: #888 font-style: italic }

6) Notes on concurrency and atomicity

Since postmeta holds a single array, there is a potential race condition: two concurrent saves may both fetch the old array, append different notes, then each write back and one will overwrite the other. For low-volume editorial usage this is usually not a problem. If you expect concurrent edits (multiple editors adding notes at the exact same time), use the custom table approach described below.

Approach B — Custom DB table (scalable)

For more robust storage and better query performance, create a custom DB table to store each note in its own row. This model supports indexing and avoids race conditions when adding rows.

1) Create the table on plugin activation

register_activation_hook( __FILE__, enote_create_table )
function enote_create_table() {
    global wpdb
    table_name = wpdb->prefix . editorial_notes
    charset_collate = wpdb->get_charset_collate()

    sql = CREATE TABLE IF NOT EXISTS table_name (
        id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
        post_id bigint(20) unsigned NOT NULL,
        user_id bigint(20) unsigned NOT NULL,
        content longtext NOT NULL,
        created datetime NOT NULL,
        status varchar(20) DEFAULT active,
        PRIMARY KEY  (id),
        KEY post_id (post_id),
        KEY user_id (user_id)
    ) charset_collate

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

2) Insert and fetch notes using wpdb

Example functions for inserting, deleting, and fetching notes.

function enote_insert_note_db( post_id, user_id, content ) {
    global wpdb
    table = wpdb->prefix . editorial_notes

    result = wpdb->insert(
        table,
        array(
            post_id => post_id,
            user_id => user_id,
            content => content,
            created => current_time( mysql ),
            status  => active,
        ),
        array( %d, %d, %s, %s, %s )
    )

    if ( false === result ) {
        return false
    }

    return wpdb->insert_id
}

function enote_get_notes_db( post_id ) {
    global wpdb
    table = wpdb->prefix . editorial_notes
    sql = wpdb->prepare( SELECT id, post_id, user_id, content, created, status FROM table WHERE post_id = %d AND status = active ORDER BY created ASC, post_id )
    return wpdb->get_results( sql, ARRAY_A )
}

function enote_delete_note_db( note_id ) {
    global wpdb
    table = wpdb->prefix . editorial_notes
    return wpdb->update( table, array( status => deleted ), array( id => intval( note_id ) ), array( %s ), array( %d ) )
}

3) Adapt the meta box and AJAX handlers

Instead of reading/writing postmeta, call these DB functions in your AJAX handlers and meta box display. The JavaScript can remain the same — only the server-side read/write changes. When using the custom table approach you avoid overwriting other users notes because each note is an independent row.

Alternative integration: use the REST API

As an alternative to admin-ajax.php you can expose a small REST API namespace (eg. /wp-json/enote/v1/notes) and use fetch requests from JavaScript to POST/DELETE. This is more modern and integrates with authentication via nonces and cookies however, admin-ajax is simpler within the admin context. If youd like REST examples, implement register_rest_route() with permission_callback checking current_user_can(edit_post, post_id).

Security checklist

  • Always verify nonces in AJAX or REST callbacks (check_ajax_referer or check_rest_nonce).
  • Use current_user_can(edit_post, post_id) to ensure only authorized users can add/delete notes.
  • Sanitize incoming data: sanitize_textarea_field or wp_kses_post if HTML is allowed.
  • Escape output with esc_html(), esc_textarea(), or appropriate escaping functions when rendering.
  • When using wpdb, always use wpdb->prepare for dynamic queries and proper formats for insert/update.

UX considerations

  • Decide who can see notes: all users, only editors, or only certain roles. Enforce via capability checks.
  • Consider adding note types (eg. private, public) for filtering.
  • Add pagination or lazy-loading if posts commonly have hundreds of notes.
  • Consider an edit capability on notes (allow updating note content) and an audit trail (who changed what and when).
  • Consider showing notes in the frontend for logged-in users (create a template function to render notes for a post).

Frontend rendering example (read-only)

To render notes on the public post view for privileged users, create a function that outputs notes. Example (postmeta approach):

function enote_render_public_notes( post_id ) {
    notes = get_post_meta( post_id, _editorial_notes, true )
    if ( ! is_array( notes )  empty( notes ) ) {
        return
    }

    echo ltdiv class=enote-publicgt
    echo lth4gtEditorial Noteslt/h4gt
    foreach ( notes as note ) {
        author = isset( note[author_id] ) ? get_the_author_meta( display_name, note[author_id] ) : Unknown
        created = isset( note[created] ) ? esc_html( note[created] ) : 
        content = isset( note[content] ) ? wp_kses_post( nl2br( esc_html( note[content] ) ) ) : 
        echo ltdiv class=enote-public-itemgt
        echo ltdiv class=metagtltstronggt . esc_html( author ) . lt/stronggt ltspangt . created . lt/spangtlt/divgt
        echo ltdiv class=contentgt . content . lt/divgt
        echo lt/divgt
    }
    echo lt/divgt
}

Call enote_render_public_notes( get_the_ID() ) from your theme where you want the notes shown. Ensure only authorized viewing as needed.

Performance tips

  • If using postmeta for many posts, avoid storing massive arrays. Consider custom table if notes per post can be large.
  • Cache note queries with transient caching or object caching for read-heavy scenarios.
  • Index the post_id column in a custom table to speed up lookups.
  • For high concurrency, prefer inserting rows (custom table) to reduce race conditions.

Example folder structure for the plugin

editorial-notes/
├─ editorial-notes.php         (main plugin file)
├─ js/
│  └─ enote-admin.js
├─ css/
│  └─ enote-admin.css

Troubleshooting and common pitfalls

  • AJAX returns 0: usually indicates a PHP fatal error or misconfigured action. Check PHP error log and ensure add_action( wp_ajax_{action}, handler ) is set.
  • Nonce verification fails: ensure the nonce sent from JS matches the one generated (wp_localize_script -> wp_create_nonce). Use check_ajax_referer or wp_verify_nonce.
  • Permissions errors: verify current_user_can(edit_post, post_id) and that the correct post_id is passed in the request.
  • Concurrent updates lost (postmeta array): consider switching to a custom table to store each note as its own row.

Advanced ideas

  • Add note editing (allow authors to edit their notes) and preserve revision history for notes.
  • Add note tagging, filtering by status, or assign notes to users.
  • Integrate with notifications (send email or Slack when a new note is created).
  • Create a Gutenberg sidebar plugin that interacts with the same backend endpoints for a richer, block-editor native experience.
  • Expose REST endpoints for third-party tools and integrations.

Resources

Final notes

This tutorial gave you both a quick postmeta-based implementation suitable for small teams and a scalable custom table approach for production-grade editorial workflows. The examples cover secure AJAX interactions, capability checks, and both server and client code that you can copy into a plugin structure. Adjust styles, roles, and UI placement as needed to fit your editorial workflow.



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

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