How to build a moderation queue with Gravity Forms and Gravity Tables
Pattern for sites that accept public submissions and need a staff workflow to approve, reject, or flag entries. Two shortcodes, one form, one audit trail.
If your site accepts public submissions, comments, classified ads, event proposals, recipe entries, talk submissions for a conference, you need a moderation queue. Staff review submissions in a dashboard, approve or reject, and approved ones flow to a public surface.
This guide ships that pattern in 30 minutes.
What you’ll build#
Three URLs, one form, one Gravity Forms entries table, all coordinated through a single status field:
/submit, public form for new submissions/admin/queue, staff-only moderation dashboard with bulk approve/reject + per-entry inline edit + audit trail/directory, public-facing list of approved submissions
A new submission lands as status: pending. Staff review at /admin/queue, change status to approved or rejected. Approved entries appear on /directory automatically.
Step 1: prepare the form#
Add a hidden status field to your existing Gravity Form (or create one). The field’s:
- Type: hidden
- Default value:
pending - Admin label:
status - Visibility: hidden from public view, never editable from the public form
This single field is the workflow’s state machine. Every entry has one of three states: pending (default for new submissions), approved, rejected.
Step 2: build the public submission page#
A standard Gravity Forms shortcode on /submit:
[gravityform id="42" title="false" description="false" ajax="true"]
That’s the only Gravity Forms part. The status field defaults to pending; the form auto-populates it without any UI exposure.
After submission, GF redirects to a thank-you page (configured in Form Settings → Confirmations). Your public submission flow is done.
Step 3: build the moderation dashboard#
Create a new page at /admin/queue with this Gravity Tables shortcode:
[gravity_table id="42"
filter="status:pending"
allowed_roles="moderator,editor,administrator"
allow_edit="status,moderation_notes"
edit_permissions="status:moderator,moderation_notes:moderator"
bulk="approve,reject,delete"
bulk_permissions="delete:manage_options"
audit_log="true"
auto_refresh="true"
refresh_interval="60"]
What this configuration does:
filter="status:pending", moderators see only entries awaiting review; the queue shrinks naturally as they workallowed_roles, three roles can access the queue (you can add more or use a custommoderatorrole)allow_edit="status,moderation_notes", moderators can change the status inline (drop-down frompendingtoapproved/rejected) or add a private noteedit_permissions, the same moderator role gates both edit columns; admins inherit the gatebulk="approve,reject,delete", bulk actions for batch decisions, with a delete that’s admin-onlyaudit_log="true", every approval, rejection, and note logged with timestamp + userauto_refresh, when a new submission arrives, it appears in the queue without manual refresh
Custom bulk actions: approve and reject#
The default bulk parameter only knows generic actions. To make approve and reject change the status field, register them as custom actions in your child theme’s functions.php or a custom plugin:
add_filter('gt_register_bulk_actions', function ($actions) {
$actions['approve'] = [
'label' => 'Approve selected',
'capability' => 'edit_others_posts',
'callback' => function ($entry_ids) {
$count = 0;
foreach ($entry_ids as $id) {
GFAPI::update_entry_field($id, 'status', 'approved');
$count++;
}
return ['success' => $count, 'message' => "Approved {$count} entries"];
},
];
$actions['reject'] = [
'label' => 'Reject selected',
'capability' => 'edit_others_posts',
'callback' => function ($entry_ids) {
$count = 0;
foreach ($entry_ids as $id) {
GFAPI::update_entry_field($id, 'status', 'rejected');
$count++;
}
return ['success' => $count, 'message' => "Rejected {$count} entries"];
},
];
return $actions;
});
After this, the bulk actions menu in /admin/queue includes “Approve selected” and “Reject selected”, and they each act on the GF entries directly.
Step 4: build the public directory#
Create a page at /directory with this shortcode:
[gravity_table id="42"
filter="status:approved"
filters="category,tag"
per_page="24"
mobile_layout="cards"
sort="created:desc"]
What this does:
filter="status:approved", only approved entries appear; pending and rejected are invisible- No
allow_edit, nobulk, pure read-only display filters="category,tag", visitors can narrow by topic- Mobile-card layout for phones
This page picks up newly-approved entries on the next page load. No additional configuration.
Step 5: notify moderators of pending submissions#
Two patterns for pinging moderators when a new submission arrives:
Pattern A: native Gravity Forms email notification#
In Form Settings → Notifications, add a notification:
- Send to:
moderation@your-site.com(or a per-moderator distribution list) - Subject:
New submission pending review - Trigger: form submission
Moderators get an email per submission. Simple but noisy on busy sites.
Pattern B: Slack webhook via the gt_action_after_inline_edit hook (or directly via GF hooks)#
Quieter, batched. After every 5 pending submissions, ping a Slack channel:
add_action('gform_after_submission_42', function ($entry, $form) {
$pending_count = count(GFAPI::get_entries(42, [
'field_filters' => [['key' => 'status', 'value' => 'pending']],
]));
if ($pending_count > 0 && $pending_count % 5 === 0) {
wp_remote_post('https://hooks.slack.com/services/...', [
'body' => json_encode([
'text' => "📬 {$pending_count} submissions pending review at /admin/queue",
]),
]);
}
}, 10, 2);
Step 6: the moderator’s actual workflow#
Daily moderator routine:
- Open
/admin/queue(auto-refresh keeps it current) - Scan new pending entries (sorted newest-first by default)
- For each entry: read the content, change
statustoapprovedorrejectedinline, add a note if needed - For batch actions: select multiple entries, choose “Approve selected” or “Reject selected” from the bulk menu
The audit log records every status change. View under Tables → System → Audit log.
Step 7: handling rejections#
When an entry’s status flips to rejected, you can:
Option A: leave it in the database, hidden from public#
The /directory filters to status:approved so rejected entries are invisible to visitors. The submitter sees no UI for their entry; from their perspective, it’s “still under review”.
This is the gentlest option but accumulates database rows.
Option B: send the submitter a rejection email#
In Form Settings → Notifications, add a conditional notification triggered by status: rejected (Gravity Forms supports conditional notifications). Use a moderation_notes field as the email body to give the submitter feedback.
Option C: auto-delete after 30 days#
Add a daily WP-Cron task that deletes rejected entries older than 30 days:
add_action('init', function () {
if (!wp_next_scheduled('gt_cleanup_rejected')) {
wp_schedule_event(time(), 'daily', 'gt_cleanup_rejected');
}
});
add_action('gt_cleanup_rejected', function () {
$thirty_days_ago = date('Y-m-d H:i:s', strtotime('-30 days'));
$entries = GFAPI::get_entries(42, [
'field_filters' => [['key' => 'status', 'value' => 'rejected']],
'date_filters' => [['column' => 'date_created', 'value' => $thirty_days_ago, 'operator' => '<']],
]);
foreach ($entries as $e) GFAPI::delete_entry($e['id']);
});
Recipe variations#
Conference talk submissions#
[gravity_table id="talks"
filter="status:pending"
allowed_roles="program-committee"
allow_edit="status,track,scoring"
bulk="approve,reject"
audit_log="true"]
Adds a track field (which conference track to assign) and a scoring numeric field for committee voting.
Classified ads board#
[gravity_table id="ads"
filter="status:pending"
allowed_roles="moderator"
allow_edit="status,featured,expires_at"
bulk="approve,reject,delete"
audit_log="true"]
Adds a featured boolean (paid placement) and expires_at date (auto-archive).
Event speaker proposals#
[gravity_table id="proposals"
filter="status:pending"
allowed_roles="committee"
allow_edit="status,track,priority,internal_notes"
bulk="approve,reject,shortlist"
audit_log="true"]
Adds a shortlist bulk action (custom, sets status to shortlisted for further review).
What to avoid#
Don’t expose the status field on the public submission form#
It’s a workflow primitive, not user input. If a malicious submitter can set status, they bypass moderation entirely.
Don’t put the moderation queue on a public URL#
/admin/queue (or anything under /admin/) signals correctly. The allowed_roles parameter is the security gate; the URL convention reinforces it.
Don’t skip the audit log#
Moderation involves human judgment, and judgment gets disputed. The audit log is your defense when a submitter asks “why was my submission rejected?”
Related#
- Build a customer portal, the per-user counterpart
- Build a searchable directory, the public read-only counterpart (stage 3 of the workflow)
- Permissions, the three-layer permission model
- PHP hooks → bulk action registration, custom approve/reject implementations