Skip to content

Backend Architecture Overview

The Upvendo backend is a Laravel 13 application backed by MongoDB, Redis, and a queue system. It serves as the central API for all Upvendo products: the Backoffice dashboard, the Kiosk ordering application, the Online Ordering (Zestidoo) platform, the Kitchen Display System, and third-party integrations.


Technology Stack

ComponentTechnology
FrameworkLaravel 13 (laravel/framework v13.x, PHP 8.4)
DatabaseMongoDB (via mongodb/laravel-mongodb)
CacheRedis (CACHE_STORE=redis, REDIS_CLIENT=predis)
QueueMongoDB -- queue connection database with driver => 'mongodb' (QUEUE_CONNECTION=database)
AuthenticationCustom JWT + OTP + Passkey (WebAuthn)
Image StorageCloudflare Images
SMSSMS service (for OTP)
Push NotificationsFirebase Cloud Messaging (FCM)
Payment ProvidersStripe, Viva Wallet
Third-Party POSSquare, Kassanet, Hendrickx, Vanhoutte, Shopify
DeliveryUberEats, Deliveroo
MonitoringSentry, custom health checks

Directory Structure

The application follows Laravel conventions enhanced with domain-driven organization.

upvendo-backend/
  app/
    Console/Commands/         # Artisan commands (including DataRepair/)
    Constants/                # Application constants (Permissions, Roles, ...)
    Contracts/                # Interfaces and contracts
    Data/                     # Plain data carriers (InventoryHistoryData, ...)
    DTOs/                     # Domain DTOs (Commission/, FieldOps/, Reseller/)
    Enums/                    # PHP enums (DiningOptions, OrderStatuses, etc.)
    Events/                   # Domain events (DashboardInvalidated, ReloadMenu, ...)
    Exceptions/               # Domain exceptions (carry their own HTTP status)
    HealthChecks/             # Health check implementations
    Helpers/                  # Utility helper functions
    Http/
      Controllers/Api/        # API controllers organized by domain
        BackOffice/            # Backoffice controllers (+ Settings/)
      Middleware/              # HTTP middleware (auth, tenant, permissions)
      Requests/                # Form request validation classes by domain
      Resources/               # API resource transformers by domain
    Jobs/                     # Queued jobs
    Kassanet/                 # Kassanet integration (Factories/, Models/)
    Listeners/                # Event listeners (auto-discovered in bootstrap/app.php)
    Logging/                  # Monolog handlers/processors (Discord, secret redaction)
    Models/                   # Only Ledger/ (Eloquent, `ledger` connection) + User.php alias
    MplusKassa/               # Generated MplusKassa SOAP client classes
    Providers/                # Service providers
    Queue/                    # MongoDB queue driver + failed-job provider
    RawFactories/             # Factory classes for creating domain objects
    RawModels/                # Domain model classes (plain PHP objects)
      SubModels/              # Nested value objects
      Traits/                 # Shared model traits
    Repositories/             # Repository pattern implementations
      Traits/                 # Reusable repository traits
    Rules/                    # Custom validation rule classes
    Services/                 # Business logic layer (see below)
    Session/                  # MongoSessionHandler
    Support/                  # PriceConverter, CurrencyNormalizer, SecretRedactor, ...
    Tools/                    # Tool abstraction (CircuitBreaker, IdempotencyStore, ...)
    Traits/                   # Application-wide traits
    Transformers/             # Data transformation utilities
    ValueObjects/             # Money, HealthResult
  config/                     # Laravel configuration
  database/                   # Migrations and seeders
  resources/
    lang/                     # Internationalization files
    views/                    # Blade templates (emails, PDFs, notifications)
  routes/
    api/                      # Modular API route files
      backoffice/             # Backoffice-specific routes
      dev/                    # Developer-only routes
      reseller/               # Reseller portal routes
      field.php               # Field Ops rep app routes
      guest.php               # Unauthenticated routes
    api.php                   # Main API route aggregator
    channels.php              # Broadcast channels
    console.php               # Closure-based console commands
    web.php                   # Web routes
  tests/
    Feature/                  # Feature tests
    Unit/Services/            # Unit tests for services

