< journal.entry />
How I Architected a Modern POS System Using Laravel, Inertia.js, React, Midtrans, and Bluetooth Printing
A full-stack walkthrough of building a production-ready point-of-sale from Laravel APIs and Inertia.js pages to Midtrans payments and ESC/POS Bluetooth receipt printing.
Building a point-of-sale system sounds straightforward until you actually ship one. Cashiers need speed. Owners need reports. Payments must be reliable. Receipts have to print instantly often over Bluetooth, on hardware that was never designed for the web.
This is how I architected a modern POS stack using Laravel on the backend, Inertia.js with React on the frontend, Midtrans for payments, and Bluetooth ESC/POS printing for thermal receipts.
Why This Stack?
Most POS products are either legacy desktop apps or over engineered SaaS platforms. I wanted something in the middle:
- Fast UI for daily cashier workflows
- Single codebase for web admin and in-store checkout
- Indonesia ready payments via Midtrans
- Hardware support for Bluetooth thermal printers without native apps
Laravel + Inertia.js + React hit that balance. Laravel handles auth, inventory, orders, and webhooks. Inertia bridges server and client without a separate REST API layer for every screen. React delivers the interactive POS cart, product search, and checkout flow.
System Architecture
At a high level, the system splits into four layers:
- Presentation React pages rendered through Inertia.js (POS screen, product catalog, order history)
- Application Laravel controllers, form requests, policies, and service classes
- Domain Orders, line items, inventory movements, payment records, and receipt logs
- Integration Midtrans Snap/Core API, Web Bluetooth ESC/POS, and optional queue workers
- Cashier adds items to cart in React
- Inertia posts checkout payload to Laravel
- Laravel creates order + reserves stock inside a DB transaction
- Midtrans payment token/charge is generated when needed
- Webhook confirms payment and marks order as paid
- Frontend receives receipt payload and sends ESC/POS bytes to Bluetooth printer
Backend: Laravel as the Source of Truth
Laravel owns every business rule. Products, categories, stock levels, discounts, tax, and user roles all live in the backend not in client-side state that can drift.
Key design choices:
- Service classes for checkout, stock deduction, and payment reconciliation
- Database transactions so an order never commits without consistent inventory
- Policies to separate cashier, supervisor can, and owner permissions
- Webhook endpoint for Midtrans with idempotent payment status updates
The POS React app stays thin: it renders UI and sends validated payloads. Laravel decides what is allowed.
Frontend: Inertia.js + React for the POS Experience
Inertia.js removed the need to build and maintain a separate REST API for every admin screen. Laravel returns page props; React renders them. For the POS floor, that meant:
- Instant product search and barcode friendly input
- Cart state with quantity edits, notes, and discounts
- Optimistic UI where safe, with server confirmation on checkout
- Shared layouts for admin vs. cashier views
React components are organized by domain: Cart, ProductGrid, PaymentModal, ReceiptPreview, and PrinterStatus. Shared hooks handle keyboard shortcuts critical when cashiers process dozens of orders per hour.
Decimal precision for money is handled server-side; the UI only displays formatted values from Laravel to avoid floating point bugs.
Midtrans Payment Integration
For Indonesian merchants, Midtrans is the practical choice. I integrated both:
- Snap for hosted checkout flows when customers pay on a separate screen or link
- Core API for in-store scenarios where the POS triggers charge/status directly
- Create order in Laravel with status
pending - Request Midtrans transaction token from backend (never expose server keys to React)
- Open Snap modal or charge via Core API depending on payment method
- Midtrans webhook hits
/webhooks/midtrans - Laravel verifies signature, updates order to
paid, and emits receipt-ready event
- Store
order_id,transaction_id, and raw webhook payload for audit - Treat webhooks as idempotent duplicate notifications must not double charge stock
- Fail gracefully when payment expires; release reserved inventory
- Log every state transition for end of day reconciliation
Bluetooth Thermal Printing (ESC/POS)
Printing was the hardest part of the stack. Most cheap thermal printers speak ESC/POS over Bluetooth Serial Port Profile (SPP). Browsers do not expose classic Bluetooth the way native apps do so I used the Web Bluetooth API where supported.
The flow:
- Laravel returns a normalized receipt DTO (store name, items, totals, payment method, timestamp)
- A client-side encoder converts that DTO into ESC/POS byte commands
navigator.bluetooth.requestDevice()pairs with the printer- Bytes stream to the printer characteristic; cashier gets paper in seconds
- Store header + address
- Order number and cashier name
- Line items with qty × price
- Subtotal, discount, tax, grand total
- Payment method (cash, QRIS, card via Midtrans)
- Footer message and optional QR code
- Web Bluetooth works best in Chromium browsers; always provide PDF/email fallback
- Printer paper width (58mm vs 80mm) changes column layout encode both profiles
- Reconnect logic matters; Bluetooth drops during busy hours
- Test with real hardware early emulator bytes ≠ real printer behavior
Database & Inventory Model
A POS lives or dies on inventory accuracy. I modeled:
- Products with SKU, price tiers, and active/archived state
- Stock movements as ledger entries (sale, restock, adjustment) instead of mutating a single
qtycolumn blindly - Orders with polymorphic payment status and soft deletes for audit trails
- Shifts (optional) to tie cashiers to sessions and simplify daily closing reports
Every sale creates an order, order items, and outbound stock movements in one transaction. Refunds create inverse movements rather than deleting history.
What I Would Do Differently
Shipping this taught me a few lessons worth sharing:
- Start with printer compatibility tests before polishing the React UI
- Keep the POS offline tolerant cache product catalog locally for search when the network flickers
- Use queues for heavy report generation instead of blocking checkout
- Document Midtrans sandbox vs production config clearly; webhook URLs differ per environment
Closing Thoughts
A modern POS is not one feature it is orchestration. Laravel keeps business logic honest. Inertia.js and React make the cashier experience feel instant. Midtrans handles payments in a market specific way. Bluetooth printing closes the loop with a physical receipt customers expect.
If you are building something similar, optimize for the cashier first. Every extra click at checkout multiplies across thousands of transactions. Everything else reports, admin panels, integrations can iterate after the core loop is fast and reliable.
Images from Unsplash — free to use under the Unsplash License.
Adi Sulaksono