Build an inventory tracker with Gravity Forms and Gravity Tables
A complete pattern for tracking stock on a WordPress site. Public catalog, staff workspace, low-stock alerts, multi-location split, restock workflow, all from one Gravity Form, no inventory SaaS subscription.
Inventory SaaS sits at $50-$300 per month per location. For a single-warehouse small business that already runs WordPress for the storefront, that’s pure overhead, the data lives in your forms, your team logs into your site daily, and the operations are simple enough that a real spreadsheet would handle them.
This guide ships the inventory pattern most small operations actually need: catalog browsing, staff stock workspace, low-stock alerts, restock workflow, and multi-location split when growth requires it. One Gravity Form, one shortcode, no SaaS bill.
What you’ll build#
Three URLs:
/catalog, public-facing product catalog with search and category filters/staff/stock, staff stock workspace (the edit surface)/staff/restocks, restock-pending view (filtered subset of/staff/stock)
Plus a daily cron that emails the operations lead when any item drops below its reorder point.
Step 1: shape the form#
Customer-visible fields (these power the public catalog):
name, single-line text, requiredsku, single-line text, required, uniquedescription, paragraphcategory, dropdownprice, number/currencyimage_url, URL or file upload
Hidden admin-side fields:
qty_on_hand, number, default 0. Current stock count.reorder_point, number, default 5. Below this → alert.location, single-line text, default empty. For multi-warehouse setups.supplier, single-line text, default empty.status, Hidden, defaultactive. Values:active,discontinued,pending_restock.last_counted, Date, default empty. Set when staff confirm a count.
Step 2: build the public catalog#
A read-only gravity_table shortcode at /catalog:
[gravity_table id="inventory"
columns="image_url,name,sku,category,price,qty_on_hand"
filter="status:active"
filters="category"
search_field="name,sku,description"
per_page="24"
sort="name:asc"
mobile_layout="cards"
allow_edit=""]
Why these knobs:
filter="status:active", discontinued or pending-restock items don’t appear. Keeps the public catalog clean automatically.search_field, search hitsname,sku, anddescriptionso customers find what they’re looking for whether they remember the model name, the SKU, or just a featureper_page="24", divisible by 1/2/3/4 columns; renders cleanly on every viewportmobile_layout="cards", phone customers get one card per row with the image prominentallow_edit="", explicitly read-only. Customers don’t edit anything; the public role gate is implicit (noallowed_rolesmeans everyone sees it).
If you want each row to link to a product detail page, add a [lookup_field] column or use Gravity Tables’ built-in link_field parameter to point each row at the matching post.
Step 3: build the staff stock workspace#
/staff/stock is where the team manages inventory. Different shortcode, same form:
[gravity_table id="inventory"
columns="image_url,name,sku,location,qty_on_hand,reorder_point,status,last_counted,supplier"
allowed_roles="warehouse-staff,warehouse-lead,administrator"
allow_edit="qty_on_hand,location,status,last_counted,supplier"
edit_permissions="status:warehouse-lead,reorder_point:warehouse-lead"
filters="category,location,status"
bulk="mark_restock,export"
audit_log="true"
auto_refresh="true"
refresh_interval="60"
sort="qty_on_hand:asc"]
Five intentional choices:
sort="qty_on_hand:asc", items closest to running out float to the top of the workspace by default. Staff see the priority queue without applying a filter.edit_permissions, staff edit qty, location, last-counted, supplier. Onlywarehouse-leadcan changestatus(so an item doesn’t get accidentally markeddiscontinued) orreorder_point(a parameter-tuning decision, not a daily-ops decision).auto_refresh="60", when one staffer counts a shelf and updates qty, the other staff workspace tabs refresh within a minute. Prevents two people counting the same shelf.bulk="mark_restock,export",mark_restockis a custom action that flipsstatustopending_restockand triggers a notification. Export gives the lead an Excel snapshot for monthly reconciliation.audit_log="true", every qty change recorded with user + timestamp. When the year-end count doesn’t match expected, the audit log tells you exactly when discrepancies were introduced.
Step 4: register the mark_restock bulk action#
In your child theme’s functions.php:
add_filter('gt_register_bulk_actions', function ($actions) {
$actions['mark_restock'] = [
'label' => 'Mark for restock',
'capability' => 'edit_others_posts',
'callback' => function ($entry_ids) {
foreach ($entry_ids as $id) {
GFAPI::update_entry_field($id, 'status', 'pending_restock');
}
// Notify the warehouse lead with a single batched email
$count = count($entry_ids);
wp_mail(
'warehouse-lead@your-co.com',
"{$count} items marked for restock",
"View at /staff/restocks"
);
return ['success' => $count, 'message' => "Marked {$count} for restock"];
},
];
return $actions;
});
The custom action bundles two operations: flipping the status field on each selected entry, plus a single batched notification email. Staff don’t need to email the lead individually.
Step 5: build the restocks view#
The pending-restock workspace at /staff/restocks is just /staff/stock with a different filter:
[gravity_table id="inventory"
columns="name,sku,supplier,qty_on_hand,reorder_point,location"
filter="status:pending_restock"
allowed_roles="warehouse-lead,administrator"
allow_edit="qty_on_hand,status"
bulk="confirm_restocked,cancel_restock"
audit_log="true"
sort="supplier:asc,name:asc"]
Two custom bulk actions for the lead’s workflow:
$actions['confirm_restocked'] = [
'label' => 'Confirm received',
'capability' => 'edit_others_posts',
'prompt' => [
'type' => 'number',
'label' => 'Quantity received (will be added to current qty_on_hand):',
],
'callback' => function ($entry_ids, $args) {
$delta = max(0, intval($args['number'] ?? 0));
foreach ($entry_ids as $id) {
$current = intval(gform_get_meta($id, 'qty_on_hand'));
GFAPI::update_entry_field($id, 'qty_on_hand', $current + $delta);
GFAPI::update_entry_field($id, 'status', 'active');
GFAPI::update_entry_field($id, 'last_counted', wp_date('Y-m-d'));
}
return ['success' => count($entry_ids), 'message' => 'Updated'];
},
];
$actions['cancel_restock'] = [
'label' => 'Cancel restock (back to active)',
'capability' => 'edit_others_posts',
'callback' => function ($entry_ids) {
foreach ($entry_ids as $id) {
GFAPI::update_entry_field($id, 'status', 'active');
}
return ['success' => count($entry_ids), 'message' => 'Cancelled'];
},
];
Notable: the confirm_restocked action takes a numeric prompt for “how many were received” and adds that to the existing qty_on_hand. Sets last_counted to today as a side effect.
Step 6: low-stock alert via daily cron#
A daily WP-Cron task that emails the operations lead when any item is below its reorder point:
add_action('init', function () {
if (!wp_next_scheduled('gt_low_stock_check')) {
wp_schedule_event(strtotime('tomorrow 8am'), 'daily', 'gt_low_stock_check');
}
});
add_action('gt_low_stock_check', function () {
// Get all active items
$items = GFAPI::get_entries('inventory', [
'field_filters' => [
['key' => 'status', 'value' => 'active'],
],
]);
$low = [];
foreach ($items as $item) {
$qty = intval(rgar($item, 'qty_on_hand'));
$reorder = intval(rgar($item, 'reorder_point'));
if ($reorder > 0 && $qty < $reorder) {
$low[] = [
'name' => rgar($item, 'name'),
'sku' => rgar($item, 'sku'),
'qty' => $qty,
'reorder' => $reorder,
];
}
}
if (empty($low)) return;
$body = "These items are below their reorder point:\n\n";
foreach ($low as $i) {
$body .= "{$i['name']} ({$i['sku']}): {$i['qty']} on hand, reorder at {$i['reorder']}\n";
}
$body .= "\nMark for restock at /staff/stock?gt_filter_status=active>_sort=qty_on_hand:asc";
wp_mail('warehouse-lead@your-co.com', count($low) . ' items below reorder point', $body);
});
Daily was deliberate. Hourly is too noisy for inventory ops; weekly misses fast-moving items. The 8 AM cadence puts the email in the lead’s inbox before they start the day.
Step 7: multi-location split#
If you grow past one warehouse, the location field becomes the partition key. Two changes:
Per-location view URLs#
<nav aria-label="Stock by location">
<a href="/staff/stock">All locations</a>
<a href="/staff/stock?gt_filter_location=warehouse-east">East</a>
<a href="/staff/stock?gt_filter_location=warehouse-west">West</a>
<a href="/staff/stock?gt_filter_location=storefront-1">Storefront 1</a>
</nav>
Per-location staff scoping#
If East-warehouse staff should only edit East-warehouse items, use filter_user_owns="location" after first populating each user’s profile with their assigned location (custom user meta key). Or use a custom hook on gt_filter_query to scope by current_user_can('manage_location_east') capability.
Capacity and dashboard reporting#
For the operations lead, a separate gravity_table shortcode at /staff/dashboard with grouped totals:
[gravity_table id="inventory"
columns="category,_count,_sum_qty_on_hand"
group_by="category"
totals="_sum_qty_on_hand"
filter="status:active"
allowed_roles="warehouse-lead,administrator"
allow_edit=""
per_page="50"]
One row per category, with item count and total quantity. Compare to your sales velocity (a separate Sales form, ideally) and decide whether to reorder more aggressively per category.
What this gives you#
For a 1-500 SKU operation:
- No SaaS subscription, replace $50-$300/mo per location with your existing WP hosting cost
- Public catalog and staff workspace from one form, no integration glue
- Audit log of every qty change, useful when the year-end count doesn’t reconcile
- Low-stock alerts that reach the right person, on a daily cadence that doesn’t become noise
- Multi-location ready when you grow
What it doesn’t give you#
- Barcode scanning, staff manually type SKUs or use a barcode-to-keyboard scanner that types into the search box (works fine for our pattern, but is not a “scan and update” workflow)
- PO management, purchase orders, vendor invoicing, payment tracking. Use Gravity Forms with a separate “po” form, or integrate with QuickBooks via a separate tool.
- Forecasting, qty-needed-next-month projections. Export to a spreadsheet for that.
- Multi-currency, single-currency setups only
For most small operations, this is the right level of tool. Past 5,000 SKUs and 5+ locations, a dedicated inventory system becomes worth the cost.
Recipe variations#
Library / equipment lending#
qty_on_hand becomes “available copies”; add a separate “checkouts” form linking borrower + item + due date. The catalog shows availability; the staff workspace tracks overdue items via a date filter.
Restaurant supply#
Categories: produce, dry goods, dairy, beverages. Reorder_point varies by category (perishables higher, dry goods lower). Daily cron becomes 6 AM so the kitchen’s morning order goes out with breakfast prep.
Photography / film equipment rental#
Add serial_number, condition, and next_service_due fields. Staff workspace filters by condition; the dashboard groups by category and surfaces items overdue for service.
Don’ts#
Don’t expose qty_on_hand as user-editable on the public form#
Hidden admin field only. Customers should never set their own quantity; staff verifies through counts.
Don’t skip the last_counted field#
Stock counts drift. Recording when each item was last manually verified means staff (and the audit log) can identify the items that haven’t been counted recently and prioritise them.
Don’t run low-stock alerts more than daily#
Hourly polling generates noise. Items dropping below reorder midweek is fine to flag once, not 24 times.
Related#
- Build a searchable directory, sister read-only catalog pattern, simpler workflow
- Build a CRM lead dashboard, different vertical, similar shape (status workflow + bulk actions + scheduled email)
- Use cases → Inventory tracking, the vertical landing page this guide implements
- Hooks → bulk action registration, for
mark_restock/confirm_restocked/cancel_restock - Performance and scaling, for inventories beyond ~5,000 items