app/Models/ is not the domain model layer. It holds only Ledger/ (Eloquent models on the separate ledger connection, used by the commission/payout subsystem) and app/Models/User.php, which is a bare alias: class User extends RawUser {}. All ~95 domain models live in app/RawModels/, with their factories in app/RawFactories/ and their persistence in app/Repositories/ -- the repositories operate on RawModels, not on App\Models.


Service-Orchestrator Pattern

The backend uses a Service-Orchestrator pattern to organize business logic. This is the most important architectural pattern in the codebase.

Services

Services are single-responsibility classes that handle CRUD operations and domain-specific logic for one entity type. They live in app/Services/ and are organized by domain.

Naming convention: {Entity}Service.php

Example: app/Services/BackOffice/ItemService.php handles item creation, updating, deletion, and querying.

Services should:

  • Handle operations for a single domain entity
  • Contain business rules specific to that entity
  • Call repositories for database access
  • Not orchestrate complex multi-entity workflows

Orchestrators

Orchestrators coordinate multiple services to handle complex workflows that span multiple entities or require multi-step operations. They live in app/Services/Orchestrators/.

Naming convention: {Entity}Orchestrator.php or {Domain}Orchestrator.php

Not every orchestrator fans out widely. Many are thin -- ItemOrchestrator injects exactly two collaborators (ItemService, PermissionService) and mostly gates on a permission before delegating; MenuOrchestrator injects DisplayGroupService + MenuService:

Controller (receives HTTP request)
  -> ItemOrchestrator
      -> PermissionService (permission gate)
      -> ItemService (item CRUD)

Example of a genuinely multi-service workflow -- online-ordering checkout:

app/Services/Orchestrators/OnlineOrderingOrchestrator.php is the real fan-out case. Its constructor injects 16 collaborators: GeocodingService, InventoryService, KitchenDisplayService, LoyaltyService, OnlineOrderingService, PaymentService, TransactionService, OfferService, CustomerService, GiftCardTemplateService, GiftCardService, OrderCapacityService, RoutesDistanceService, PaymentCaptureService, ReceiptService, TransactionLifecycleLogService.

Orchestrators Directory

app/Services/Orchestrators/
  AppUpdateOrchestrator.php
  CustomerOrchestrator.php
  DynamicConstantOrchestrator.php
  GenerateDescriptionOrchestrator.php
  InvoiceCollectionOrchestrator.php
  KioskOrchestrator.php
  MplusKassaWebhookOrchestrator.php
  OnlineOrderingOrchestrator.php
  PosBusinessDayCloseOrchestrator.php
  PosOrderOrchestrator.php
  PosRefundOrchestrator.php
  PrinterPairingOrchestrator.php
  StripeWebhookOrchestrator.php
  VivaWebhookOrchestrator.php
  BackOffice/
    BootstrapOrchestrator.php
    CustomerOrchestrator.php
    DeliverooIntegrationOrchestrator.php
    DeviceOrchestrator.php
    DeviceProfileOrchestrator.php
    InHouseSettingsOrchestrator.php
    ItemOrchestrator.php
    MenuOrchestrator.php
    QrOrderingOrchestrator.php
    ShopCaisseIntegrationOrchestrator.php
    ShopifyIntegrationOrchestrator.php
    UberEatsIntegrationOrchestrator.php
    Settings/
      BrandingProfileOrchestrator.php
      LocationOrchestrator.php
      PaymentProfileOrchestrator.php
      TranslationOrchestrator.php
  FieldOps/
    ActivateStandOrchestrator.php

Two more orchestrators live outside app/Services/Orchestrators/:

  • app/Services/MplusKassa/OrderSyncOrchestrator.php
  • app/Services/Subscription/SubscriptionOrchestrator.php

That is the complete set of 33 files (find app -name '*Orchestrator.php'). Most domains -- categories, display groups, inventory, modifiers, offers, variant groups, tax rates, loyalty, table sections, languages, activity logs, billing profiles, receipt settings -- have no orchestrator; their controllers call the service directly.

Request-Resource Pattern

