Build an event registration and attendee dashboard with Gravity Forms
A complete pattern for running an event off Gravity Forms: public registration form, organiser triage workspace, on-the-door check-in tablet view, public attendee directory, and post-event reporting. One form, four URLs, no Eventbrite fee.
Eventbrite charges 3.7% + $1.79 per ticket. For a 200-person conference at $400 a seat, that’s nearly $4,000 in fees you keep if you handle registration on your own WordPress install. Plus you own the data, the email list, and the post-event report.
This guide ships a complete event-management workflow on one Gravity Form: registration intake, organiser triage, on-the-door check-in, public attendee list, and per-session reporting.
What you’ll build#
Four URLs, all driven by a single Gravity Form:
/register, public registration form/staff/registrations, organiser dashboard (the inbox; bulk approve, reassign, export attendee list)/door, tablet-friendly check-in view used at the venue/attendees, public list of confirmed attendees (opt-in)
Plus a manager view at /staff/reports for session-by-session capacity and revenue summaries.
Step 1: shape the registration form#
Customer-facing fields:
first_name,last_name,email, requiredcompany,job_title, optional, but useful for the public attendee listsession_choice, single-select with all session names, or multi-select for multi-track eventsdietary_restrictions, paragraph (catering planning)payment_token, only if you’re charging via Stripe / PayPal / etc.
Hidden admin-side fields:
status, Hidden, defaultpending_paymentif paid,confirmedif free. Values:pending_payment,confirmed,checked_in,cancelled,refunded,waitlisted.ticket_code, Hidden, populated by submission hook with a unique 8-char code (used for door scanning).checkin_time, Date/time, default empty. Set when checked in at the door.assigned_seat, Hidden, default empty. For events with assigned seating.internal_notes, Paragraph, default empty. Organiser commentary.
Step 2: generate ticket codes on submission#
A small hook in your child theme’s functions.php:
add_action('gform_after_submission_event', function ($entry, $form) {
$code = strtoupper(wp_generate_password(8, false));
GFAPI::update_entry_field($entry['id'], 'ticket_code', $code);
if (!rgar($entry, 'payment_token')) {
GFAPI::update_entry_field($entry['id'], 'status', 'confirmed');
}
}, 10, 2);
The 8-char code is what attendees show at the door. It’s short enough to be QR-encoded for badge printing, unique enough that collision is statistically negligible (62^8 ≈ 218 trillion combinations).
For paid events, leave status as pending_payment until your payment hook flips it to confirmed after a successful charge.
Step 3: build the public registration page#
Standard Gravity Forms shortcode at /register:
[gravityform id="event" title="false" description="false" ajax="true"]
After submission, redirect to a thank-you page that displays the ticket code and a calendar .ics download. Configure under Form Settings → Confirmations.
Step 4: build the organiser dashboard#
The triage workspace at /staff/registrations:
[gravity_table id="event"
columns="created,first_name,last_name,email,session_choice,status,ticket_code,checkin_time,dietary_restrictions"
allowed_roles="event-organiser,event-staff,administrator"
allow_edit="status,assigned_seat,internal_notes"
edit_permissions="status:event-organiser"
filters="status,session_choice,created"
bulk="confirm,cancel,export,email_ticket"
bulk_permissions="cancel:event-organiser,email_ticket:event-organiser"
audit_log="true"
auto_refresh="true"
refresh_interval="30"
sort="created:desc"
per_page="50"]
Six knobs doing real work:
filter_user_ownsis omitted, staff see all registrations, not just their own (events are team-managed)statuseditable byevent-organiseronly, staff can update internal notes and seat assignments, but only organisers move statebulkincludesemail_ticket, custom bulk action that resends the confirmation email + ticket code (registered via the hooks doc)auto_refreshevery 30s, last-minute registrations appear in the dashboard automaticallyaudit_log, every state change is logged; useful when reconciling with payment processor exports
Saved view URLs#
A small navigation strip:
<nav aria-label="Registration views">
<a href="/staff/registrations">All</a>
<a href="/staff/registrations?gt_filter_status=pending_payment">Awaiting payment</a>
<a href="/staff/registrations?gt_filter_status=confirmed">Confirmed</a>
<a href="/staff/registrations?gt_filter_status=checked_in">Checked in today</a>
<a href="/staff/registrations?gt_filter_status=cancelled,refunded">Cancellations</a>
</nav>
Step 5: build the door check-in view#
The on-the-door tablet view at /door looks different from the staff dashboard, it’s optimised for fast lookup and one-tap actions.
[gravity_table id="event"
columns="ticket_code,first_name,last_name,session_choice,status"
filter="status:confirmed,checked_in"
allowed_roles="event-staff,event-organiser,administrator"
allow_edit="status"
edit_permissions=""
bulk=""
search_field="ticket_code,first_name,last_name,email"
per_page="100"
mobile_layout="cards"]
Notable choices:
filterincludes bothconfirmedANDchecked_in, staff can see who’s already through, useful for “have you seen X come in yet?” lookupssearch_fieldrestricts the global search to the four columns most useful at the door, staff type a partial name or paste a ticket code, get results in real timeallow_edit="status"with no role gate, anyone with door access can flip a row fromconfirmedtochecked_in. The intentionally narrow column scope means they can’t edit anything else.mobile_layout="cards", the tablet sees one card per attendee with a big touch-target for the status dropdownbulk="", no bulk actions on the door view; check-ins are individual events
Auto-stamp check-in time#
When status flips to checked_in, automatically stamp the time:
add_action('gravity_tables_entry_updated', function ($entry_id, $form_id, $old, $new) {
if ($form_id !== 'event') return;
if (rgar($new, 'status') === 'checked_in' && rgar($old, 'status') !== 'checked_in') {
GFAPI::update_entry_field($entry_id, 'checkin_time', wp_date('Y-m-d H:i:s'));
}
}, 10, 4);
The dashboard’s checkin_time column then shows when each attendee arrived, useful for measuring arrival flow and identifying late-comers.
Step 6: build the public attendee directory#
For events that benefit from an “I see Sarah from Acme is going” public list (most B2B conferences do), an opt-in view at /attendees:
[gravity_table id="event"
columns="first_name,last_name,company,job_title"
filter="status:confirmed,checked_in"
user_filter_field="public_listing:yes"
allow_edit=""
filters="company"
per_page="50"
sort="last_name:asc"
mobile_layout="cards"]
Two important config bits:
user_filter_field="public_listing:yes", only attendees who explicitly opted into a “list me publicly” checkbox on registration appear. GDPR-defensible by default.- No email or any contact info in the columns list, the public directory shows only the data attendees explicitly agreed to share.
For paid events, this directory becomes a marketing asset: prospective registrants see who’s already coming, social proof drives more registrations.
Step 7: register the custom bulk actions#
The confirm, cancel, and email_ticket bulk actions need registration. Pattern matches other guides:
add_filter('gt_register_bulk_actions', function ($actions) {
$actions['confirm'] = [
'label' => 'Confirm selected',
'capability' => 'edit_others_posts',
'callback' => function ($entry_ids) {
foreach ($entry_ids as $id) {
GFAPI::update_entry_field($id, 'status', 'confirmed');
}
return ['success' => count($entry_ids), 'message' => count($entry_ids) . ' confirmed'];
},
];
$actions['email_ticket'] = [
'label' => 'Resend ticket email',
'capability' => 'edit_others_posts',
'callback' => function ($entry_ids) {
$sent = 0;
foreach ($entry_ids as $id) {
$entry = GFAPI::get_entry($id);
$code = rgar($entry, 'ticket_code');
$email = rgar($entry, 'email');
if (!$email || !$code) continue;
wp_mail(
$email,
'Your event ticket',
"Your code: {$code}\n\nDoors open at 9 AM."
);
$sent++;
}
return ['success' => $sent, 'message' => "Re-sent {$sent} tickets"];
},
];
$actions['cancel'] = [
'label' => 'Cancel registration',
'capability' => 'edit_others_posts',
'prompt' => [
'type' => 'textarea',
'label' => 'Cancellation reason (optional, recorded in notes):',
],
'callback' => function ($entry_ids, $args) {
foreach ($entry_ids as $id) {
GFAPI::update_entry_field($id, 'status', 'cancelled');
if ($args['textarea'] ?? false) {
$existing = gform_get_meta($id, 'internal_notes');
GFAPI::update_entry_field($id, 'internal_notes', trim($existing . "\n" . wp_date('Y-m-d') . ": " . $args['textarea']));
}
}
return ['success' => count($entry_ids), 'message' => count($entry_ids) . ' cancelled'];
},
];
return $actions;
});
Step 8: capacity reporting#
For events with capped sessions, organisers need real-time capacity by session. A separate [gravity_table] shortcode at /staff/reports with group_by:
[gravity_table id="event"
columns="session_choice,_count,_sum_value"
group_by="session_choice"
totals="_count,_sum_value"
filter="status:confirmed,checked_in"
allowed_roles="event-organiser,administrator"
allow_edit=""
per_page="50"]
The grouped view gives one row per session with attendee count and revenue (if value is the ticket price). Compare to your venue’s capacity per session and fire a Slack alert when any session crosses 90%:
add_action('gravity_tables_entry_created', function ($entry_id, $form_id) {
if ($form_id !== 'event') return;
$entry = GFAPI::get_entry($entry_id);
$session = rgar($entry, 'session_choice');
if (!$session) return;
$session_total = count(GFAPI::get_entries('event', [
'field_filters' => [
['key' => 'session_choice', 'value' => $session],
['key' => 'status', 'value' => ['confirmed','checked_in'], 'operator' => 'in'],
],
]));
$cap = SESSION_CAPS[$session] ?? PHP_INT_MAX;
if ($session_total === intval($cap * 0.9)) {
wp_remote_post(SLACK_WEBHOOK_URL, [
'body' => json_encode(['text' => "⚠ {$session} is 90% full ({$session_total}/{$cap})"]),
]);
}
}, 10, 2);
The integer-equality check prevents repeat alerts, the message fires once when the threshold is crossed, not on every subsequent registration.
What this gives you#
For a 50-500 person event, this configuration delivers:
- No platform fees, replace 3.7% + $1.79 per ticket with your existing WP hosting cost
- You own the data, attendee list, email addresses, dietary preferences all in your DB, no export job required
- Real-time capacity tracking, organisers always know where they are vs. cap
- Door experience that scales, staff with tablets check in 200 attendees in 20 minutes
- Public attendee directory for social-proof marketing
- Audit trail for refund disputes (“when was this registration cancelled?”)
What it doesn’t give you#
- Payment processing (use Gravity Forms Stripe / PayPal add-ons; this guide assumes payment is wired separately)
- Email broadcasting / drip sequences (use FluentCRM, Brevo, or Mailchimp; integrate via the lifecycle hooks)
- Mobile app for attendees (web-based check-in via /door is the alternative)
- Multi-event support (one form = one event; for recurring events, duplicate the form)
Recipe variations#
Free meetup or community event#
Drop payment_token, value, and the 90%-capacity Slack alert. Default status to confirmed on submission. The waitlist pattern still applies if you have venue caps.
Multi-track conference#
Promote session_choice from single-select to multi-select. Update the capacity report to group_by="session_choice" with attendees-per-session. Add a track_lead field for assigning each session to its organiser.
Workshop or training course#
Add a cohort field (e.g. “March 2026 Cohort”). Filter the public attendee list by cohort, so each cohort sees only their peers.
Internal company event (no public list)#
Drop /attendees entirely. Set allowed_roles="employee" on /staff/registrations. The internal team accesses the registration data via WP login.
Don’ts#
Don’t expose ticket_code or status on the public registration form#
Both are workflow primitives. A submitter setting their own status: confirmed skips your payment validation entirely. Always Hidden, always populated by hooks.
Don’t skip the public-listing opt-in for the attendee directory#
Posting names + companies without explicit consent fails GDPR. The public_listing:yes filter is non-negotiable for an EU-attended event.
Don’t run the door check-in on a flaky WiFi connection#
The check-in view requires a working connection. For venues with bad connectivity, pre-print a paper backup attendee list (export from /staff/registrations) and reconcile manually post-event.
Related#
- Build a moderation queue, sister status-machine pattern (different framing for the same primitives)
- Build a help desk, uses similar custom-bulk-action patterns
- Build a CRM lead dashboard, lead-vs-attendee analogue with similar shape
- Use cases → Event management, the vertical landing page this guide implements
- Hooks → bulk action registration, for the confirm / cancel / email_ticket actions