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:
- 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.
- 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 🙂 |