The HTTP layer uses two complementary classes:

  • Requests (app/Http/Requests/): Validate incoming data. Each domain has its own folder of request classes.
  • Resources (app/Http/Resources/): Transform models into JSON API responses. Mirror the request folder structure.

Controllers should be thin -- they validate via Request classes, delegate to Orchestrators/Services, and transform via Resource classes.


Multi-Tenant Architecture

Upvendo is a multi-tenant SaaS platform. Each vendor (merchant) has its own MongoDB database for tenant-specific data, while shared data lives in a central upvendo database.

Database Connections

Defined in config/database.php:

ConnectionPurpose
mongodbCentral/shared database (vendors, users, locations, roles)
tenantPer-vendor database, set dynamically at runtime

The tenant connection has database: null in the config -- it is populated at runtime by the SetTenantDatabase middleware.

How Tenant Switching Works

  1. JWT token contains tenant_database in its payload.
  2. SetTenantDatabase middleware extracts the tenant database name and calls setTenantDatabase() to configure the tenant connection.
  3. All subsequent queries on the tenant connection target that vendor's database.

The middleware handles three routing strategies:

TypeStrategy
backofficeExtract tenant_database from JWT payload
kiosk / kdsExtract tenant_database from JWT payload
online-orderingLook up tenant from request slug parameter
customerLook up tenant from request locationId parameter

Data Separation

A CollectionsOptimization command consolidated the original one-collection-per-entity layout into a handful of polymorphic collections. The physical collection name usually does not match the model name -- read CONNECTION / COLLECTION / MODEL off the RawModel before writing a query, and always filter on the model discriminator.

Central database (mongodb connection):

  • users -- User, Customer, CustomerMerchant, Device
  • vendors -- Merchant records (contains database_name field)
  • locations -- Location, ThirdPartyIntegration (+ every provider subclass), CustomerAddress, CustomerMerchantAddress
  • settings -- PaymentProfile, AllowedIpSetting, GuidedSetupVideoSetting
  • roles -- Permission roles
  • subscriptions -- Subscription
  • tax_rates -- TaxRate, TaxRateCustom
  • logs -- FCMToken, RequestLog, KioskAppUpdate
  • single-model collections: resellers, representatives, stands, stand_batches, field_invites, field_sessions, mplus_relations, notifications, merchant_daily_stats, job_metrics, media_attachments, stuck_transactions, wallet_device_registrations, restaurant_suggestions, crm_lead_intakes, odoo_erasure_requests, purchase_logs, subscription_logs, vendor_ai_credit_logs, caches

Tenant database (tenant connection):

  • menus -- Item, Menu, Modifier, ModifierGroup, DisplayGroup, VariantGroup, VariantOption, Offer, GiftCard, ComboDefinition, UpsellGroup
  • settings -- Category, Language, DeviceProfile, BrandingProfile, BillingProfile, TableSection, Loyalty, Reward, BirthdayBonus, DomainProfile, GiftCardTemplate, InactivePaymentProfile, TranslationJobProgress, QrCodeJobProgress, KdsStation (kds_station), KdsProfile (kds_profile), PrinterProfile (printer_profile), PosLayout (pos_layout)
  • transactions -- Transaction, TransactionItem, OrderSession, ThirdPartyTransaction, Invoice, SplitPaymentSession, LoyaltyPoint, PointRedemption, RewardRedemption, OfferRedemption
  • inventories -- Inventory, InventoryHistory
  • activity_logs -- ActivityLog, Timeline, DeviceLog, QueueCounter, CustomerMerchantToken
  • contents -- Content (media/image records)
  • POS collections (first-party POS wave): pos_tills -- Till, pos_registers -- PosRegister, pos_drawers -- PosDrawer, pos_business_days -- PosBusinessDay, pos_fdm_devices -- FdmDevice, pos_staff_credentials -- PosStaffCredential

There are no transaction_items, branding_profiles, billing_profiles, customer_vendors, loyalty, offers, devices, device_profiles, payment_profiles or customers collections. Note also that Device and Subscription are central, not tenant.

The Merchant Model and Tenancy

