Simple post view counter plugin in WordPress
This article walks through a real, production‑ready WordPress plugin that tracks post views with a dedicated log table and rolling aggregates, while keeping a simple meta fallback for totals and today. You’ll see how it hooks into WordPress, stores data efficiently, avoids bot traffic, and exposes the numbers via shortcode and an admin report.
What the plugin does (at a glance)
- Counts views on single posts
- Dedupe per user/day using a fingerprint (IP subnet + user agent + date + salt) and a 24‑hour transient.
- Persists each view into a custom table for auditability and rollups.
- Maintains light counters in post meta: total and today are bumped on each view; 7d and 30d are computed hourly from the log table.
- Adds an Admin report page with sorting, search, pagination, and CSV export.
- Shortcode and PHP helpers to display counts.
- Namespaced, class‑based structure.
What a transient is in WordPress
A transient is a way to store cached data in the WordPress database with an expiration time. Think of it as a temporary option: you save some value, tell WordPress how long it should live, and after that time it expires automatically.
It’s built for things you don’t want to recalculate or fetch every time (like API responses, heavy queries, or counts).
How it works
- Stored in the
wp_optionstable (unless you use an external object cache like Redis or Memcached, then it lives there). - Has two parts:
- The value (your data).
- The expiration timestamp.
When you request a transient:
- If it exists and hasn’t expired → you get the value.
- If it’s missing or expired → WordPress returns
false.
File layout and bootstrap
Everything lives in a single plugin file using a PHP namespace:
<?php
/*
Plugin Name: MTN Post Views
Description: ... log table + rolling aggregates ... Class-based, with legacy meta fallback.
Version: 1.1.1
*/
namespace MTN\PostViews;
if (!defined('ABSPATH')) { exit; }
The namespace ensures function/class names don’t collide with other plugins. A hard exit guards direct access.
Core classes
- Plugin – lifecycle (activation/deactivation/uninstall), constants, meta keys, table name.
- Logger – bot filtering, dedupe + fingerprint, insert log row, update light counters, compute rollups.
- Shortcodes –
647and helpers to fetch/format counts. - Admin – menu and report UI (table, sorts, search, CSV export, pagination).
- Utils – small date/hash helpers.
Hooks and scheduling
<?php
// Front
\add_action('template_redirect', [Logger::class, 'maybe_count']);
// Cron rollups
\add_action('mtn_pv_rollup_hourly', [Logger::class, 'compute_rollups']);
// Admin
\add_action('admin_menu', [Admin::class, 'register_menu']);
// Shortcodes
\add_shortcode
On activation, we create the custom table and schedule the hourly rollup job a few minutes in the future:
<?php
register_activation_hook(__FILE__, [Plugin::class, 'activate']);
// ... inside activate():
wp_schedule_event(time() + 300, 'hourly', 'mtn_pv_rollup_hourly');
On deactivation, we clear the scheduled hook. On uninstall, we drop the table and (optionally) purge meta keys.
Database schema (audit and rollups)
The plugin stores each accepted view in a dedicated table for accurate rollups and audits:
CREATE TABLE wp_mtn_post_views (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
post_id BIGINT UNSIGNED NOT NULL,
fp_hash CHAR(64) NULL, -- per-day fingerprint hash
ip_hash CHAR(64) NULL, -- IP hash (subnet-trimmed for IPv4)
ua_hash CHAR(64) NULL, -- user agent hash
created_at DATETIME NOT NULL,
day DATE NOT NULL, -- derived from created_at (UTC)
PRIMARY KEY (id),
KEY post_created (post_id, created_at),
KEY day_idx (day),
KEY post_day (post_id, day)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
- p_hash dedupes views per person per day (transient also used).
- day enables efficient 7‑/30‑day range queries.
Using a log table keeps the meta keys lean and allows reconstructing aggregates or building richer analytics later.
Logging flow: Logger::maybe_count()
Triggered on template_redirect for single posts:
- Guardrails – bail if not a single post or no ID.
- Bot filtering – quick user‑agent rejects (curl/wget/headless), then known bots/link previewers, then header‑based prefetch/preview heuristics.
- Fingerprinting – build a per‑day fingerprint from IP subnet + User Agent + current UTC day + WordPress salt. This both dedupes and avoids storing raw IPs.
- Transient dedupe – a 24h transient
mtn_pv_{hash}prevents duplicate counts within a day. - Insert log row – write into the custom table (
post_id, hashes, timestamps). - Light counters – bump
post_views_totalandpost_views_todayin post meta.post_last_viewedstores last timestamp.
Key detail: today resets when the stored date changes – the code keeps a hidden _mtn_today_date meta to know when to zero it.
Hourly rollups: Logger::compute_rollups()
A scheduled task recalculates:
- 7d – count rows between
today-6andtoday. - 30d – count rows between
today-29andtoday. - today – sanity refresh for the current day.
The rollup job limits work to posts seen in the last 30 days to keep it fast, and saves results into post meta keys (post_views_7d, post_views_30d, post_views_today).
Shortcode and PHP helpers
Use the shortcode anywhere in content or templates:
[mtn_post_views]
[mtn_post_views period="today"]
[mtn_post_views period="7d"]
[mtn_post_views period="30d"]
[mtn_post_views post_id="123"]
Under the hood, this calls:
<?php
Shortcodes::get_views($post_id, $period); // returns int
Shortcodes::get_views_formatted($post_id, $period); // returns string (">1000" rule)
Admin report (menu: Post Views)
The plugin registers an admin page (manage_options) with:
- Sortable columns: Title, Total, Today, Last 7 days, Last 30 days, Author, Date, Status.
- Search: title contains.
- Pagination: 20 rows per page.
- CSV export: nonce‑protected URL that streams a UTF‑8 CSV of current results.
Implementation notes:
- Queries join against
postmetafour times (total, today, 7d, 30d) withCOALESCE(NULLIF(meta_value,''), '0') + 0for safe numeric casts. sanitize_key,sanitize_text_field, andprepareguard inputs;current_user_canprotects access.- Table UI uses standard
widefatmarkup and simple pagination links.
Security & privacy considerations
- No raw IP storage – IPs are subnet‑trimmed (IPv4) and hashed.
- Strict capability checks – admin page requires
manage_options. - Nonces – CSV export is nonce‑gated.
- Prepared statements – all dynamic SQL goes through
$wpdb->prepareor is derived from sanitize helpers. - Bot defense – staged approach: fast path, known UA patterns, and header heuristics (e.g., prefetch/preview).
Performance considerations
- Write‑light path: one insert + a few meta updates per accepted view.
- Hourly rollups keep front‑end display fast.
- Indexed day/post columns support efficient COUNT() windows.
- Dedupe transient reduces repeat writes from the same visitor.
How to display counts in your theme
Shortcode in templates:
<?php
echo do_shortcode('647');
Direct PHP (no shortcode):
<?php
use MTN\PostViews\Shortcodes;
$views = Shortcodes::get_views(get_the_ID(), '30d');
echo number_format_i18n($views);
Formatted output (>1000 compacts):
<?php
echo Shortcodes::get_views_formatted(get_the_ID(), 'total'); // e.g., ">1000"
Installing, upgrading, removing
- Install: drop the file into
wp-content/plugins/mtn-post-views/and activate in admin UI - Upgrade: bump version + deploy; DB schema is created idempotently.
- Uninstall: removes the custom table and (optionally) counters from
postmeta. Comment the deletes if you want to keep historical numbers.
Extending the plugin
- Add a REST endpoint to fetch counts for headless themes or dashboards.
- Track custom post types by relaxing the
is_singular('post')guard. - Add per‑author or per‑category leaderboards off the log table.
- Record referrers (hashed) for simple source attribution.
Privacy and consent considerations
One benefit of this plugin is that it can record page views for visitors who are not tracked by Google Analytics or similar tools due to cookie consent banners.
Because the plugin does not set cookies or store raw personal data (IPs are truncated and hashed, UAs are hashed, and deduplication is done server‑side with transients), it is generally lighter and more privacy‑friendly. In many EU interpretations, this kind of aggregated, anonymous pageview logging can be justified under legitimate interest without requiring explicit consent.
That said, it is important to:
- Be transparent in your privacy policy.
- Ensure no identifiers like full IPs, emails, or logged‑in user IDs are stored.
- Avoid combining view logs with other personal data.
This way, you can benefit from simple analytics without the compliance overhead of consent-based tracking and see post views you have not seen before in Google Analytics. For example, I was surprised to learn that my most viewed articles are actually about WordPress topics rather than marketing automation.
Troubleshooting
- Numbers don’t change – ensure you’re on a single post view; check that bots/previews aren’t being filtered.
- Today resets unexpectedly – server timezones vs UTC; the plugin uses UTC for day boundaries.
- 7d/30d zero – verify WP Cron is running; trigger
mtn_pv_rollup_hourlymanually viawp cron event run. - Admin list empty – filtering by search? Status excludes
auto-draftonly; private/draft posts still appear.
Plugin
<?php
/*
Plugin Name: MTN Post Views
Description: Post view tracker with log table + rolling aggregates (today, 7d, 30d, total).
Version: 1.1.1
Author: martechnotes
License: GPLv2 or later
*/
namespace MTN\PostViews;
if (!defined('ABSPATH')) { exit; }
final class Plugin {
const VERSION = '1.1.1';
const META_TOTAL = 'post_views_total';
const META_TODAY = 'post_views_today';
const META_7D = 'post_views_7d';
const META_30D = 'post_views_30d';
const META_LAST_TS = 'post_last_viewed';
private static $instance;
public static $table;
public static function instance(): self {
if (!self::$instance) self::$instance = new self();
return self::$instance;
}
private function __construct() {
global $wpdb; self::$table = $wpdb->prefix . 'mtn_post_views';
// Front
\add_action('template_redirect', [Logger::class, 'maybe_count']);
// Cron rollups
\add_action('mtn_pv_rollup_hourly', [Logger::class, 'compute_rollups']);
// Admin
\add_action('admin_menu', [Admin::class, 'register_menu']);
// Shortcodes
\add_shortcode('mtn_post_views', [Shortcodes::class, 'post_views']);
}
// Lifecycle
public static function activate(): void {
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$charset = $wpdb->get_charset_collate();
$table = self::table();
$sql = "CREATE TABLE $table (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
post_id BIGINT UNSIGNED NOT NULL,
fp_hash CHAR(64) NULL,
ip_hash CHAR(64) NULL,
ua_hash CHAR(64) NULL,
created_at DATETIME NOT NULL,
day DATE NOT NULL,
PRIMARY KEY (id),
KEY post_created (post_id, created_at),
KEY day_idx (day),
KEY post_day (post_id, day)
) $charset;";
\dbDelta($sql);
// Schedule hourly rollup
if (!\wp_next_scheduled('mtn_pv_rollup_hourly')) {
\wp_schedule_event(time() + 300, 'hourly', 'mtn_pv_rollup_hourly');
}
}
public static function deactivate(): void {
$ts = \wp_next_scheduled('mtn_pv_rollup_hourly');
if ($ts) { \wp_unschedule_event($ts, 'mtn_pv_rollup_hourly'); }
}
public static function uninstall(): void {
global $wpdb;
// Drop table
$wpdb->query('DROP TABLE IF EXISTS ' . self::table());
// Optionally delete meta - comment out if you want to keep data
$meta_keys = [self::META_TOTAL, self::META_TODAY, self::META_7D, self::META_30D, self::META_LAST_TS];
foreach ($meta_keys as $k) {
$wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->postmeta} WHERE meta_key=%s", $k));
}
}
public static function table(): string { return self::$table ?: self::init_table_name(); }
private static function init_table_name(): string { global $wpdb; self::$table = $wpdb->prefix . 'mtn_post_views'; return self::$table; }
}
final class Utils {
public static function now_gmt(): string { return gmdate('Y-m-d H:i:s'); }
public static function day_gmt(): string { return gmdate('Y-m-d'); }
public static function h($v): string { return hash('sha256', (string) $v); }
}
final class Logger {
private static function is_crawler(): bool {
$ua = strtolower($_SERVER['HTTP_USER_AGENT'] ?? '');
// Fast fails - empty UA or common HTTP clients
if ($ua === '' ||
preg_match('~\b(curl|wget|httpclient|libwww-perl|python-requests|aiohttp|okhttp|go-http-client|java/\d|node-fetch|scrapy|feedfetcher|headlesschrome)\b~', $ua)) {
return true;
}
// Real bots + link previewers (add/remove as needed)
$bots = '`
bot|crawl|spider|slurp|
googlebot|bingbot|duckduckbot|yandex|baiduspider|semrush|ahrefs|majestic|mj12|bytespider|barkrowler|
facebookexternalhit|facebot|
linkedinbot|
slackbot|
discordbot|
telegram|whatsapp|
pinterestbot|snapchat|
twitterbot|xbot|tco|
redditbot|
bsky|bluesky|atproto
`x';
if (preg_match($bots, $ua)) return true;
// Heuristics for preview/fetchers
$method = $_SERVER['REQUEST_METHOD'] ?? '';
if ($method !== 'GET') return true; // skip POST, OPTIONS, etc.
$h = array_change_key_case($_SERVER, CASE_LOWER);
if (
(isset($h['http_purpose']) && stripos($h['http_purpose'], 'prefetch') !== false) ||
(isset($h['http_sec_fetch_mode']) && $h['http_sec_fetch_mode'] !== 'navigate') ||
(isset($h['http_sec_fetch_dest']) && $h['http_sec_fetch_dest'] === 'empty') ||
(isset($h['http_x_purpose']) && stripos($h['http_x_purpose'], 'preview') !== false)
) {
return true;
}
return false;
}
public static function maybe_count(): void {
if (!\is_singular('post')) return;
$post_id = \get_the_ID();
if (!$post_id) return;
// Bot filter
if (self::is_crawler()) return;
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
// Fingerprint (daily)
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$p = explode('.', $ip);
if (count($p) === 4) $ip = $p[0] . '.' . $p[1] . '.' . $p[2] . '.0';
}
$day = Utils::day_gmt();
$salt = \wp_salt('auth');
$fp = hash('sha256', $ip . '|' . $ua . '|' . $day . '|' . $salt);
// Deduplicate per day
$key = 'mtn_viewed_' . $post_id . '_' . $fp;
if (\get_transient($key)) return;
// Persist log row
self::insert_log((int)$post_id, $fp, Utils::h($ip), Utils::h($ua));
// Bump light counters
$total = (int) \get_post_meta($post_id, Plugin::META_TOTAL, true);
\update_post_meta($post_id, Plugin::META_TOTAL, $total + 1);
$today = (int) \get_post_meta($post_id, Plugin::META_TODAY, true);
$today_last = \get_post_meta($post_id, '_mtn_today_date', true);
if ($today_last !== $day) { \update_post_meta($post_id, '_mtn_today_date', $day); $today = 0; }
\update_post_meta($post_id, Plugin::META_TODAY, $today + 1);
\update_post_meta($post_id, Plugin::META_LAST_TS, Utils::now_gmt());
// Remember dedupe for 24h
\set_transient($key, 1, DAY_IN_SECONDS);
}
private static function insert_log(int $post_id, string $fp_hash, string $ip_hash, string $ua_hash): void {
global $wpdb;
$wpdb->insert(
Plugin::table(),
[
'post_id' => $post_id,
'fp_hash' => $fp_hash,
'ip_hash' => $ip_hash,
'ua_hash' => $ua_hash,
'created_at' => Utils::now_gmt(),
'day' => Utils::day_gmt(),
],
['%d','%s','%s','%s','%s','%s']
);
}
public static function compute_rollups(): void {
global $wpdb; $table = Plugin::table();
$today = Utils::day_gmt();
$d7 = gmdate('Y-m-d', strtotime('-6 days')); // inclusive today
$d30 = gmdate('Y-m-d', strtotime('-29 days')); // inclusive today
// Limit work to posts seen in the last 30 days
$post_ids = $wpdb->get_col($wpdb->prepare("SELECT DISTINCT post_id FROM $table WHERE day >= %s", $d30));
if (!$post_ids) return;
foreach ($post_ids as $pid) {
$pid = (int) $pid;
// 7d
$views7 = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM $table WHERE post_id=%d AND day BETWEEN %s AND %s",
$pid, $d7, $today
));
\update_post_meta($pid, Plugin::META_7D, $views7);
// 30d
$views30 = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM $table WHERE post_id=%d AND day BETWEEN %s AND %s",
$pid, $d30, $today
));
\update_post_meta($pid, Plugin::META_30D, $views30);
// Today
$viewsToday = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM $table WHERE post_id=%d AND day=%s",
$pid, $today
));
\update_post_meta($pid, Plugin::META_TODAY, $viewsToday);
\update_post_meta($pid, '_mtn_today_date', $today);
}
}
}
final class Shortcodes {
public static function post_views($atts): string {
$atts = \shortcode_atts(['period' => 'total', 'post_id' => 0], $atts, 'mtn_post_views');
$pid = (int) ($atts['post_id'] ?: \get_the_ID());
return (string) self::get_views_formatted($pid, $atts['period']);
}
public static function get_views(int $post_id = 0, string $period = 'total'): int {
$post_id = $post_id ?: \get_the_ID();
switch (strtolower($period)) {
case 'today':
$meta_key = Plugin::META_TODAY;
break;
case '7d':
case '7days':
case 'week':
$meta_key = Plugin::META_7D;
break;
case '30d':
case '30days':
case 'month':
$meta_key = Plugin::META_30D;
break;
case 'total':
default:
$meta_key = Plugin::META_TOTAL;
break;
}
$value = \get_post_meta($post_id, $meta_key, true);
return (int) ($value ?: 0);
}
// New static method with display formatting (>1000 rule)
public static function get_views_formatted(int $post_id = 0, string $period = 'total'): string {
$views = self::get_views($post_id, $period);
return $views > 1000 ? '>1000' : (string) $views;
}
}
final class Admin {
public static function register_menu(): void {
\add_menu_page(
'Post Views',
'Post Views',
'manage_options',
'mtn-post-views',
[self::class, 'render_page'],
'dashicons-chart-bar',
58
);
}
public static function render_page(): void {
if (!\current_user_can('manage_options')) { \wp_die(__('You do not have sufficient permissions to access this page.')); }
global $wpdb;
// Inputs
$page = isset($_GET['paged']) ? max(1, (int) $_GET['paged']) : 1;
$per_page = 20;
$orderby = isset($_GET['orderby']) ? \sanitize_key($_GET['orderby']) : 'views';
$order = isset($_GET['order']) ? strtoupper(\sanitize_text_field($_GET['order'])) : 'DESC';
if (!in_array($order, ['ASC','DESC'], true)) $order = 'DESC';
switch ($orderby) {
case 'title': $order_by_sql = 'p.post_title'; break;
case 'date': $order_by_sql = 'p.post_date'; break;
case 'views7': $order_by_sql = 'views7'; break;
case 'views30': $order_by_sql = 'views30'; break;
case 'today': $order_by_sql = 'today'; break;
case 'views':
default: $order_by_sql = 'views'; $orderby = 'views'; break;
}
$search = isset($_GET['s']) ? \wp_unslash($_GET['s']) : '';
$search_sql = '';
if ($search !== '') {
$like = '%' . $wpdb->esc_like($search) . '%';
$search_sql = $wpdb->prepare(' AND p.post_title LIKE %s ', $like);
}
// CSV export
if (isset($_GET['mtn_export']) && $_GET['mtn_export'] === 'csv' && \check_admin_referer('mtn_export_csv')) {
$rows = $wpdb->get_results("
SELECT p.ID, p.post_title,
COALESCE(NULLIF(pm_total.meta_value, ''), '0') + 0 AS views,
COALESCE(NULLIF(pm_today.meta_value, ''), '0') + 0 AS today,
COALESCE(NULLIF(pm_7.meta_value, ''), '0') + 0 AS views7,
COALESCE(NULLIF(pm_30.meta_value, ''), '0') + 0 AS views30
FROM {$wpdb->posts} p
LEFT JOIN {$wpdb->postmeta} pm_total ON (pm_total.post_id = p.ID AND pm_total.meta_key = '".esc_sql(Plugin::META_TOTAL)."')
LEFT JOIN {$wpdb->postmeta} pm_today ON (pm_today.post_id = p.ID AND pm_today.meta_key = '".esc_sql(Plugin::META_TODAY)."')
LEFT JOIN {$wpdb->postmeta} pm_7 ON (pm_7.post_id = p.ID AND pm_7.meta_key = '".esc_sql(Plugin::META_7D)."')
LEFT JOIN {$wpdb->postmeta} pm_30 ON (pm_30.post_id = p.ID AND pm_30.meta_key = '".esc_sql(Plugin::META_30D)."')
WHERE p.post_type = 'post' AND p.post_status <> 'auto-draft'
$search_sql
ORDER BY $order_by_sql $order
");
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename=post-views.csv');
$out = fopen('php://output', 'w');
fputcsv($out, ['ID','Title','Total','Today','Last 7 days','Last 30 days','Permalink','Edit URL']);
foreach ($rows as $r) {
fputcsv($out, [
$r->ID, $r->post_title,
(int)$r->views, (int)$r->today, (int)$r->views7, (int)$r->views30,
\get_permalink($r->ID), \get_edit_post_link($r->ID, '')
]);
}
fclose($out);
exit;
}
// Totals & rows
$total = (int) $wpdb->get_var("
SELECT COUNT(*) FROM {$wpdb->posts} p
WHERE p.post_type = 'post' AND p.post_status <> 'auto-draft'
$search_sql
");
$offset = ($page - 1) * $per_page;
$rows = $wpdb->get_results($wpdb->prepare("
SELECT p.ID, p.post_title, p.post_author, p.post_date, p.post_status,
COALESCE(NULLIF(pm_total.meta_value, ''), '0') + 0 AS views,
COALESCE(NULLIF(pm_today.meta_value, ''), '0') + 0 AS today,
COALESCE(NULLIF(pm_7.meta_value, ''), '0') + 0 AS views7,
COALESCE(NULLIF(pm_30.meta_value, ''), '0') + 0 AS views30
FROM {$wpdb->posts} p
LEFT JOIN {$wpdb->postmeta} pm_total ON (pm_total.post_id = p.ID AND pm_total.meta_key = %s)
LEFT JOIN {$wpdb->postmeta} pm_today ON (pm_today.post_id = p.ID AND pm_today.meta_key = %s)
LEFT JOIN {$wpdb->postmeta} pm_7 ON (pm_7.post_id = p.ID AND pm_7.meta_key = %s)
LEFT JOIN {$wpdb->postmeta} pm_30 ON (pm_30.post_id = p.ID AND pm_30.meta_key = %s)
WHERE p.post_type = 'post' AND p.post_status <> 'auto-draft'
$search_sql
ORDER BY $order_by_sql $order
LIMIT %d OFFSET %d
", Plugin::META_TOTAL, Plugin::META_TODAY, Plugin::META_7D, Plugin::META_30D, $per_page, $offset));
$base_url = \menu_page_url('mtn-post-views', false);
$build_url = function($args = []) use ($base_url) { return \esc_url(\add_query_arg($args, $base_url)); };
$toggle_order = $order === 'ASC' ? 'DESC' : 'ASC';
$total_pages = max(1, ceil($total / $per_page));
echo '<div class="wrap">';
echo '<h1 class="wp-heading-inline">Post Views</h1>';
echo '<form method="get" style="margin-top:10px; margin-bottom:10px;">';
echo '<input type="hidden" name="page" value="mtn-post-views" />';
echo '<input type="search" name="s" value="' . \esc_attr($search) . '" placeholder="Search title..." />';
echo '<input type="submit" class="button" value="Search" /> ';
echo '<a class="button button-secondary" href="' . \wp_nonce_url($build_url([
's' => $search, 'orderby' => $orderby, 'order' => $order, 'mtn_export' => 'csv'
]), 'mtn_export_csv') . '">Export CSV</a>';
echo '</form>';
echo '<table class="widefat fixed striped">';
echo '<thead><tr>';
echo '<th><a href="' . $build_url(['orderby' => 'title','order' => ($orderby==='title'?$toggle_order:'ASC'),'s'=>$search]) . '">Title</a></th>';
echo '<th><a href="' . $build_url(['orderby' => 'views','order' => ($orderby==='views'?$toggle_order:'DESC'),'s'=>$search]) . '">Total</a></th>';
echo '<th><a href="' . $build_url(['orderby' => 'today','order' => ($orderby==='today'?$toggle_order:'DESC'),'s'=>$search]) . '">Today</a></th>';
echo '<th><a href="' . $build_url(['orderby' => 'views7','order' => ($orderby==='views7'?$toggle_order:'DESC'),'s'=>$search]) . '">Last 7 days</a></th>';
echo '<th><a href="' . $build_url(['orderby' => 'views30','order' => ($orderby==='views30'?$toggle_order:'DESC'),'s'=>$search]) . '">Last 30 days</a></th>';
echo '<th>Author</th>';
echo '<th><a href="' . $build_url(['orderby' => 'date','order' => ($orderby==='date'?$toggle_order:'DESC'),'s'=>$search]) . '">Date</a></th>';
echo '<th>Status</th>';
echo '<th>Actions</th>';
echo '</tr></thead><tbody>';
if ($rows) {
foreach ($rows as $r) {
$author = \get_user_by('id', $r->post_author);
$status_obj = \get_post_status_object($r->post_status);
$view_url = \get_permalink($r->ID);
$edit_url = \get_edit_post_link($r->ID);
echo '<tr>';
echo '<td><strong>' . \esc_html($r->post_title ?: '(no title)') . '</strong></td>';
echo '<td>' . \number_format_i18n((int)$r->views) . '</td>';
echo '<td>' . \number_format_i18n((int)$r->today) . '</td>';
echo '<td>' . \number_format_i18n((int)$r->views7) . '</td>';
echo '<td>' . \number_format_i18n((int)$r->views30) . '</td>';
echo '<td>' . \esc_html($author ? $author->display_name : '-') . '</td>';
echo '<td>' . \esc_html(\get_date_from_gmt(\get_gmt_from_date($r->post_date), \get_option('date_format') . ' ' . \get_option('time_format'))) . '</td>';
echo '<td>' . \esc_html($status_obj->label ?? $r->post_status) . '</td>';
echo '<td>';
if ($view_url) echo '<a href="' . \esc_url($view_url) . '" target="_blank">View</a> ';
if ($edit_url) echo '| <a href="' . \esc_url($edit_url) . '">Edit</a>';
echo '</td>';
echo '</tr>';
}
} else {
echo '<tr><td colspan="9">No posts found.</td></tr>';
}
echo '</tbody></table>';
echo '<div class="tablenav"><div class="tablenav-pages">';
if ($total_pages > 1) {
$first = 1; $prev = max(1, $page - 1); $next = min($total_pages, $page + 1); $last = $total_pages;
$qs = ['s'=>$search,'orderby'=>$orderby,'order'=>$order];
echo '<span class="displaying-num">' . \number_format_i18n($total) . ' items</span> ';
echo '<span class="pagination-links">';
echo '<a class="first-page button" href="' . $build_url($qs + ['paged'=>$first]) . '">«</a> ';
echo '<a class="prev-page button" href="' . $build_url($qs + ['paged'=>$prev]) . '">‹</a> ';
echo '<span class="paging-input">' . $page . ' of <span class="total-pages">' . $total_pages . '</span></span> ';
echo '<a class="next-page button" href="' . $build_url($qs + ['paged'=>$next]) . '">›</a> ';
echo '<a class="last-page button" href="' . $build_url($qs + ['paged'=>$last]) . '">»</a>';
echo '</span>';
}
echo '</div></div>';
echo '</div>';
}
}
// Bootstrap plugin
Plugin::instance();
// Global namespace hooks
\register_activation_hook(__FILE__, [Plugin::class, 'activate']);
\register_deactivation_hook(__FILE__, [Plugin::class, 'deactivate']);
\register_uninstall_hook(__FILE__, [Plugin::class, 'uninstall']);
// WP-CLI helpers
if (defined('WP_CLI') && WP_CLI) {
\WP_CLI::add_command('mtn-pv/rollup', function(){ Logger::compute_rollups(); \WP_CLI::success('Rollups recomputed.'); });
}
Change mtn to your own plugin prefix if you need.
Filtering Out Bot Traffic with Manual Exclusions
One thing I noticed quickly after deploying my post views
đź”’ This content is for Premium Subsribers only.
Please log in to preview content. Log in or Register
You must log in and have a Premium Subscriber account to preview the content.
When upgrading, please use the same email address as your WordPress account so we can correctly link your Premium membership.
Please allow us a little time to process and upgrade your account after the purchase. If you need faster access or encounter any issues, feel free to contact us at info@martechnotes.com or through any other available channel.
To join the Discord community, please also provide your Discord username after subscribing, or reach out to us directly for access.
You can subscribe even before creating a WordPress account — your subscription will be linked to the email address used during checkout.
Premium Subscriber
19,99 € / Year
- Free e-book with all revisions - 101 Adobe Campaign Classic (SFMC 101 in progress)
- All Premium Subscriber Benefits - Exclusive blog content, weekly insights, and Discord community access
- Lock in Your Price for a Full Year - Avoid future price increases
- Limited Seats at This Price - Lock in early before it goes up








