Running a restaurant menu in WordPress from a Google Sheet comes down to four moving parts: a menu_item custom post type for each dish, a sheet the kitchen actually edits, a WP-CLI command that reads the sheet on a schedule and applies each row, and a "sold out" flag that hides a dish instead of deleting it. The front-of-house staff never log into wp-admin. They tick a cell that says a dish is 86'd, change a price, or flag the soup of the day, and the site catches up within a few minutes.
This is the pull model applied to a real vertical: WordPress reaches out to the sheet with a service account, compares each row to what it already has, and writes only what changed. Nobody pushes a button. A cron does it. The whole thing is built on the change-only, dry-run discipline from the safe batch updater, because a menu sync that runs every few minutes has to be cheap and boring: when nothing changed, it does nothing.
The sheet the kitchen edits
One row per dish. The columns are exactly what a chef or a manager would want to touch, and nothing more:
| item_id | name | section | price | special | sold_out |
|---|---|---|---|---|---|
| BR-01 | Sourdough & Cultured Butter | Starters | 7.00 | no | no |
| MN-04 | Dry-Aged Ribeye, 300g | Mains | 38.50 | yes | no |
| MN-09 | Whole Roast Plaice | Mains | 26.00 | no | yes |
| DS-02 | Burnt Basque Cheesecake | Desserts | 9.00 | no | no |
The first column, item_id, is the join key, and getting that right is the single most important decision in this whole setup. Do not join on the dish name. Names change constantly: "Ribeye" becomes "Dry-Aged Ribeye, 300g" becomes "Tomahawk for Two." If the name is your key, every rename orphans a row and creates a duplicate. A stable SKU-style item_id (MN-04) is assigned once and never changes, even when the dish, the price, and the description all do. I store that item_id on the post as meta and look posts up by it.
The menu_item post type
Each dish is one post of a custom post type. If you already have a menu plugin with its own CPT, skip this and reuse its post type and fields. If you are building it yourself, registering the type is a few lines:
<?php
/** Register the menu_item post type. Runs on init. */
function te_register_menu_item_cpt() {
register_post_type( 'menu_item', array(
'labels' => array(
'name' => 'Menu Items',
'singular_name' => 'Menu Item',
),
'public' => true,
'show_in_rest' => true,
'menu_icon' => 'dashicons-food',
'supports' => array( 'title', 'editor', 'thumbnail' ),
'has_archive' => true,
'rewrite' => array( 'slug' => 'menu' ),
) );
}
add_action( 'init', 'te_register_menu_item_cpt' );That gives you the post type. The fields the sheet drives (price, section, the two flags) live as post meta on each menu_item. Whether you reach for ACF or native register_post_meta is a real decision with real trade-offs, and I have laid it out in full in ACF fields versus native post meta. The short version for a menu: the values here are simple scalars (a price string, a section label, two booleans), so native meta is perfectly sufficient and has no dependency. ACF is worth it if non-technical staff also edit dishes in wp-admin sometimes and you want a friendly field UI for them. The sync code below works with either; the only thing that changes is the write call.
Money is a string, not a number
A menu price is the place this kind of sync quietly goes wrong, so it earns its own rule: treat the price as a string, never let it become a float. A Google Sheet, left to its own devices, will show 9.00 as 9 the moment the cell is formatted as a number, because trailing zeros are not significant to a number. If your sync reads that cell as a float you store 9, the template prints "£9," and a customer reads it as nine pounds nothing on a menu that meant nine pounds zero zero. Worse, 9.10 can round to 9.1.
Two defenses, both cheap. Format the price column in the sheet as plain text, not as a number or currency, so 9.00 stays the literal string 9.00. And in the sync, read the value as a string and write it as a string: never cast it to (float) or (int). Store the canonical 9.00 and let the theme decide how to render the currency symbol. The same reasoning applies to any money field anywhere; I treat it as non-negotiable.
86'd means hide, not delete
When the kitchen runs out of plaice, the dish is "86'd," restaurant shorthand for sold out. The instinct is to delete the post so it disappears from the menu. That is the wrong move, because the dish is coming back tomorrow, and deleting it throws away the post, its item_id link, its photo, its history, and any URL that ranked. Re-creating it the next morning gives you a new post ID, a broken join key, and a fresh permalink.
So sold_out is a flag, not a delete. The sync writes a is_86 meta value (or sets a term, if you prefer taxonomy-driven templates), and the theme reads that flag and decides what to do: grey the dish out with a "Sold out for today" badge, or hide it from the menu entirely while keeping the post intact. Both are a template if, not a database deletion:
<?php
/** In the menu template loop: skip or mark an 86'd dish. */
$is_86 = get_post_meta( get_the_ID(), 'is_86', true );
if ( $is_86 ) {
// Option A: hide it completely from today's menu.
// continue;
// Option B: keep it visible but greyed out and unorderable.
echo '<li class="menu-item is-sold-out">';
echo '<span class="dish">' . esc_html( get_the_title() ) . '</span>';
echo '<span class="badge">Sold out</span>';
echo '</li>';
}Tomorrow the kitchen flips sold_out back to no in the sheet, the next sync clears the flag, and the dish is back with the same post ID and the same URL. Nothing was lost. The special column works the same way, as a is_special boolean the template surfaces (a "Chef's special" ribbon, a spot in a featured row), so the daily soup or the weekend dish is just a cell the kitchen ticks.
The pull command
This registers wp te-menu pull. It authenticates as a service account, reads the sheet, finds each dish by its item_id, and applies price, section, and the two flags, change-only, writing nothing unless you pass --live. The command is self-contained: te_sheet_token() and te_sheet_read() (the same service-account helpers used across the sheet cluster) are included at the top. (If publishing the sheet to the web as CSV is acceptable for your case, the simpler CSV-versus-API trade-off is worth a look, but a menu with prices is usually one you want to keep unpublished, which means the authenticated API.)
<?php
/**
* Plugin Name: TE Menu Sheet Sync
* Plugin URI: https://techearl.com/restaurant-menu-wordpress-google-sheet
* Description: WP-CLI command that pulls a restaurant menu from a Google Sheet and reconciles the menu_item post type, change-only.
* Author: Ishan Karunaratne
* Author URI: https://techearl.com
*/
defined( 'ABSPATH' ) || exit;
if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) { return; }
/** Base64url-encode (JWT segments are unpadded and use -_ instead of +/). */
function te_b64url( $s ) { return rtrim( strtr( base64_encode( $s ), '+/', '-_' ), '=' ); }
/**
* Mint a short-lived service-account access token (JWT bearer grant).
* Scope defaults to read-only; pass the read-write Sheets scope to write back.
*/
function te_sheet_token( array $key, string $scope = 'https://www.googleapis.com/auth/spreadsheets.readonly' ) {
$now = time();
$header = te_b64url( wp_json_encode( array( 'alg' => 'RS256', 'typ' => 'JWT' ) ) );
$claims = te_b64url( wp_json_encode( array(
'iss' => $key['client_email'],
'scope' => $scope,
'aud' => 'https://oauth2.googleapis.com/token',
'iat' => $now,
'exp' => $now + 3600,
) ) );
$sig = '';
openssl_sign( "{$header}.{$claims}", $sig, $key['private_key'], 'sha256WithRSAEncryption' );
$jwt = "{$header}.{$claims}." . te_b64url( $sig );
$res = wp_remote_post( 'https://oauth2.googleapis.com/token', array(
'body' => array(
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt,
),
) );
return json_decode( wp_remote_retrieve_body( $res ), true )['access_token'] ?? '';
}
/** Read a sheet range over the Sheets API. Returns the raw rows, header included. */
function te_sheet_read( string $sheet_id, string $range, string $token ): array {
$url = sprintf( 'https://sheets.googleapis.com/v4/spreadsheets/%s/values/%s', rawurlencode( $sheet_id ), rawurlencode( $range ) );
$res = wp_remote_get( $url, array( 'headers' => array( 'Authorization' => "Bearer {$token}" ) ) );
return json_decode( wp_remote_retrieve_body( $res ), true )['values'] ?? array();
}
class TE_Menu_Sync_Command {
/** Resolve a menu_item post by its stable item_id, never by title. */
private function te_find_by_item_id( $item_id ) {
$found = get_posts( array(
'post_type' => 'menu_item',
'post_status' => 'any',
'numberposts' => 1,
'fields' => 'ids',
'meta_key' => 'item_id',
'meta_value' => $item_id,
) );
return $found ? (int) $found[0] : 0;
}
/**
* Pull the menu sheet and reconcile each dish.
*
* ## OPTIONS
* --sheet=<id> : Spreadsheet ID.
* --key=<path> : Path to the service-account JSON key.
* [--range=<a1>] : Sheet range. Default: Menu!A:F
* [--live] : Actually write. Without it, dry run.
*/
public function pull( $args, $assoc ) {
$key = json_decode( file_get_contents( $assoc['key'] ), true );
$range = $assoc['range'] ?? 'Menu!A:F';
$live = isset( $assoc['live'] );
$token = te_sheet_token( $key );
if ( ! $token ) { WP_CLI::error( 'Could not get an access token.' ); }
$rows = te_sheet_read( $assoc['sheet'], $range, $token );
array_shift( $rows ); // drop the header row
$changes = array();
foreach ( $rows as $row ) {
$item_id = trim( (string) ( $row[0] ?? '' ) );
if ( '' === $item_id ) { continue; }
$post_id = $this->te_find_by_item_id( $item_id );
if ( ! $post_id ) {
WP_CLI::warning( "No menu_item for item_id {$item_id}; skipping." );
continue;
}
// The sheet is semi-trusted input, so sanitize every free-text value on
// the way in (sanitize_text_field leaves a price like "9.00" untouched).
// Price stays a string. Never cast to float.
$want = array(
'price' => sanitize_text_field( (string) ( $row[3] ?? '' ) ),
'section' => sanitize_text_field( (string) ( $row[2] ?? '' ) ),
'is_special' => strtolower( trim( (string) ( $row[4] ?? '' ) ) ) === 'yes' ? '1' : '0',
'is_86' => strtolower( trim( (string) ( $row[5] ?? '' ) ) ) === 'yes' ? '1' : '0',
);
foreach ( $want as $meta_key => $value ) {
$current = (string) get_post_meta( $post_id, $meta_key, true );
if ( $current === $value ) { continue; } // change-only
$changes[] = array(
'item_id' => $item_id,
'field' => $meta_key,
'old' => $current,
'new' => $value,
);
if ( $live ) { update_post_meta( $post_id, $meta_key, $value ); }
}
}
if ( empty( $changes ) ) { WP_CLI::success( 'Menu and sheet already match. Nothing to do.' ); return; }
WP_CLI\Utils\format_items( 'table', $changes, array( 'item_id', 'field', 'old', 'new' ) );
$mode = $live ? 'applied' : 'WOULD apply (dry run)';
WP_CLI::success( sprintf( '%d field changes %s from the sheet.', count( $changes ), $mode ) );
}
}
WP_CLI::add_command( 'te-menu', 'TE_Menu_Sync_Command' );If your fields are ACF rather than native meta, swap get_post_meta/update_post_meta for get_field/update_field so the field reference resolves correctly (the why is in ACF fields versus native meta). Everything else is identical.
Treat the sheet as semi-trusted input. The command sanitizes each free-text value with sanitize_text_field on the way in, and your theme should still escape on the way out (esc_html( get_post_meta( $id, 'section', true ) ), esc_html around the price) exactly as it would for any user-supplied value. Whoever can edit the sheet can write what lands in your pages, so do not echo a raw cell.
Run it
Dry run first, every time. It reads the sheet, finds each dish by item_id, compares field by field, and prints exactly what would change without touching the database:

The old column is what WordPress had, the new column is what the sheet says, and only differing fields appear. That is the change-only rule doing its job: a dish whose price and flags already match the sheet produces no row, so the sync stays cheap even when the sheet has a hundred items and only one changed. Once the dry run looks right, add --live to apply it.
Put it on a cron
The whole point is that nobody clicks anything. Schedule the command and the sheet becomes the live source of truth that WordPress reconciles to:
# Every 3 minutes, reconcile the menu from the sheet.
*/3 * * * * cd /var/www/site && wp te-menu pull --sheet=SHEET_ID --key=/etc/secrets/menu-sa.json --live >> /var/log/te-menu.log 2>&1Because the sync is idempotent and change-only, running it every three minutes costs almost nothing: most runs read the sheet, find nothing different, and exit. When the kitchen 86's the plaice at 7pm, the dish is greyed out on the site within three minutes, no login, no deploy. If you run this across more than one restaurant site, the multi-site fleet pattern fans the same command out to every site from one sheet. And if even three minutes is too slow (you want a dish to vanish the instant someone ticks the cell), an onEdit trigger that pushes to WordPress fires on the edit itself rather than waiting for the next cron tick.
Closing the loop back to the sheet
There is one gap in a pure pull: if a dish is marked 86'd inside wp-admin (a manager greys it out from the dashboard rather than the sheet), the sheet still says sold_out = no, and the next pull will happily un-86 it, because the sheet is the source of truth and the sheet says available. To stop the two sides fighting, push the flag back: when WordPress changes is_86, write it into the sheet's sold_out cell so the sheet reflects what the site actually shows.
That is a two-way sync, and the rule that keeps it sane is one owner per field. For a menu I let the sheet own price, section, and special (the kitchen and the manager live in the sheet), and let whichever side last changed is_86 win, reconciled back so both agree. The pull I built here is the foundation; the write-back is the push direction layered on top, and the combination is what makes the sheet trustworthy as the single place the whole team looks at.
Sources
Authoritative references this article was fact-checked against.
- register_post_type() - WordPress Developer Referencedeveloper.wordpress.org
- update_post_meta() - WordPress Developer Referencedeveloper.wordpress.org
- spreadsheets.values.get - Google Sheets APIdevelopers.google.com
- WP_CLI::add_command() - WP-CLI Handbookmake.wordpress.org
- wp_remote_get() - WordPress Developer Referencedeveloper.wordpress.org