The merchant model is App\RawModels\Merchant (app/RawModels/Merchant.php). The class was renamed from Vendor; the storage was not -- it still declares COLLECTION = 'vendors' and MODEL = 'vendor', and the foreign key stored on user and location documents is still vendor_id. There is no app/RawModels/Vendor.php.

Merchant uses the TenancyTrait and has a database_name field. When accessing tenant-specific data through a Merchant, it automatically switches the tenant database:

php
// app/RawModels/Merchant.php -- sets the tenant DB before querying related data
private function setTenantDb(): void
{
    if (config('database.connections.tenant.database') !== $this->database_name) {
        $this->setTenantDatabase($this->database_name);
    }
}

Authentication Layers

The backend supports multiple authentication mechanisms, each targeting a different consumer type.

JWT Authentication

The primary auth mechanism. The JwtService issues and validates JWT tokens. Tokens contain:

  • User identity (user ID, vendor ID)
  • Tenant database name
  • Role/permission claims

Middleware: JwtAuthenticate

OTP (One-Time Password)

Used as a second factor for backoffice login. The User model tracks:

  • otp -- Current OTP code
  • last_otp_request -- Timestamp of last OTP request
  • resend_counter -- Rate limiting counter

Passkey / WebAuthn

Modern passwordless authentication. The User model stores:

  • webauthn_challenge -- Current challenge for registration/authentication
  • passkeys -- Array of registered passkey credentials

Device Authentication

Used by Kiosk devices. The AuthDeviceService handles device-specific auth flows. Devices authenticate via the CapacitorApiKey middleware header.

Trusted Devices

The User model maintains a trusted_devices array. Trust is based on IP + User-Agent fingerprinting with a 30-day expiration.

Challenge Tokens

For sensitive operations, the system issues time-limited challenge tokens. The User.isValidChallengeToken() method verifies these.


Middleware Stack

Located in app/Http/Middleware/. Aliases are declared in bootstrap/app.php ($middleware->alias([...])) -- that map, not this table, is the authority on which route string maps to which class.

MiddlewareRoute aliasPurpose
JwtAuthenticateauthValidates JWT bearer token and sets authenticated user
OptionalJwtAuthauth.optionalOptional JWT -- sets user if token present, continues otherwise
Authenticateauth.sanctumLaravel's built-in authentication (kept as an option)
TokenTypetypeValidates token type matches expected type
SetTenantDatabasetenantSwitches MongoDB tenant connection based on JWT or request
CheckPermissionpermissionCASL-style permission checking against user roles
CheckUserActivitycheck-user-activityTracks user activity
CapacitorApiKeycapacitor.authValidates API key header for Capacitor (mobile) clients
SuperAdminsuper-adminRestricts to super-admin users
GlobalAdministratorglobal-adminRestricts to global administrators
AdminVendorOverrideadmin-vendor-overrideLets an admin act in another merchant's context
EnsureLocationBelongsToMerchantlocation-ownerCross-tenant guard on {locationId} route parameters
EnsureGlobalFieldOpsAuthorityfield-ops-authorityGuards the global Field Ops surface
VerifyAdminSwitchIpverify.admin-switch-ipIP guard on admin merchant switching
VerifyTurnstileverify.turnstile[:configKey]Cloudflare Turnstile check against the widget secret named by the optional arg -- fails open when that secret is empty (see below)
E2EAuthMiddlewaree2e.authE2E test verification
VerifyDeliverooWebhookverify.deliveroo-webhookValidates Deliveroo webhook signatures
VerifyShopifyWebhookverify.shopify-webhookValidates Shopify webhook signatures
VerifySquareWebhookverify.square-webhookValidates Square webhook signatures
VerifyUberEatsWebhookverify.uber-eats-webhookValidates UberEats webhook signatures
VerifyShopCaisseWebhookverify.shopcaisse-webhookValidates ShopCaisse webhook signatures
VerifyMplusKassaWebhookverify.mpluskassa-webhookValidates MplusKassa webhook signatures
VerifyLightspeedKSeriesWebhookverify.lightspeed-k-series-webhookValidates Lightspeed K Series webhook signatures
VerifyCrmWebhookverify.crm-webhookValidates CRM webhook signatures
VerifyVivaWebhookIpverify.viva-webhookIP-based verification of Viva Wallet webhooks

