Contents
Introduction
This tutorial shows how to count WordPress post views without using plugins by combining PHP, post meta and WordPress transients. The technique minimizes database writes by batching increments in short-lived transients and flushing them to post meta only periodically or when a threshold is reached. You get a lightweight, plugin-free, high-performance view counter suitable for single-server and small-scale sites. This tutorial includes production-ready examples, detection to avoid counting bots or admin views, optional WP-Cron flushing, and helper functions to display, reset and manage counts.
Why use transients?
- Reduce writes: Instead of updating post_meta on every page load, store a small counter in a transient and only flush to the database occasionally.
- Performance: Fewer writes reduce I/O, locks and contention when you get many read requests.
- Simple: Transients are built into WordPress and degrade gracefully.
High-level approach
- Hook into the page view for a single post (front-end only) and detect valid views (not admins, not previews, not bots).
- Increment an in-memory/transient counter for that post. Keep a master option/list of active posts with pending increments (to support cron flush).
- When the transient count passes a threshold, or when a scheduled cron runs, flush the transient to post_meta (the persisted view count) and clear the transient.
- Provide helper functions to get the current view count (persisted transient), display it in templates, and reset if needed.
Design choices and caveats
- Transient lifetime: Short (e.g., 5–15 minutes) to keep writing infrequent but timely.
- Flush threshold: You can flush when transient hits N hits or rely solely on cron to flush periodically. Combining both is robust.
- Atomicity: Transients post_meta updates are not strictly atomic. For extremely high concurrency, use a persistent object cache (Redis/Memcached) or a dedicated table.
- Bot detection: Basic user-agent filtering reduces false counts, but no method is perfect.
- Theme vs plugin: This example assumes adding code to your themes functions.php. For portability, convert to a plugin (recommended for production).
Full implementation (copy into functions.php)
Paste the following into your themes functions.php (or convert to a plugin file). It provides: counting on view, transient buffering, optional cron flush, helpers to get/display/reset counts, and a small bot filter.
= this } if ( ! defined( PV_ACTIVE_POSTS_OPTION ) ) { define( PV_ACTIVE_POSTS_OPTION, pv_active_posts ) // Option to track posts with pending counts } if ( ! defined( PV_META_KEY ) ) { define( PV_META_KEY, pv_views ) // Post meta key used for persisted counts } if ( ! defined( PV_CRON_HOOK ) ) { define( PV_CRON_HOOK, pv_flush_transients_hook ) // Cron hook name } if ( ! defined( PV_CRON_SCHEDULE ) ) { define( PV_CRON_SCHEDULE, 15 MINUTE_IN_SECONDS ) // Cron interval to flush (15 minutes) } / ---------- Utility: is bot? ---------- / function pv_is_bot() { if ( empty( _SERVER[HTTP_USER_AGENT] ) ) { return true } ua = strtolower( wp_unslash( _SERVER[HTTP_USER_AGENT] ) ) // Basic list of common bots - extend as needed bot_signatures = array( bot, crawl, spider, slurp, bingpreview, mediapartners-google, facebookexternalhit, facebookplatform, ahrefsbot, semrushbot, mj12bot, ) foreach ( bot_signatures as s ) { if ( false !== strpos( ua, s ) ) { return true } } return false } / ---------- Counting logic ---------- / function pv_count_post_view() { // Only run on front-end single post views if ( is_admin() ) { return } if ( is_preview() ) { return } if ( ! is_singular() ! is_main_query() ) { return } if ( pv_is_bot() ) { // skip bots return } post_id = get_queried_object_id() if ( ! post_id post !== get_post_type( post_id ) ) { // Change condition if you want counts for other post types return } // Optionally avoid counting for certain logged-in roles if ( is_user_logged_in() ) { user = wp_get_current_user() if ( in_array( administrator, (array) user->roles, true ) ) { return // administrators not counted } // Remove above check if you want to count logged-in users } transient_key = pv_count_ . post_id count = get_transient( transient_key ) if ( false === count ) { count = 0 } count // Save back transient set_transient( transient_key, count, PV_TRANSIENT_TTL ) // If threshold reached, flush immediately if ( count >= PV_FLUSH_THRESHOLD ) { pv_flush_post_transient( post_id ) return } // Record post id for cron-based flushes active = get_option( PV_ACTIVE_POSTS_OPTION, array() ) if ( ! in_array( post_id, active, true ) ) { active[] = post_id update_option( PV_ACTIVE_POSTS_OPTION, active ) } } add_action( wp, pv_count_post_view, 20 ) / ---------- Flush single post transient to post meta ---------- / function pv_flush_post_transient( post_id ) { post_id = absint( post_id ) if ( ! post_id ) { return false } transient_key = pv_count_ . post_id count = get_transient( transient_key ) if ( false === count 0 === (int) count ) { // nothing to do // Ensure the post id is removed from active posts list pv_remove_active_post( post_id ) return false } // Add the buffered count to persistent post_meta persisted = (int) get_post_meta( post_id, PV_META_KEY, true ) new = persisted (int) count update_post_meta( post_id, PV_META_KEY, new ) // Delete transient and remove from active posts delete_transient( transient_key ) pv_remove_active_post( post_id ) return true } / ---------- Manage active posts option ---------- / function pv_remove_active_post( post_id ) { active = get_option( PV_ACTIVE_POSTS_OPTION, array() ) index = array_search( post_id, active, true ) if ( false !== index ) { unset( active[ index ] ) // Reindex active = array_values( active ) update_option( PV_ACTIVE_POSTS_OPTION, active ) } } / ---------- Cron: flush all tracked transients ---------- / function pv_flush_all_transients_cron() { active = get_option( PV_ACTIVE_POSTS_OPTION, array() ) if ( empty( active ) ) { return } foreach ( active as post_id ) { pv_flush_post_transient( post_id ) } } add_action( PV_CRON_HOOK, pv_flush_all_transients_cron ) / Schedule cron event if not scheduled / function pv_ensure_cron_schedule() { if ( ! wp_next_scheduled( PV_CRON_HOOK ) ) { wp_schedule_event( time(), pv_15min, PV_CRON_HOOK ) } } add_action( init, pv_ensure_cron_schedule ) / Register a custom cron schedule of 15 minutes / function pv_cron_schedules( schedules ) { if ( ! isset( schedules[pv_15min] ) ) { schedules[pv_15min] = array( interval => PV_CRON_SCHEDULE, display => __( Every 15 Minutes, pv ), ) } return schedules } add_filter( cron_schedules, pv_cron_schedules ) / Unschedule cron on theme switch (good hygiene) / function pv_clear_cron_on_theme_switch() { timestamp = wp_next_scheduled( PV_CRON_HOOK ) if ( timestamp ) { wp_clear_scheduled_hook( PV_CRON_HOOK ) } // Also attempt to flush any remaining transients to be safe pv_flush_all_transients_cron() } add_action( switch_theme, pv_clear_cron_on_theme_switch ) / ---------- Helper: get current views (persisted buffered) ---------- / function get_post_views( post_id = 0 ) { post_id = (int) post_id if ( 0 === post_id ) { post_id = get_the_ID() } if ( ! post_id ) { return 0 } persisted = (int) get_post_meta( post_id, PV_META_KEY, true ) transient_key = pv_count_ . post_id buffer = get_transient( transient_key ) if ( false === buffer ) { buffer = 0 } return persisted (int) buffer } / ---------- Helper: reset post views ---------- / function pv_reset_post_views( post_id ) { post_id = (int) post_id if ( ! post_id ) { return false } delete_post_meta( post_id, PV_META_KEY ) delete_transient( pv_count_ . post_id ) pv_remove_active_post( post_id ) return true } / ---------- Optional: Admin quick reset link (example) ---------- You can add UI to the admin (e.g. a row action to reset) if you want. This sample is intentionally omitted to keep the example focused. / / End of PV counter code / ?>
How it works (step-by-step)
- User visits a single post page. The wp action triggers pv_count_post_view().
- The function checks context (front-end, singular post, not admin/preview, not a bot).
- If valid, it increments a transient named pv_count_{POST_ID} and stores/update an active posts option entry so the cron knows which posts have pending counts.
- If the transient value reaches PV_FLUSH_THRESHOLD (default 10), pv_flush_post_transient() is called immediately to add the buffered count to the post meta PV_META_KEY and delete the transient.
- Every PV_CRON_SCHEDULE (default 15 minutes) a scheduled job pv_flush_all_transients_cron() flushes all pending transients tracked in the PV_ACTIVE_POSTS_OPTION option.
- get_post_views() reads persisted meta plus any transient buffer for a near-real-time count.
Where to show the view count in your template
Place this where you want the view count to appear (e.g. inside single.php or content templates):
Resetting counts
To reset a posts views from code or in a small admin action, call:
Alternative flush strategy: only WP-Cron (no threshold)
If you prefer not to flush by threshold and rely solely on cron, set PV_FLUSH_THRESHOLD to a very large number (or remove threshold checks) and let the scheduled job flush buffers every N minutes. That reduces immediate DB writes even further but means the persisted count will lag by the cron interval.
Enhancements and production notes
- Use persistent object cache: If your site is high-traffic, enabling Redis or Memcached and using a persistent object cache will make transient increments more reliable and scalable.
- Atomic increment: WP Transients are not atomic across concurrent requests. On very busy sites you can employ an alternative atomic increment provided by your object cache (e.g., Redis INCR) or use a custom table with an atomic UPDATE … SET col = col 1 query.
- Bot detection: The included UA-based detection is basic. Consider server-side bot lists or third-party services if accurate filtering is crucial.
- Cache compatibility: If you use full-page caching (Varnish, WP Super Cache, etc.), the counting hook may not run because the cached HTML is served bypassing PHP. For cached responses, use a client-side approach (AJAX ping on page load) to register views.
- Multisite: If running WordPress multisite, adapt option storage and cron scheduling accordingly.
- Privacy: Avoid storing personally identifiable information. This approach stores only counts.
- Testing: Test with multiple concurrent requests to ensure counts behave as expected under load.
Troubleshooting
- If counts dont increase, ensure your theme calls wp_head() / wp_footer() and that code is loaded (functions.php or plugin).
- If counts are missing while caching is active, use AJAX to ping a small handler that runs pv_count_post_view() or disable page caching for single posts where counts matter.
- If scheduled flushes dont run, check WP-Cron. On low-traffic sites WP-Cron only runs when a visitor arrives consider a real cron job hitting wp-cron.php externally.
- If you see wildly inaccurate numbers with heavy concurrency, consider atomic increments in a dedicated DB table or using a Redis INCR key to avoid race conditions.
Comparison: Transients vs post_meta-on-every-load vs custom table
Method | Writes per view | Scale | Complexity |
post_meta on every view | 1 DB write per view | Low — not suitable for high traffic | Low |
transients occasional flush (this tutorial) | 1 write per threshold or cron interval | Medium — good for many sites | Medium |
atomic cache/increment (Redis INCR) or custom table | 1 atomic increment in cache / DB per view | High — best for heavy traffic | High |
References
|
Acepto donaciones de BAT's mediante el navegador Brave 🙂 |