Lunch rush, table 7 orders two more iced teas, and the order never reaches the kitchen. At another table, a party of six wants separate checks and the cashier recalculates everything on a calculator. Retail POS software was not built for this. This guide covers what makes a restaurant POS different, how its data should be structured, and what to check before adopting restaurant POS source code.
Why Retail POS Falls Short
In retail, a sale happens once: scan, pay, done. In a restaurant, an order stays open for as long as guests are seated. It is opened, added to several times, sent to the kitchen in rounds, and paid at the end, sometimes by several people. For a primer on POS basics, see what a POS system is and how it works.
| Need | Retail POS | Restaurant POS |
|---|---|---|
| Transaction lifecycle | Single payment | Open tab, items added over time |
| Customer location | Irrelevant | Table number and status |
| Products | Fixed SKUs | Menu items with modifiers (size, spice level, toppings) |
| Coordination | Cashier only | Servers, kitchen, cashier |
| Payment | One bill | Merge or split checks |
| Inventory | Finished goods | Ingredients via recipes |
Core Modules
- Menu and modifiers: categories, variants, and the ability to 86 an item instantly when it runs out.
- Tables and orders: open a tab per table, add items, move guests, merge or split checks, and see table status at a glance.
- Kitchen integration: new items appear on a kitchen display or print on a kitchen printer.
- Payments and split bills: cash, card, and digital wallets, split by person or by item.
- Reports: daily sales, best-selling items, peak hours, and server performance.
- Ingredient stock: each sale deducts ingredients according to its recipe.
Recommended Schema
The key is separating orders from payments, because one order can be paid in several parts. Each item also needs a kitchen status.
CREATE TABLE orders (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
dining_table_id INT UNSIGNED NULL,
waiter_id BIGINT UNSIGNED NOT NULL,
status ENUM('open','closed','void') DEFAULT 'open',
opened_at DATETIME NOT NULL,
closed_at DATETIME NULL
);
CREATE TABLE order_items (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
order_id BIGINT UNSIGNED NOT NULL,
menu_id BIGINT UNSIGNED NOT NULL,
qty SMALLINT UNSIGNED NOT NULL,
unit_price DECIMAL(12,2) NOT NULL,
note VARCHAR(150) NULL,
kitchen_status ENUM('queued','cooking','served') DEFAULT 'queued',
payment_id BIGINT UNSIGNED NULL
);
CREATE TABLE payments (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
order_id BIGINT UNSIGNED NOT NULL,
method ENUM('cash','card','wallet') NOT NULL,
amount DECIMAL(12,2) NOT NULL,
paid_at DATETIME NOT NULL
);
CREATE TABLE recipes (
menu_id BIGINT UNSIGNED NOT NULL,
ingredient_id BIGINT UNSIGNED NOT NULL,
qty_used DECIMAL(10,3) NOT NULL,
PRIMARY KEY (menu_id, ingredient_id)
);
A payment_id on each item enables split-by-item: each guest pays for their own items, and the order closes once every item has a payment. The recipes table links menu items to ingredients.
Deducting ingredients when an order closes
Wrap stock deduction in a database transaction so a failure never leaves stock half-updated:
use Illuminate\Support\Facades\DB;
DB::transaction(function () use ($order) {
foreach ($order->items as $item) {
foreach ($item->menu->recipes as $recipe) {
Ingredient::whereKey($recipe->ingredient_id)
->decrement('stock', $recipe->qty_used * $item->qty);
}
}
$order->update(['status' => 'closed', 'closed_at' => now()]);
});
For busy venues, push this into a queued job so the cashier never waits. See Laravel queues and jobs for heavy tasks.
Peak Hours and Best Sellers
These two reports are what owners use most for staff scheduling and purchasing. Both come from closed orders:
-- Busiest hours, last 30 days
SELECT HOUR(o.opened_at) AS hour, COUNT(DISTINCT o.id) AS orders
FROM orders o
WHERE o.status = 'closed' AND o.opened_at >= NOW() - INTERVAL 30 DAY
GROUP BY HOUR(o.opened_at)
ORDER BY orders DESC;
-- Top 10 menu items
SELECT m.name, SUM(oi.qty) AS sold, SUM(oi.qty * oi.unit_price) AS revenue
FROM order_items oi
JOIN orders o ON o.id = oi.order_id AND o.status = 'closed'
JOIN menus m ON m.id = oi.menu_id
GROUP BY m.name
ORDER BY sold DESC
LIMIT 10;
For example, suppose 3 to 5 pm is consistently quiet. You could test an afternoon snack bundle and compare the following week. Items that sit at the bottom for months deserve a review of price, portion, or whether they belong on the menu at all.
A Phased Rollout
- Week one: enter menu items, prices, and tables. Train servers to open and close tabs.
- Week two: connect the kitchen via display or printer and stop paper tickets.
- Week three: enable split checks and every payment method you accept.
- Month two: fill in recipes and opening ingredient stock, then reconcile against physical counts weekly.
Phasing reduces staff confusion and makes it easier to spot which part is not working yet.
Running It Locally
- Install PHP, Composer, and MySQL at the versions listed in the README.
- Run
composer install. - Copy
.env.exampleto.env, set database credentials, and runphp artisan key:generate. - Run migrations and seeders, or import the bundled SQL.
- Start
php artisan serve, log in as admin, and test the full flow: open a table, order, send to kitchen, split, close.
Evaluation Checklist
- Items can be added after the first kitchen send without creating a new order.
- Cooked items cannot be deleted without manager approval, which closes a common fraud gap.
- Split by item and by person both work and totals still reconcile.
- Prices are stored on
order_itemsat order time, not re-read from the menu. - Sold-out items disappear from server screens immediately.
- Best-seller and peak-hour reports exist or are easy to add.
- Runs on a tablet browser with no special install.
- Follows standard Laravel structure and ships with documentation.
Common Mistakes
- Using retail POS in a restaurant: no table concept, so servers go back to paper.
- Kitchen not connected: add-on orders get lost. At least use a kitchen printer.
- Empty recipes: the ingredient module is useless. Start with your top 10 sellers.
- Untracked voids: always log who voided what, when, and why.
Limitations
Thermal printer and payment terminal integrations depend on your hardware and provider, so expect adjustment work. Offline mode is rare in web-based POS; if your connection drops often, consider running the server on the local network. Backups and server security are your responsibility. For a framework to weigh this decision, read buy or build: choosing ready-to-use source code. GudangCode's catalog also includes Laravel POS apps you can compare.