Globally applied (no alias, registered in bootstrap/app.php):

MiddlewarePositionPurpose
AssignRequestIdprependCorrelation id on every log line and dispatched job
SecurityHeadersappendSecurity response headers on all requests
LocaleappendSets application locale from request

app/Http/Middleware/IpWhitelist.php still exists on disk but is dead -- it has no alias in bootstrap/app.php and no route references it.

verify.turnstile -- per-widget secret, and it fails open

VerifyTurnstile::handle() takes an optional third argument naming which key under services.turnstile holds the secret to verify against: handle(Request $request, Closure $next, string $configKey = 'secret') (app/Http/Middleware/VerifyTurnstile.php:26), read as config("services.turnstile.{$configKey}") (:28). So one middleware backs several independent Cloudflare widgets. Both registrations live in routes/api/guest.php:

RouteMiddleware stringConfig key read
POST /back-office/register (routes/api/guest.php:25-26)verify.turnstile (bare -- default arg)services.turnstile.secret (config/services.php:367)
POST /customer/send-otp (routes/api/guest.php:110-111)verify.turnstile:storefront_secretservices.turnstile.storefront_secret (config/services.php:373)

It fails open, per widget. The empty-secret check runs before the token is examined:

php
// app/Http/Middleware/VerifyTurnstile.php:32-34
if ($secret === '') {
    return $next($request);
}

If the selected key resolves to an empty string, the request passes through with no Turnstile check at all -- no token required, no siteverify call, no 403. Because each route selects its own key, the two are independent: one surface can be enforcing while the other passes through.

Only when the selected secret is non-empty does it fail closed:

  • missing cf-turnstile-response input -> 403 with error.code = CAPTCHA_REQUIRED (:36-43)
  • siteverify unreachable (ConnectionException, 5s timeout) or the response is unsuccessful / success !== true -> 403 with error.code = CAPTCHA_INVALID (:51-70)

Both keys come from env (TURNSTILE_SECRET_KEY, TURNSTILE_STOREFRONT_SECRET_KEY), so whether either route is actually protected is deployment configuration -- the repository cannot tell you that for any environment. When debugging "the captcha isn't blocking anything", check the resolved config value for that specific key, not the other one.

Log redaction: one catalogue, App\Support\SensitiveKeys

Two redactors used to carry their own hard-coded key lists and silently diverged: the Monolog-facing SecretRedactor knew pin / clock_pin / manager_pin / insz / national_number, while the sanitiser guarding the write into the global, cross-tenant logs collection on every 500 knew only password/token/secret/…/ssn. A 500 on POST /pos/staff-credentials therefore wrote the submitted POS PIN and the operator's Belgian national number verbatim into a long-lived, backed-up, cross-tenant store.

Both consumers now derive from one catalogue so the lists cannot drift again. Controller::shouldMaskKey() delegates to it.

Matching is deliberately two-mode — do not "simplify" it to a substring check:

ModeBehaviourUsed for
MATCH_FRAGMENTMatches anywhere inside the candidate, separator- and case-insensitively (_ also matches - or nothing)Names unambiguous enough that a coincidental substring is implausible — employee_id covers employeeId, token covers device_token
MATCH_WORDMust appear as a whole word (split on separators and camelCase humps, plus a trailing s)The ambiguous short tokens pin and key

The word mode exists to dodge a real trap: as a fragment, pin would redact shipping_address and topping_ids, and key would redact monkey — each quietly gutting the request log's diagnostic value with no error. As words they still cover manager_pin, clockPin, pin_hash, secret_key, apiKey and the plurals.

Three consumption contexts match differently: IN_PAYLOAD (an array key in a decoded payload or header map), IN_QUERY (?key=value inside a string) and IN_JSON ("key":"value" inside a string). Per-context membership is preserved from the two lists it replaced — including SecretRedactor's deliberate exclusion of bare key / username from the JSON matcher — and is pinned by tests in both directions.

Typical Middleware Chain for a Backoffice API Request

