Skip to content

Architecture

DG SmartPOS is a monorepo of four applications sharing one Laravel API. Each application is its own independent git repository (separate history, separate branches), living side by side on disk:

POS/
├── dg_smart_pos_api/ # Laravel 12, central + per-tenant API
├── dg_smart_pos_admin/ # Vue 3 + Vuetify 3, central SaaS admin panel
├── dg_smart_pos_frontend/ # Vue 3 + Vuetify 3, POS terminal SPA + Electron desktop app
└── dg_smart_pos_storefront/ # Astro + Preact, customer-facing online storefront

See Applications for what each one does. This page covers the thing that shapes almost every other decision in the codebase: multitenancy.

DG SmartPOS is multi-tenant using Stancl Tenancy (stancl/tenancy ^3.9). One Laravel codebase serves every client; each client (tenant) gets an isolated database.

Database Holds
Central DB (pos_central) tenants, domains, packages, features, tenant_features, tenant_contacts, subscription history, eTIMS/Tuma configs, admin notifications
Tenant DB (one per tenant, myclient_{tenant_id}_db) users, stores, store_stocks, customers, sales, inventory, roles/permissions, receipts, everything store-operational

A tenant is identified by App\Models\Tenant. Creating one triggers a queued pipeline: CreateDatabase → MigrateDatabase (runs database/migrations/tenant/*) → SeedTenantDatabase (roles, permissions, units) → OnboardTenant (first store, admin user).

How a request knows which tenant it’s for

Section titled “How a request knows which tenant it’s for”

Every tenant-scoped API call must carry the header X-Tenant-ID: {uuid}. There is no domain-based routing for this in practice. Identification is request-header based (InitializeTenancyByRequestData, configured in AppServiceProvider::boot()).

  • routes/api.php: central routes, no tenant header needed (tenant resolution itself, Tuma callbacks, everything under routes/admin.php).
  • routes/tenant.php: tenant routes, requires the header; middleware stack is api, InitializeTenancyByRequestData, CheckSubscriptionStatus.

The two POS-facing apps set this header automatically once a tenant is resolved:

  • Frontend (POS terminal): user enters a client code or the app resolves tenant by domain, then the Axios interceptor in src/axios/index.js attaches X-Tenant-ID to every request. Tenant ID is cached in localStorage/Electron store under x-tenant-id.
  • Admin: never sends the header. It only ever talks to central resources (managing tenants, not operating as one).
  • Storefront: resolves its website/tenant server-side per request (Astro SSR), independent of the frontend’s client-side flow.

Tenants have a package and optional tenant_features overrides. CheckSubscriptionStatus middleware rejects tenant requests with codes like SUBSCRIPTION_CANCELLED or ACCOUNT_SUSPENDED (403), which the frontend’s subscription store turns into a suspension UI. Feature gating in code uses tenant()->hasFeature(...) or FeatureService.

Laravel Sanctum (auth:sanctum) for token-based auth, shared shape across Admin and POS. Inside a tenant, roles/permissions use Spatie Laravel Permission, seeded per tenant by SeedTenantDatabase.

Every store has a business_type column (retail | restaurant, default retail). Restaurant mode is not a separate app or module. It’s a configuration:

  • accompanying_stock_enabled turns on automatically; barcode/SKU/serial settings turn off at store creation.
  • Certain sidebar items hide (Warehouses, Stock Transfers, GRNs, Quotations).
  • Frontend exposes this via systemConfigStore.isRestaurantMode.

This pattern (a business type as a configuration of capabilities rather than a fork in the codebase) is also the direction the whole platform is being restructured toward. See Roadmap.