← Back to journal
Laravel, React, POS

< 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.

How I Architected a Modern POS System Using Laravel, Inertia.js, React, Midtrans, and Bluetooth 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

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

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:

  1. Laravel returns a normalized receipt DTO (store name, items, totals, payment method, timestamp)
  2. A client-side encoder converts that DTO into ESC/POS byte commands
  3. navigator.bluetooth.requestDevice() pairs with the printer
  4. 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

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 qty column 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.