JwtAuthenticate (auth)
  -> TokenType (type:backoffice)
  -> SetTenantDatabase (tenant:backoffice)
  -> CheckUserActivity (check-user-activity)
  -> CheckPermission (permission:*, applied per route)
  -> Controller

Typical Middleware Chain for a Kiosk API Request

capacitor.auth is on the kiosk group, not the backoffice group:

JwtAuthenticate (auth)
  -> CapacitorApiKey (capacitor.auth)
  -> TokenType (type:kiosk)
  -> SetTenantDatabase (tenant:kiosk)
  -> Controller

Typical Middleware Chain for an Online Ordering Request

SetTenantDatabase:online-ordering -> Controller

Key Services by Domain

BackOffice Domain (app/Services/BackOffice/)

Core CRUD services for merchant management:

ServicePurpose
CategoryServiceCategory CRUD
ContentServiceMedia/content management
CustomerServiceVendor-specific customer management
DeviceServiceKiosk device management
DeviceProfileServiceDevice configuration profiles
DisplayGroupServiceMenu display grouping
InHouseSettingsServiceIn-house/dine-in settings
ItemServiceItem/product CRUD
LoyaltyServiceLoyalty program management
MenuServiceMenu CRUD and availability
ModifierGroupServiceModifier group management
ModifierServiceIndividual modifier management
OfferServicePromotional offer management
OnlineOrderingServiceOnline ordering configuration
OnlineSettingsServiceOnline platform settings
OrderingChannelServiceChannel (kiosk, online, QR) configuration
QrOrderingServiceQR/table ordering settings
TableSectionServiceRestaurant table section management
TaxRateServiceTax rate configuration
TransactionServiceTransaction history and reporting
VariantGroupServiceProduct variant management

Settings sub-domain (app/Services/BackOffice/Settings/):

ServicePurpose
ActivityLogServiceActivity/audit log
BrandingProfileServiceVisual branding (colors, logos)
GuidedSetupVideoServiceGuided-setup video settings
PaymentProfileServicePayment provider configuration
ReceiptSettingServiceReceipt formatting and content
TeamServiceTeam member management
TranslationServiceMulti-language translation management

Third-Party Integration Services

ServicePurpose
DeliverooServiceDeliveroo integration
KassanetIntegrationServiceKassanet POS integration
ShopifyIntegrationServiceShopify product sync
SquareIntegrationServiceSquare POS integration
SquareUpServiceSquare data sync
UberEatsServiceUberEats integration

Payment Domain (app/Services/Payment/)

ServicePurpose
PaymentServicePayment processing orchestration
PaymentCaptureServicePayment capture/settlement
WebhookServicePayment webhook handling

Common/Shared Services (app/Services/Common/)

ServicePurpose
AppUpdateServiceKiosk app version management
CloudflareImageServiceCloudflare Images upload/retrieval
FCMServiceFirebase push notifications
GeocodingServiceAddress geocoding
HendrickxServiceHendrickx supplier integration
SMSServiceSMS sending (OTP, notifications)
SpreadsheetServiceExcel export generation
TaxRateServiceShared tax rate logic
TimezoneServiceTimezone handling
UserServiceUser account management
VanhoutteServiceVanhoutte supplier integration
VivaWalletServiceViva Wallet payment integration

Other Domain Services

ServicePurpose
AuthServiceAuthentication logic
AuthCustomerServiceCustomer-facing authentication
AuthDeviceServiceKiosk device authentication
JwtServiceJWT token issuance and validation
PasskeyServiceWebAuthn/passkey operations
PermissionServiceRole-based permission checking
TransactionServiceTransaction processing
BaseMerchantServiceShared merchant-level operations
RepresentativeServiceSales representative management
DescriptionGeneratorServiceAI-powered description generation
RestaurantSuggestionServiceRestaurant discovery/suggestions
TableQrOrderingServiceQR-based table ordering flow
ThirdPartyAuthServiceThird-party auth (OAuth) handling

RawModels Architecture

The backend uses a custom RawModels pattern instead of Eloquent ORM models. All models extend BaseModel and are plain PHP objects with typed constructor properties.

