Restaurant Menus & Online Ordering turns the site into a food-ordering front door: publish menus with sections, items, photos, dietary tags, and modifiers (sizes, sides, toppings), then take pickup or delivery orders straight from the page — with tips, Stripe payment or pay-at-pickup, a live kitchen order board, and an order-tracking page the customer can bookmark. Every order lands in the CRM, so the person who ordered today is reachable by every follow-up tool the site has.
The problem
A restaurant's menu usually lives on a PDF or a photo of a printed page, and its orders go through a third-party app that takes a cut of every sale, owns the customer relationship, and doesn't talk to the site's own contact list or marketing tools. The website shows what's on the menu; the actual ordering happens somewhere else, on somebody else's terms.
The fix
A self-contained feature module under app/Features/Restaurant/ gives the site its own ordering system: menus organized into sections and items, reusable modifier groups (size, sides, toppings), ordering hours, and a public ordering widget that takes pickup or delivery orders with Stripe payment or pay-at-pickup — all in the install's own database. Orders create CRM contacts automatically (source restaurant) and log to the contact's timeline.
Like every addon, it's structured as a feature module gated by feature:restaurant middleware. The service provider boots unconditionally; routes 404 and the sidebar entry hides when the feature is off. Toggling it on runs the module's migrations and materializes the editable /order page.
What the dashboard gets
Four pages under Dashboard → Restaurant (Menus, Modifiers, and Orders need Manager and up; Settings is Admin-only):
- Menus — build any number of menus (Lunch, Dinner, Drinks), each with an active toggle (inactive menus are hidden from the public page). Inside a menu, add sections (Appetizers, Entrées, Desserts) with drag-free up/down reordering, and inside a section, add items: name, description, price, a photo picked from the media library, dietary tags (Vegetarian, Vegan, Gluten-free, Dairy-free, Nut-free, Spicy), which modifier groups attach to it, an Available switch to 86 an item instantly without deleting it, and an optional availability schedule (hours and/or days — "lunch only, weekdays") for items that shouldn't be orderable around the clock. Deleting a menu, section, or item removes it from the live menu, but every past order keeps its own snapshot of names and prices — nothing already ordered ever changes retroactively.
- Modifiers — reusable choice groups attached to items from the Menus page: a group ("Choose a side", "Size", "Add toppings") has a minimum and maximum number of selections (min > 0 makes it required; max = 0 means no limit), and each option inside it carries a signed price change (e.g.
+1.50or-0.50) plus its own availability toggle. A "Required" badge shows on groups wheremin_selectis above zero. - Orders — the kitchen order board: Active / Completed / Cancelled / All tabs, with the Active tab auto-refreshing every 15 seconds so new orders appear without a manual reload. Each order shows items, fulfillment type (including Dine-in · Table N for QR orders), requested time, total, payment status, and a one-click advance button that walks it through the fulfillment flow: Received → Preparing → Ready for pickup (or Out for delivery for delivery orders) → Completed. A detail modal shows the full breakdown — line items with chosen modifiers and special instructions, customer contact, delivery address, notes, and payment status. Orders can be cancelled (any status before Completed) and paid orders can be refunded in full through Stripe — both are separate, explicit actions.
- Settings (admin only) — ordering options, delivery zones (postal-prefix matching with per-zone fees/minimums), order scheduling horizon, a pause switch, weekly ordering hours, dine-in table QR codes (with a printable sheet), payment options and the Stripe webhook secret, email sender/notification settings, a kitchen printer email, and the SMS status updates toggle.
The dashboard also gets a Restaurant Orders card showing today's order count and revenue, linking straight to the Orders board.
What visitors see
On the /order page, visitors browse the menu (tabs when there's more than one), see each item's price, dietary tag badges, schedule badge ("Mon, Tue · 11:00–14:00") when one applies, and photo, then tap an item to open a configuration panel: pick required/optional modifiers (with "Required"/"Optional" and "up to N" hints), set a quantity, add a free-text special instruction, and add it to their order. A sticky cart panel tracks the running order with per-line quantity steppers and a subtotal/tax preview.
Checking out walks through:
- Fulfillment — Pickup, Delivery, or both (whichever the restaurant enables). Delivery asks for an address — and, when delivery zones are configured, a postal code that's checked live against the zones (showing the zone's fee and minimum, or "outside our delivery area"). Guests who scanned a table QR code skip all of this: the form locks to Dine-in · Table N.
- Timing — "As soon as possible" (with a live prep-time estimate) or a specific time slot in 15-minute steps. With a scheduling horizon configured, a day picker offers today plus up to N days ahead (skipping closed days); dine-in orders are always ASAP.
- Contact details — name, phone (optional), email, order notes, and — when SMS updates are enabled — a "text me order updates" opt-in.
- Tip — quick 0/10/15/20% buttons, when tipping is enabled.
- Payment — Pay online (embedded Stripe checkout) or pay at pickup/delivery, when both are offered.
Submitting a pay-at-store order places it immediately and sends the visitor straight to their order status page. Paying online hands off to an embedded Stripe checkout first — the order isn't finalized (and never reaches the kitchen board) until Stripe confirms payment.
Every placed order gets:
- A confirmation email with the itemized order, totals, requested time, delivery address (if any), payment method, and a Track your order link.
- An order status page (tokenized link, no login) showing a live status timeline — Received → Preparing → Ready for pickup/Out for delivery → Completed — the same items and totals, and an "Order again" link. It polls every 15 seconds while the order is still active, so the page updates itself as the kitchen works it.
The public order form is protected by a honeypot field and per-IP rate limiting (10 attempts per 10 minutes).
Ordering hours & pausing
- No hours configured means ordering is always open — hours are entirely opt-in. Once any weekly window exists, ordering is only available inside those windows.
- Hours are weekly wall-clock windows per day, interpreted in the site timezone (any number of windows per day; leave a day empty to stay closed that day).
- A dedicated pause switch stops new orders instantly — "kitchen slammed" — while leaving the menu fully browsable. An optional custom message replaces the default "closed" copy.
- Time slots for "order for later" are generated in 15-minute steps — from now + prep time for today, or across the whole window for future days. Settings → Schedule orders up to (days ahead) controls how far out the day picker goes (0 = today only); requested times beyond the horizon are rejected at submission.
- Per-item schedules are enforced at placement against the time the order is for — a lunch-only item is refused on a dinner-time ASAP order but accepted on an order scheduled for tomorrow noon.
- Every requested time is re-validated at submission (still ahead of now, still inside an open window), so a page left open past closing time can't sneak an order through.
Payments
Restaurant orders use the same site-wide Stripe keys as the shop, donations, booking, and ticketing (Settings → API Keys). The only restaurant-specific piece is the webhook: create an endpoint in Stripe pointed at /order/stripe/webhook (shown on the Settings page), subscribe it to checkout.session.completed, checkout.session.expired, and charge.refunded, and paste its signing secret into Dashboard → Restaurant → Settings. Pay-at-pickup/delivery works with no Stripe configuration at all.
Online orders are created as a pending-payment hold the moment checkout starts (never shown on the kitchen board) and are promoted to a real, kitchen-visible order the instant Stripe confirms payment — whichever of the webhook or the checkout return page lands first wins, and the other is a no-op. An abandoned checkout releases its hold automatically once the Stripe session expires.
Refunding an order from the Orders board issues a full refund (the remaining paid amount, minus anything already refunded) through Stripe. Cancelling an order does not automatically refund it — refunding is a separate, explicit action, so a cancelled paid order still needs its own refund click if money should go back.
Totals are computed as: subtotal (item prices + chosen modifier price changes, by quantity) + delivery fee (delivery orders only — the matched zone's fee when delivery zones are configured, otherwise the flat amount) + tax (a configured percentage applied to the subtotal and the delivery fee) + tip (0/10/15/20% of the subtotal, when tipping is enabled). Delivery orders below a configured minimum subtotal are rejected with a clear message at checkout.
Currency comes from the restaurant.currency setting, falling back to the shop's currency, defaulting to usd.
Promo codes. With a code applied on the details step, the discount comes off the subtotal before tax — a promo is a discount, unlike a gift card which is tender against the grand total. The tip is deliberately still a percentage of the full subtotal, so a house promotion doesn't quietly cut the staff's tip. Codes come from the shared list at Dashboard → Promo Codes, and a redemption is only counted once the order is real — an abandoned checkout never consumes a use. Loyalty points earn on the discounted subtotal. Codes and gift cards/credit/points are mutually exclusive.
Gift cards, account credit & points. With Gift Cards and/or Loyalty enabled, the details step offers a gift-card field, an account-credit toggle, and a points field, above the totals. Credit here is tender, not a discount: it comes off the grand total, and tax and tip stay calculated on the full amounts — a gift card pays the tax the same way cash does. It rides into Stripe as the single one-off coupon Stripe allows (so it can't be combined with a promo code), and the balance is only captured when the order actually pays. A 50c minimum stays card-payable. Pay-at-store orders have no credit UI — nothing would capture the balance without a payment. The order detail on the Orders board shows what was spent, which matters because the Stripe payout is net of it. See gift-cards.md.
CRM integration
When the CRM feature is enabled, every placed order:
- creates or updates a CRM contact (source restaurant, matched by email — existing contacts are never clobbered),
- re-fires the source-specific "contact recaptured" automation for returning customers,
- logs a restaurant interaction on the contact's timeline (order number, total, and the item list).
It's fire-and-forget: a CRM hiccup can never break an order placement, and everything no-ops cleanly when the CRM is off.
The editable /order page
Enabling the feature materializes an editable /order page (theme source: resources/themes/{theme}/feature-pages/restaurant/) — the heading and copy around the ordering widget are edited in the page editor like any page. The transactional pages (checkout, the Stripe return page, the order status page) stay module-owned and are never cached or page-edited.
The starter menu
The first time the feature is enabled on an install with no menus, a complete starter menu is seeded so /order and the menu-driven design rows render real content instead of empty states: a Lunch & Dinner menu (Starters, Burgers & Sandwiches, Mains, Desserts) and a Drinks menu — 14 dishes with prices, descriptions, dietary tags, and professional photos for the eight most photogenic plates (shipped WebPs, seeded into the media library under defaults/menu/). It's strictly one-shot: if any menu exists (even an emptied one), seeding never runs, so it can't clobber a real menu. Edit or delete everything from Restaurant → Menus like any other data.
Design-library rows
With the feature on, a Restaurant category appears in the row picker:
- Menu Display — your full menu rendered live (sections, dotted price leaders, dietary tags, availability windows); 86'd dishes hide automatically.
- Featured Dishes — photo cards pulled from the live menu (defaults to photo-carrying dishes), with price, description, and dietary tags.
Both are backed by data-source presets (menu_sections, menu_items) with filters for menu, section, dietary tags, and photos — so a "vegan starters" grid is a filter choice, not custom code.
Delivery zones
Optional zones under Settings → Delivery zones match customers by postal-code prefix (comma-separated codes or prefixes — "44101" or "441"; spaces/dashes/case are normalized). Each zone carries its own fee and order minimum. With any active zone defined, delivery requires a matching postal code (the checkout checks it live and the placement re-validates), and the flat fee/minimum settings are ignored; with none, the v1 flat-fee behavior holds everywhere. Orders snapshot the zone name, so zone edits never rewrite history.
Dine-in table QR codes
Turn on Settings → Dine-in ordering, set the table count, and print the generated QR sheet — one code per table, encoding /order?table=N. A guest who scans it gets the same menu with the checkout locked to Dine-in · Table N: no fulfillment choice, no delivery fee, no time picker (dine-in is always ASAP), and a "Pay at the table" option when pay-in-person is enabled. The kitchen board and ticket show the table number.
SMS status updates
With Settings → SMS order-status updates on (and Twilio configured under Marketing → Settings), checkout shows a text-me-updates opt-in. Consenting customers get a text when the order is received, ready (or out for delivery), and on cancellation — each status at most once per order, with STOP opt-outs and marketing SMS unsubscribes always honored. SMS problems never break an order.
Kitchen printer (email-to-print)
Set Settings → Kitchen printer email to your cloud printer's ingest address (Star/Epson email-to-print, or any print-forwarding service) and every new order also sends a print-shaped ticket: big monospace type, order number + fulfillment + table/address, requested time, each line with modifiers and special instructions, and PAID/COLLECT payment status. Sent exactly once per order via the same idempotency log the emails use.
The Bistro theme
Fresh installs can pick the Bistro theme (resources/themes/restaurant/) and start with a complete restaurant site instead of assembling one. Its manifest declares required_features: ["restaurant"], so installing it enables the feature, runs its migrations, materializes /order, and seeds the starter menu automatically. What it ships:
- A menu-driven homepage — full-photo hero with Order Online / View the Menu calls to action, featured dishes pulled live from the menu, an "our story" block, and a visit-us split with address and hours.
- A dedicated /menu page — the full menu rendered live, plus the shared order call-to-action band.
- Core pages — about, contact, privacy policy, terms, and a styled 404, with restaurant-flavored starter copy.
- Navigation seeded as Home / Menu / Order Online / About / Contact.
- Two variants: Bistro (Playfair Display + Lora, deep burgundy and champagne gold, a transparent header over the hero photo) and Fresh (Outfit + Inter, garden green and citrus, a clean light header). Switching variants re-skins the site and never touches content.
Limitations
- No courier integration. Zones handle fees and boundaries; the restaurant still does the driving.
- No CloudPRNT/raw ESC-POS printing. Kitchen tickets ride email-to-print; direct printer protocols aren't wired up.
- Cancelling never auto-refunds. Cancelling a paid order stops the kitchen from working it but leaves the charge in place — refunding is a separate action from the Orders board.
Technical notes (for developers)
- Module:
app/Features/Restaurant/(keyrestaurant, default OFF). Livewire namespacerestaurant; routesdashboard.restaurant.*(manager; settings admin) + publicorder.*. - Tables:
restaurant_menus,restaurant_menu_sections,restaurant_menu_items(incl.available_start/available_end/available_daysschedule columns),restaurant_modifier_groups,restaurant_modifiers,restaurant_item_modifier_group(pivot),restaurant_hours,restaurant_delivery_zones,restaurant_orders(incl.table_number,sms_consent, zone snapshot columns),restaurant_order_items,restaurant_order_email_log(also claims SMS + kitchen-ticket sends). - Hours:
Support/OrderingHours.php— pure availability math offrestaurant_hoursrows (OpeningWindowmodel); no rows = always open; all wall-clock comparisons run in the site timezone viaDateValue::siteTimezone().slotsForDate()/schedulableDates()power multi-day scheduling (restaurant.schedule_days_ahead);isValidRequestedTime()enforces the horizon. - Zones:
Models/DeliveryZone.php(postal-prefix matching, normalized) resolved inOrderPlacer::resolveDeliveryZone(); zone fee/minimum replace the flat settings intotals()/place(). - SMS:
Support/RestaurantOrderSms.php— per-status claim onrestaurant_order_email_log(sms_{status}types), opt-out suppression mirrored from booking reminders; hooked inOrderPlacer::afterPlaced()and the orders board'sadvanceStatus/cancelOrder. - Kitchen ticket:
RestaurantOrderEmails::sendKitchenTicket()(claim typekitchen_ticket), sent fromafterPlaced(). - Table QRs:
Support/TableQr.php(BaconQrCode SVG, same stack as ticketing); the widget reads the table via a Livewire#[Url]property so the response-cached /order page stays visitor-agnostic. - Cart:
Support/RestaurantCart.php— session-backed, keyed by a hash of item + sorted modifier ids + instructions; re-prices from the database on every read (a stale tab can never check out at an old price) and silently drops lines whose item/menu/modifier went unavailable. - Totals + placement:
Support/OrderPlacer.php— single source of truth for the money math (shared by the widget's live preview and the actual order write) and the one write path for both pay-at-store (STATUS_RECEIVEDimmediately) and online orders (STATUS_PENDING_PAYMENThold). - Payments:
Services/RestaurantStripe.php(sharedstripe.key/stripe.secret, ownstripe.webhook_secret.restaurant),Support/RestaurantCheckoutStarter.php(pending hold + embedded session, fingerprint reuse so reloading/order/checkoutdoesn't spawn duplicate sessions),Support/RestaurantOrderFinalizer.php(atomicpaid_atclaim shared by webhook + return page; monotonic refund recording),Http/Controllers/StripeWebhookController.php(CSRF-exempt inbootstrap/app.php). - Emails:
Support/RestaurantOrderEmails.phpthrough the sharedCampaignMailertransport with a restaurant sender override; idempotency claims inrestaurant_order_email_logso the webhook/return-page race can't double-send. - Page generator:
Support/RestaurantPageGenerator.php, invoked when the feature is toggled on;themes:capturere-snapshots the page when the feature is enabled. Only the/orderlanding page materializes — checkout, the status page, and the webhook stay module-owned (routes/order.php) because they're per-visitor, transactional pages that must never be response-cached or page-edited. - Settings keys:
restaurant.pickup_enabled,restaurant.delivery_enabled,restaurant.delivery_fee_cents,restaurant.delivery_minimum_cents,restaurant.delivery_note,restaurant.prep_minutes,restaurant.tax_percent,restaurant.tips_enabled,restaurant.ordering_paused,restaurant.closed_message,restaurant.pay_at_store_enabled,restaurant.online_payment_enabled,restaurant.currency,restaurant.email.from_name/from_email,restaurant.notify_email,restaurant.sms_updates_enabled,restaurant.schedule_days_ahead,restaurant.dine_in_enabled,restaurant.table_count,restaurant.kitchen_print_email,stripe.webhook_secret.restaurant. - Spam protection on the public form: honeypot + per-IP rate limiting (10 attempts / 10 minutes).
- Tests:
tests/Feature/Restaurant*.php(menu/modifier management, ordering hours, cart/totals math, payments/webhook, dashboard order board, page generator, andRestaurantV2Test.phpfor zones / item schedules / multi-day scheduling / dine-in QR / SMS / kitchen tickets).