QR Menu and WhatsApp Ordering for Restaurants
Overview
MenuQR — QR Menu & WhatsApp Ordering for Restaurants
A self-hosted QR menu & WhatsApp ordering system for restaurants, cafés and cloud kitchens — built on Laravel 13 & PHP 8.4. Customers scan a table QR, browse a fast mobile menu and send their order straight to the kitchen on WhatsApp via wa.me — no app to install, no payment gateway, no monthly commission and no third-party API keys. One install runs unlimited outlets; the whole thing lives on your own server.
Live Demo — Scan, Browse, Order
Browse the customer menu and the full admin panel before you buy. The demo catalog is a real Indian restaurant — 3 outlets, 42 dishes, photos, tables and order history — so it feels like a live shop, not a skeleton.
- Live preview: https://menuqr.laravelsiteshub.com
- Customer menu: /m/{outlet} or /m/{outlet}/t/{table} — scan a table QR to include table context
- Shareable inquiry page: /i/MQ-XXXXX — fallback link when a WhatsApp message is truncated
- Read-only API: /api/outlets and /api/outlets/{code}/menu
- Admin panel: /admin/login
- Email: owner@example.com
- Password: change-me-before-seeding
Set ADMIN_EMAIL and ADMIN_PASSWORD in your .env before php artisan migrate --seed on a live install — the demo credentials above are for evaluation only. Set DEMO_MODE=true only on a staging demo; it blocks admin writes with a friendly notice while leaving the customer menu, cart and checkout fully live.
Why MenuQR — Keep 30% You Are Giving Away
A restaurant doing ₹8 lakh a month through aggregators hands over ₹1.5–2.5 lakh in effective platform take (25–35% after GST, gateway and ads). A single direct order on wa.me costs ₹0.30 in WhatsApp conversation fees instead of ₹99–135 in commission. MenuQR turns every table tent, takeaway bag and window decal into a direct order engine you own — zero commission forever, one-time licence, your server, your data.
Existing QR SaaS products charge ₹799+ per outlet per month, require Redis, queues and Pusher, and make you a billing company (plans, subscriptions, 15 payment gateways). MenuQR is the opposite: self-hosted, no payment gateway at all, no queue, no scheduler, no Redis required — it runs on the same shared hosting your WordPress already does, and every inquiry stays in your own MySQL.
How It Works
For guests — 30 seconds, no account:
- Scan the QR on the table — opens /m/{outlet} or /m/{outlet}/t/{table} in the browser. No app, no login. A bad table code shows a notice but never blocks ordering.
- Browse — sticky category chips, All/Veg/Egg/Non-veg filter, dietary and spice markers, allergen text and photos. Search matches name, description and tags, and hides empty categories as you type. Works with JavaScript disabled.
- Add to cart — instant, no reload, with an optional per-dish note (e.g. “less spicy”). The cart lives in localStorage scoped to that outlet and survives reloads and an expired session. Carts over 50 lines are rejected with a validation error.
- Tap Send order on WhatsApp — the cart is re-validated against live prices and availability, an immutable inquiry snapshot is saved with a reference code (MQ-XXXXX), and WhatsApp opens with a prefilled, itemised message — including each dish note — ready to send.
For staff — built for a phone on the floor, mid-service:
- One availability screen lists every item grouped by category — one toggle each, with bulk category toggle for “out of fish today”. Optimised for touch, with search.
- Catalog stays in sync across outlets — change a price or hide an item once, every outlet updates instantly. Per-outlet price overrides and availability without duplicating the menu.
- Tables get a QR each — add individually or in bulk, download as SVG (always) or PNG, print a ready-to-laminate A4 sheet (two cards per page, cut lines, no PDF dependency).
- Every checkout tap is saved as a read-only inquiry — time, table, items with per-dish notes, total and reference code — viewable as a dedicated detail page, filterable and exportable to CSV.
Customer Menu — Mobile-First, No App Needed
- One long menu page with sticky horizontal category chips that follow the scroll. Search filters instantly; chips remain usable without JavaScript.
- Item cards show name, description, price, dietary marker (dark green veg / brown non-veg — egg dishes carry the brown mark with a “contains egg only” note per FSSAI), spice level and tag pills (e.g. Bestseller, Chef’s Special). Unavailable items are shown disabled, not hidden.
- Item detail as a bottom sheet: option groups (e.g. Half/Full) with inline price deltas, and add-on choices. Renders as a server-side <details> block with JavaScript disabled — allergen text and option prices are never hidden behind JS.
- Handles empty states honestly — “This menu is being updated” when the catalog is empty, and a distinct “all items unavailable” banner — never a 404.
- Closed-hours handling with a friendly banner, reopen time and disabled checkout (but menu remains browsable). Two outlet flags kept independent: Published (QR shows “not available” rather than a broken page) and Accepting orders (browsable but checkout gated via closed_notice_text).
- Safe-area aware header and receipt strip — viewport-fit=cover, env(safe-area-inset-*) padding and svh/dvh units — tested at 320px against real browser chrome.
Cart & Checkout — Validated, Never Trusted
- Cart lives in localStorage (mq_cart_v1_{public_code}) — survives page reload, back navigation and a 419 session expiry. Nothing is sent while browsing; opening the cart reconciles against live prices.
- Re-validation at checkout is the source of truth — a tampered price, a deleted item or a changed option delta is caught server-side and shown as a named, dismissible notice before WhatsApp opens. Forged localStorage is rejected.
- Global charge lines above the total — flat (e.g. Packaging ₹20) or percentage via basis points (e.g. 500 = 5% of the item subtotal only, never compounding, rounded half-up inside Money and nowhere else). Reordering charge lines never changes the total — faithfully tested.
- Optional per-dish note (max 200 chars) on each item plus the order-level customer note — both flow into the WhatsApp message, the inquiry snapshot, the fallback page, the admin view and per-line display — escaped in every surface.
- WhatsApp message composed from an owner-editable template with live preview and a budget counter. A template that overflows the measured wa.me budget takes the summary-plus-link fallback to /i/MQ-XXXXX — never silent truncation.
- One-tap checkout that respects platform popup rules — same-tab navigation on touch, synchronously pre-opened tab on desktop, visible anchor fallback, pending button state, double-tap writes one row, and a persisted confirmation so returning to the tab shows the order without re-posting.
Admin Panel — Owner & Staff, Policy-Guarded
- Two roles: owner (everything) and staff (availability and viewing inquiries/analytics only). Every admin route is policy-checked — not hidden-button security — with nine dedicated permission tests.
- Availability — the single screen designed to be used standing up: every item grouped by category, one toggle each, bulk per-category toggle, optimistic updates, search, phone-first.
- Catalog — categories, items and tags with drag-to-reorder and keyboard support. Each item: name, description, price, dietary type, spice, allergen text, tags, photo and option groups (e.g. Half/Full, extra cheese). Per-outlet availability and price overrides when running multiple branches.
- Outlets — name, slug, address, phone, WhatsApp number with live wa.me link preview, seven-day hours with split shifts, independent Published and Accepting orders toggles, outlet timezone (Asia/Kolkata default).
- Media — uploads auto-optimised to modern formats (WebP) via a three-tier pipeline: Imagick → GD → passthrough. Works even when neither image library is present. Spoofed extensions rejected by real MIME via fileinfo, SVG rejected by content sniff — never the client-supplied extension.
- Inquiries — read-only log of every checkout tap with time, outlet, table, items (including per-dish notes), totals and reference code, each with a dedicated detail page; permanent disclaimer that these are checkout taps, not confirmed orders; filterable by outlet and date; exportable to CSV.
- Dashboard — today’s menu views, cart adds and inquiries with two-week sparklines (labelled indicative, not audited). Menu-view de-duplicated per session (ten reloads = one row).
- Settings — business identity, branding colour, currency (code, symbol, position — prefix/suffix), GST display note (free-text, shown below the total — never a computed tax), charge lines, WhatsApp template editor, data-retention periods and pruning.
- Import / Export — bulk-edit items as CSV (flat, items-only) with mandatory dry-run preview before anything is written; or back up the full nested catalog as JSON (categories, items, option groups, options, per-outlet overrides, tags, media metadata) — images listed, not embedded. On import, missing image files are reported by name.
- Users — owner sets staff passwords directly; no invitation email to lose. Forgot-password link intentionally absent — mail cannot be assumed on shared hosting; recovery via php artisan menuqr:reset-password {email} over SSH — a reset signs out every other session, exactly like changing the password from the profile screen. All password forms enforce a 10-character minimum; repeated failed logins are throttled by IP and by email distinctly.
Tables & QR — One QR Per Outlet, One Per Table
- Add tables individually or in bulk; each gets a 10-char Crockford base-32 public_code (e.g. TFKXFWJH64), never a guessable auto-increment.
- QR codes via bacon/bacon-qr-code — the only QR dependency, no Endroid wrapper. Download as SVG (always crisp for print) or PNG when GD is available; per-outlet and per-table sheets ready for A4 print — two cards per page with cut lines, no PDF library required.
- Host-mismatch banner when APP_URL does not match the admin domain, with one-click regenerate. Canonical-host middleware keyed by APP_URL — scheme is upgraded never downgraded (so adding SSL without updating the setting does not redirect-loop); www ↔ apex follows the setting; trusted proxies configured for TLS terminators.
- Every scan-time state has a designed page — unpublished outlet, inactive table, unresolvable code, changed domain — never a 404, never a stack trace.
Multi-Outlet — One Catalog, Every Branch
- Shared catalog serves every outlet; each outlet can hide items or set its own prices without duplicating the menu. The outlet switcher hides entirely on single-outlet installs.
- Hours per weekday with split shifts and timezone; is_published vs is_accepting_orders are independent — a closed-for-lunch outlet still shows the menu but replaces checkout with the owner’s closed_notice_text.
- Restoring a soft-deleted outlet backfills pivot rows for items added while it was gone — symmetric with item restoration.
- A second outlet can be added later and existing items appear there as unavailable until explicitly enabled — no silent surprises.
WhatsApp Ordering — wa.me Only, No Business API
- Integration is a wa.me deep link only — no WhatsApp Business API, no app approval, no webhook, no message-delivery status. No credentials to obtain, no keys to rotate, no SDK to expire.
- Phone validation is strict: digits only, country code included, pattern ^[1-9]\d{6,14}$ — previewed live as a wa.me link in the outlet editor.
- Message shows quantities, option names, price deltas, per-dish notes, subtotal, charge lines and order note, with a reference code for both sides. Long-message fallback renders the same snapshot at /i/{publicCode} — keyed on the random code, never the sequential id.
- The cart page refreshes its CSRF token on load, and the customer session lasts twelve hours — matching the signed mq_session cookie — so a table that scans on arrival and orders two and a half hours later does not hit an unstyled 419. A deliberately expired session renders a branded, translated 419 while the cart remains intact after reload.
Read-Only API — Same Payload the Menu Renders
Public, unauthenticated, read-only JSON — useful for a custom front end or native app. The endpoints serve the identical cached payload the menu page renders (one builder, two consumers — asserted equal on every test run).
- GET /api/outlets — published outlets ordered by sort_order (empty state is {"data":[]}, no 404; internal id never exposed)
- GET /api/outlets/{code}/menu — full menu payload for one outlet: outlet identity, currency, charge lines, categories with items, option groups, tags and media. price_minor / from_price signal “From ₹X” correctly when a required option raises the minimum. Images may be null — render public/img/no-image.png like the native page.
- Non-GET on these routes is 405; unknown or unpublished {code} is 404 with a byte-identical body so the response cannot be used to probe which codes are real.
- No pagination, no {success, data, message} envelope — /menu returns the payload directly; the listing returns {"data":[…]}. Error bodies carry {"message":…} only.
SEO Built In
- Clean slugs with per-outlet and per-category uniqueness enforced by a stored generated column (slug_active_key) — two live rows can never share a slug; a deleted row’s slug can be reused; restoring the deleted row into a now-taken slug fails with a caught, explained error.
- Outlet and menu pages are server-rendered Blade with semantic headings, real <p> and <ul> structure — readable without JavaScript and indexable without a headless pass. Menu search reads plain text, not raw HTML, so injected markup never leaks into the index.
- Shareable inquiry fallback at /i/MQ-XXXXX is noindex, rate-limited and non-enumerable (sequential id never resolves).
- Correct canonical-host handling — single source of truth from APP_URL with asymmetric upgrade (https upgrades, never downgrades) so domain moves do not create duplicate content.
Security Hardening
- Owner-authored fields (item description, allergen text, category and tag names, outlet notes, customer note) containing <script> render as escaped text — Blade {{ }} escaping on every customer-facing surface, never {!! !!} — verified by adversarial-input tests.
- Uploaded images validated by real MIME type via fileinfo (mimes: + mimetypes: backed by Symfony’s guessExtension()), never the client-supplied extension; explicit SVG/XML rejection (image/svg+xml plus <svg content sniff) — SVGs served same-origin could steal session/CSRF cookies.
- All password forms reject under 10 characters; changing a password is done from the authenticated profile screen. The login page carries no forgot-password link by design — recovery is the owner-only SSH command php artisan menuqr:reset-password {email}.
- Policy checks on every admin route plus a gate short-circuit — defence in depth, not a single guard. Staff cannot reach owner-only mutations even by direct URL.
- CSRF split by surface — analytics beacon (POST /api/events) is CSRF-exempt and defended by a signed, server-verifiable mq_session token plus rate limiting; checkout (POST /m/{code}/checkout) retains full CSRF.
- Rate limiting keyed on the session/token, not the raw IP — forty distinct sessions from one IP (a full house behind one NAT) all succeed; one session posting six times in a minute is throttled. Inquiry fallback and analytics have independent limits.
- Escaped LIKE queries; zero raw SQL anywhere — grep-guarded. No dropColumn/renameColumn migrations without an explicit acknowledgement comment; migrations additive only.
Performance — Fast on Shared Hosting
- Per-outlet menu payload cached on the database driver (menu:v2:outlet:{id}) — cold ~6 queries regardless of menu size, warm one cache read. Observer-driven invalidation with an enumerable blast radius: shared-catalog writes invalidate all outlet keys, per-outlet writes invalidate one. Cache::flush() and Cache::tags() never appear anywhere.
- Settings cached for 24 hours and fail open when the database is unreachable, so a transient DB hiccup does not become a 500 on every menu load.
- Composite indexes on publish state, outlet hours and menu inquiry reporting; eager loading on every query that feeds a view or API resource — menu query-count is pinned by a dedicated test cold and warm.
- Three-tier image handling — Imagick → GD → passthrough — so uploads work regardless of what the host has installed; deleting the storage:link symlink degrades images to a styled placeholder, never a broken-image icon or a 500.
- Money is always minor units (paise) — Money::ofMinor() and Money::percentage() (basis points, half-up rounding, integer arithmetic only) — no float ever touches a monetary value, and rounding happens inside Money and nowhere else.
- No asset-time CDN, no queue worker, no scheduler, no Redis required at runtime. Tailwind via Vite ships as static files; Blade + Alpine.js render server-side out of the box.
Code Quality
| Test suite | 1099 passing, 5089 assertions — MySQL/MariaDB only. SQLite is not supported anywhere, including tests — the schema relies on generated columns, JSON columns and enums that SQLite silently ignores. |
| Drivers verified | Green on both CACHE_STORE=redis / SESSION_DRIVER=redis (default phpunit.xml) and CACHE_STORE=database / SESSION_DRIVER=database — RefreshDatabase leakage between drivers flushed via Cache::flush() in tests/TestCase.php. |
| Static analysis | Larastan level 8 — zero errors (phpstan.neon scans app/). Clean since S0. |
| Code style | Laravel Pint — clean. CI runs composer guards (7 bash + 1 PHP guard) on every slice. |
| Guards enforced | Zero env() outside config/; no closure routes (route:cache-safe); no Cache::flush()/Cache::tags(); no destructive migration without acknowledgement; no bare customer-facing string outside __(); no sed/regex patching of Blade. |
Requirements
- PHP ^8.4 and Composer — product floor is 8.4 (Laravel 13 advertises 8.3; Symfony 8 deps from 13.3+ need 8.4; config.platform.php pins to 8.4)
- MySQL 8+ or MariaDB 10.6+, database created with utf8mb4_unicode_ci
- SQLite is not supported — the schema relies on MySQL generated columns (slug_active_key), JSON columns and enums
- PHP extensions: pdo_mysql, mbstring, openssl, json, tokenizer, xml, xmlwriter, fileinfo, ctype, curl — imagick, gd, zip and exif degrade gracefully
- Shell access: SSH or cPanel Terminal; Node 20+ with npm only on the developer’s build machine to build public/build/ (npm install && npm run build) — not required at runtime; if the target host has no Node, build locally and upload public/build/
- Not required at runtime: SMTP/mail service, cron, Redis, queue worker or any API key. Optional mail defaults to the log driver.
What Is Included
- Full Laravel 13 source (no vendor/ or node_modules/ in the archive — deployer runs composer install on the host so the lockfile resolves against that host), plus tests/ and phpunit.xml so the “1099 tests pass” claim is buyer-verifiable.
- Migrations and seeders: first owner account plus optional demo catalog (SEED_DEMO_DATA=true — 3 outlets, 42 Indian dishes with photos, dining tables, charge lines, hours and sample inquiry history).
- Docs: docs/installation.md (both document-root layouts, fallback .htaccess verification, troubleshooting, password recovery, domain-change reprint note), docs/feature-walkthrough.md, docs/api.md, docs/customisation.md, docs/upgrade.md, CHANGELOG.md.
- Clean release archive — no vendor/, node_modules/, .env, bootstrap/cache/*.php, storage/logs/* or public/storage symlink in the zip; .env.example complete; public/img/no-image.png placeholder present; WebP demo images at thumbnail scale.
What This Is Not — Read Before You Buy
- Does not process payments, collect money or handle refunds. No gateway, no PCI, no webhooks.
- Does not compute tax — GST is a note you write yourself. A rate change never makes your menu wrong.
- No third-party API requiring credentials — no Maps, Algolia, Twilio, WhatsApp Business API, S3 or external QR service. WhatsApp is a wa.me deep link only.
- No order status lifecycle — no pending/preparing/delivered, no kitchen display. Inquiries are checkout taps and the listing says so plainly.
- No customer accounts — no login, registration or saved addresses; the cart lives in the browser.
- No multi-tenancy — one business per installation.
- No runtime CDN — assets ship as Vite-built static files, not a CDN.
- No required queue worker or scheduler — you already read this twice because it matters on shared hosting.
- Installation requires a developer with SSH/terminal. A paid installation service is available for restaurant owners without a developer.
Frequently Asked Questions
Do guests need to install an app or create an account?
No — the menu is a mobile-first web page. Scan → browse → add → WhatsApp. No app, no registration.
Does it need the WhatsApp Business API or any API key?
No. It opens https://wa.me/{whatsapp_number}?text={message} in the guest’s own WhatsApp. No Business API account, no webhook, no per-message fee.
Can one installation run multiple outlets with different prices?
Yes — one catalog, N outlets. Each item can be hidden per outlet or given a per-outlet price override without duplicating the row.
Does it work on shared hosting in India?
Yes — MySQL 8 or MariaDB 10.6+, PHP 8.4, no queue, no Redis. Optional mail defaults to log so no SMTP account is needed. Image uploads work even without Imagick/GD.
What about large carts — will WhatsApp truncate the message?
The message is measured against a tested budget; overflow renders a short summary plus a shareable fallback link at /i/MQ-XXXXX showing the same immutable snapshot.
Is the cart lost if the session expires during dinner?
No — the cart is in localStorage. A twelve-hour public session matches the signed mq_session token, the cart page refreshes its token on load, and an expired-session 419 reloads with the cart intact.
What is in the download — are food photos included?
Ten WebP demo photos at thumbnail scale are included and credited; the JSON export carries media metadata but not image bytes — copy storage/app/public alongside the JSON. Full install, upgrade and customisation docs are in the archive.
Changelog
v1.0.2
Latest Sep 24, 2026v1.0.1
Sep 14, 2026v1.0.0
Sep 01, 2026QR Menu and WhatsApp Ordering for Restaurants
One-time payment · Lifetime license — ownership stays with us
Secure checkout via Razorpay
Details
Need help launching it?
We handle deployment, SSL, and production setup as a paid service, quoted separately from this purchase.
Get a quote →