Key characteristics:

  • Immutable by default -- Properties are set via constructor and accessed via getters
  • No Eloquent dependency -- Models are decoupled from the ORM
  • Typed properties -- Full PHP type declarations on all fields
  • Connection constants -- Each model declares its CONNECTION and COLLECTION
  • Soft deletes -- Controlled via SOFT_DELETE constant
  • Archiving -- Some models support ARCHIVE constant for archival

BaseModel provides:

  • MongoDB ObjectId management (getId(), getObjectId())
  • Timestamp handling (getCreatedAt(), getUpdatedAt(), getDeletedAt())
  • Carbon date helpers
  • Soft delete checking (isTrashed())
  • ObjectId array conversion (toObjectIds())
  • New instance detection (isNewlyCreated())

Polymorphic Collections

Some MongoDB collections store multiple model types using a model discriminator field:

  • menus collection (tenant) stores: Item, Menu, Modifier, ModifierGroup, DisplayGroup, VariantGroup, VariantOption, Offer, GiftCard, ComboDefinition, UpsellGroup
  • settings collection (tenant) stores: Category, Language, DeviceProfile, BrandingProfile, BillingProfile, TableSection, Loyalty, Reward, BirthdayBonus, and other settings entities
  • transactions collection (tenant) stores: Transaction, TransactionItem, OrderSession, ThirdPartyTransaction, Invoice, SplitPaymentSession and the loyalty/offer redemption records
  • users collection (central) stores: User, Customer, CustomerMerchant, Device
  • locations collection (central) stores: Location, ThirdPartyIntegration, CustomerAddress, CustomerMerchantAddress

DisplayGroup is in menus, not settings -- querying settings with model: 'display_group' returns nothing.


Background Job System

The backend uses Laravel's queue system, backed by MongoDB -- not Redis. Redis is the cache store and the rate limiter store; locks stay on MongoDB deliberately (the managed Valkey instance runs without AOF, so a lock store on it is not durable enough).

The rate limiter store

config/cache.php:35 resolves the limiter as env('CACHE_LIMITER', env('CACHE_STORE') === 'redis' ? 'redis' : 'file').

This matters: file limiter counters are per-container, so on the autoscaled deployment every throttle: limit -- including the POS PIN brute-force guard and the login throttles -- was effectively multiplied by the instance count. Deployed environments already run CACHE_STORE=redis and so pick up a shared limiter with no env change. Non-redis stores (local, the CI unit job, PHPUnit's array store) keep file, which also sidesteps the MongoDB null-key duplicate error.

A Valkey blip kills queue workers, and that is acceptable

When the managed Valkey node drops its connections, every long-lived queue:work process throws Predis\Connection\ConnectionException ("Error while reading line from the server"). This is expected and unfixed by design: predis has no reconnect, and Laravel's retry-on-dropped-socket lives only in PhpRedisConnection::command(). The throw lands in Worker::stopIfNecessary -- between jobs -- the queue itself is on MongoDB, and supervisord's autorestart brings the worker straight back. Nothing is lost; the blast radius is worker churn.

When to revisit: a stacktrace passing through a job class rather than queueShouldRestart means a job was actually interrupted, and that is a real bug. Before switching to phpredis, note that CachingTrait::forgetRedisByPattern uses the predis SCAN signature and would silently no-op under phpredis.

Queue Configuration

  • Connection: database (QUEUE_CONNECTION=database, the default in config/queue.php). That connection is configured with 'driver' => 'mongodb' and writes to the jobs collection on the mongodb connection.
  • Driver class: app/Queue/RetryableMongoQueue.php, registered via app/Queue/RetryableMongoConnector.php in AppServiceProvider::boot() so it wins over the package's stock mongodb connector (the stock driver's deleteMany is not retried by retryWrites, so a replica-set step-down surfaced as an uncaught "not primary" error).
  • retry_after: 900s by default (DB_QUEUE_RETRY_AFTER) -- it must exceed the longest job timeout and every worker --timeout.
  • Failed jobs: Stored in MongoDB via custom app/Queue/MongoDBFailedJobProvider.php, bound by AppServiceProvider::boot() extending the queue.failer container binding.

