Skip to content
Gravity Tables
tutorial

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.

· 6 min read · By Fahd Murtaza

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 work
  • allowed_roles, three roles can access the queue (you can add more or use a custom moderator role)
  • allow_edit="status,moderation_notes", moderators can change the status inline (drop-down from pending to approved/rejected) or add a private note
  • edit_permissions, the same moderator role gates both edit columns; admins inherit the gate
  • bulk="approve,reject,delete", bulk actions for batch decisions, with a delete that’s admin-only
  • audit_log="true", every approval, rejection, and note logged with timestamp + user
  • auto_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, no bulk, 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:

  1. Open /admin/queue (auto-refresh keeps it current)
  2. Scan new pending entries (sorted newest-first by default)
  3. For each entry: read the content, change status to approved or rejected inline, add a note if needed
  4. 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?”