Key Job Types

Jobs are dispatched for operations that should not block the HTTP request:

  • Payment webhook processing
  • Third-party sync operations (Square, Deliveroo, UberEats)
  • FCM push notification sending
  • Email/SMS dispatch
  • Data repair operations (Console/Commands/DataRepair/)

Service Providers

Located in app/Providers/. Registered in bootstrap/providers.php:

ProviderPurpose
AppServiceProviderContainer bindings (Lightspeed K Series / Odoo API clients) + the MongoDB queue driver and failed-job provider (queue.failer)
QueueMetricsServiceProviderWires the queue lifecycle to JobMetricsRecorder (job duration + queue wait time for the async-processing monitor)
SessionServiceProviderCustom session handling
SentryServiceProviderConfigures Sentry error tracking
ToolServiceProviderRegisters the tool runtime (ToolRegistry, ToolExecutor, CircuitBreaker, IdempotencyStore, ToolAuditLogger) and auto-discovers app/Tools/Read/ + app/Tools/Write/

HealthServiceProvider is registered separately, via ->withProviders([...]) in bootstrap/app.php -- it is not in bootstrap/providers.php.

Custom Validation Rule Classes (app/Rules/)

AppServiceProvider registers no validation rules -- there are no Validator::extend string rules such as exists_in_connection. Validation is done with invokable Rule classes:

ClassFilePurpose
ExistsInConnectionWithModelapp/Rules/ExistsInConnectionWithModel.phpValue exists in a given connection/collection for a model type
ExistsInConnectionArrayWithModelapp/Rules/ExistsInConnectionArrayWithModel.phpSame check across an array field
UniqueInConnectionWithModelapp/Rules/UniqueInConnectionWithModel.phpUniqueness within a connection for a model type
ActiveUserIdapp/Rules/ActiveUserId.phpValue references a real, active, non-soft-deleted User
EuVatNumber / ValidEuVatapp/Rules/EuVatNumber.php, ValidEuVat.phpEU VAT number format / validity
SafeLinkUrlapp/Rules/SafeLinkUrl.phpOnly relative paths/fragments or absolute http(s) URLs -- blocks javascript:, data:, etc.
ValidIpOrCidrapp/Rules/ValidIpOrCidr.phpIP address or CIDR range format

Money-specific rules live in app/Rules/Money/: MinMoney, MaxMoney, MoneyFormat.


Route Organization

API Routes (routes/api.php and routes/api/)

Routes are organized by consumer type:

  • routes/api/backoffice/ -- Backoffice dashboard API routes (glob-included by routes/api.php)
  • routes/api/reseller/ -- Reseller portal routes
  • routes/api/dev/ -- Developer-only routes
  • routes/api/field.php -- Field Ops rep app routes
  • routes/api/guest.php -- Unauthenticated/public endpoints
  • routes/api.php -- Main route file that includes all sub-route files

There is no routes/guest.php at the top level.

Each route group applies appropriate middleware for its consumer type (authentication, tenant switching, permissions).


Key Patterns for AI Bug Fixing

When investigating or fixing bugs, keep these patterns in mind:

  1. Always check which database connection a model uses. Central (mongodb) vs tenant (tenant) changes where data is stored and queried.

  2. Multi-tenant context is critical. Ensure the tenant database is set before any tenant-connection query. Missing tenant context causes "database not set" errors or cross-tenant data leaks.

  3. The polymorphic menus and settings collections mean multiple model types share a collection. Always filter by model type when querying.

  4. Not every domain has an orchestrator. Check the verified orchestrator list above before assuming one exists -- most BackOffice controllers inject their service directly, and services do inject sibling services. Only look for an orchestrator where one is listed.

  5. Factory defaults matter. When creating new records, check app/RawFactories/ for the factory class to understand default field values.

  6. Webhook idempotency. Transaction processing uses idempotency_key to prevent duplicate processing. Webhook orchestrators should always check for duplicates.

  7. Price calculations involve tax rates. The PriceTrait handles tax-inclusive and tax-exclusive pricing per dining option (dine-in, takeout, delivery). Bugs in pricing often involve incorrect tax rate resolution.