Appearance
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
| Component | Technology |
|---|---|
| Framework | Laravel 13 (laravel/framework v13.x, PHP 8.4) |
| Database | MongoDB (via mongodb/laravel-mongodb) |
| Cache | Redis (CACHE_STORE=redis, REDIS_CLIENT=predis) |
| Queue | MongoDB -- queue connection database with driver => 'mongodb' (QUEUE_CONNECTION=database) |
| Authentication | Custom JWT + OTP + Passkey (WebAuthn) |
| Image Storage | Cloudflare Images |
| SMS | SMS service (for OTP) |
| Push Notifications | Firebase Cloud Messaging (FCM) |
| Payment Providers | Stripe, Viva Wallet |
| Third-Party POS | Square, Kassanet, Hendrickx, Vanhoutte, Shopify |
| Delivery | UberEats, Deliveroo |
| Monitoring | Sentry, 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 onlyLedger/(Eloquent models on the separateledgerconnection, used by the commission/payout subsystem) andapp/Models/User.php, which is a bare alias:class User extends RawUser {}. All ~95 domain models live inapp/RawModels/, with their factories inapp/RawFactories/and their persistence inapp/Repositories/-- the repositories operate on RawModels, not onApp\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.phpTwo more orchestrators live outside app/Services/Orchestrators/:
app/Services/MplusKassa/OrderSyncOrchestrator.phpapp/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:
| Connection | Purpose |
|---|---|
mongodb | Central/shared database (vendors, users, locations, roles) |
tenant | Per-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
- JWT token contains
tenant_databasein its payload. - SetTenantDatabase middleware extracts the tenant database name and calls
setTenantDatabase()to configure thetenantconnection. - All subsequent queries on the
tenantconnection target that vendor's database.
The middleware handles three routing strategies:
| Type | Strategy |
|---|---|
backoffice | Extract tenant_database from JWT payload |
kiosk / kds | Extract tenant_database from JWT payload |
online-ordering | Look up tenant from request slug parameter |
customer | Look 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, Devicevendors-- Merchant records (containsdatabase_namefield)locations-- Location, ThirdPartyIntegration (+ every provider subclass), CustomerAddress, CustomerMerchantAddresssettings-- PaymentProfile, AllowedIpSetting, GuidedSetupVideoSettingroles-- Permission rolessubscriptions-- Subscriptiontax_rates-- TaxRate, TaxRateCustomlogs-- 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, UpsellGroupsettings-- 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, OfferRedemptioninventories-- Inventory, InventoryHistoryactivity_logs-- ActivityLog, Timeline, DeviceLog, QueueCounter, CustomerMerchantTokencontents-- 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 codelast_otp_request-- Timestamp of last OTP requestresend_counter-- Rate limiting counter
Passkey / WebAuthn
Modern passwordless authentication. The User model stores:
webauthn_challenge-- Current challenge for registration/authenticationpasskeys-- 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.
| Middleware | Route alias | Purpose |
|---|---|---|
JwtAuthenticate | auth | Validates JWT bearer token and sets authenticated user |
OptionalJwtAuth | auth.optional | Optional JWT -- sets user if token present, continues otherwise |
Authenticate | auth.sanctum | Laravel's built-in authentication (kept as an option) |
TokenType | type | Validates token type matches expected type |
SetTenantDatabase | tenant | Switches MongoDB tenant connection based on JWT or request |
CheckPermission | permission | CASL-style permission checking against user roles |
CheckUserActivity | check-user-activity | Tracks user activity |
CapacitorApiKey | capacitor.auth | Validates API key header for Capacitor (mobile) clients |
SuperAdmin | super-admin | Restricts to super-admin users |
GlobalAdministrator | global-admin | Restricts to global administrators |
AdminVendorOverride | admin-vendor-override | Lets an admin act in another merchant's context |
EnsureLocationBelongsToMerchant | location-owner | Cross-tenant guard on {locationId} route parameters |
EnsureGlobalFieldOpsAuthority | field-ops-authority | Guards the global Field Ops surface |
VerifyAdminSwitchIp | verify.admin-switch-ip | IP guard on admin merchant switching |
VerifyTurnstile | verify.turnstile[:configKey] | Cloudflare Turnstile check against the widget secret named by the optional arg -- fails open when that secret is empty (see below) |
E2EAuthMiddleware | e2e.auth | E2E test verification |
VerifyDeliverooWebhook | verify.deliveroo-webhook | Validates Deliveroo webhook signatures |
VerifyShopifyWebhook | verify.shopify-webhook | Validates Shopify webhook signatures |
VerifySquareWebhook | verify.square-webhook | Validates Square webhook signatures |
VerifyUberEatsWebhook | verify.uber-eats-webhook | Validates UberEats webhook signatures |
VerifyShopCaisseWebhook | verify.shopcaisse-webhook | Validates ShopCaisse webhook signatures |
VerifyMplusKassaWebhook | verify.mpluskassa-webhook | Validates MplusKassa webhook signatures |
VerifyLightspeedKSeriesWebhook | verify.lightspeed-k-series-webhook | Validates Lightspeed K Series webhook signatures |
VerifyCrmWebhook | verify.crm-webhook | Validates CRM webhook signatures |
VerifyVivaWebhookIp | verify.viva-webhook | IP-based verification of Viva Wallet webhooks |
Globally applied (no alias, registered in bootstrap/app.php):
| Middleware | Position | Purpose |
|---|---|---|
AssignRequestId | prepend | Correlation id on every log line and dispatched job |
SecurityHeaders | append | Security response headers on all requests |
Locale | append | Sets 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:
| Route | Middleware string | Config 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_secret | services.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-responseinput ->403witherror.code = CAPTCHA_REQUIRED(:36-43) siteverifyunreachable (ConnectionException, 5s timeout) or the response is unsuccessful /success !== true->403witherror.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:
| Mode | Behaviour | Used for |
|---|---|---|
MATCH_FRAGMENT | Matches 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_WORD | Must 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)
-> ControllerTypical 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)
-> ControllerTypical Middleware Chain for an Online Ordering Request
SetTenantDatabase:online-ordering -> ControllerKey Services by Domain
BackOffice Domain (app/Services/BackOffice/)
Core CRUD services for merchant management:
| Service | Purpose |
|---|---|
CategoryService | Category CRUD |
ContentService | Media/content management |
CustomerService | Vendor-specific customer management |
DeviceService | Kiosk device management |
DeviceProfileService | Device configuration profiles |
DisplayGroupService | Menu display grouping |
InHouseSettingsService | In-house/dine-in settings |
ItemService | Item/product CRUD |
LoyaltyService | Loyalty program management |
MenuService | Menu CRUD and availability |
ModifierGroupService | Modifier group management |
ModifierService | Individual modifier management |
OfferService | Promotional offer management |
OnlineOrderingService | Online ordering configuration |
OnlineSettingsService | Online platform settings |
OrderingChannelService | Channel (kiosk, online, QR) configuration |
QrOrderingService | QR/table ordering settings |
TableSectionService | Restaurant table section management |
TaxRateService | Tax rate configuration |
TransactionService | Transaction history and reporting |
VariantGroupService | Product variant management |
Settings sub-domain (app/Services/BackOffice/Settings/):
| Service | Purpose |
|---|---|
ActivityLogService | Activity/audit log |
BrandingProfileService | Visual branding (colors, logos) |
GuidedSetupVideoService | Guided-setup video settings |
PaymentProfileService | Payment provider configuration |
ReceiptSettingService | Receipt formatting and content |
TeamService | Team member management |
TranslationService | Multi-language translation management |
Third-Party Integration Services
| Service | Purpose |
|---|---|
DeliverooService | Deliveroo integration |
KassanetIntegrationService | Kassanet POS integration |
ShopifyIntegrationService | Shopify product sync |
SquareIntegrationService | Square POS integration |
SquareUpService | Square data sync |
UberEatsService | UberEats integration |
Payment Domain (app/Services/Payment/)
| Service | Purpose |
|---|---|
PaymentService | Payment processing orchestration |
PaymentCaptureService | Payment capture/settlement |
WebhookService | Payment webhook handling |
Common/Shared Services (app/Services/Common/)
| Service | Purpose |
|---|---|
AppUpdateService | Kiosk app version management |
CloudflareImageService | Cloudflare Images upload/retrieval |
FCMService | Firebase push notifications |
GeocodingService | Address geocoding |
HendrickxService | Hendrickx supplier integration |
SMSService | SMS sending (OTP, notifications) |
SpreadsheetService | Excel export generation |
TaxRateService | Shared tax rate logic |
TimezoneService | Timezone handling |
UserService | User account management |
VanhoutteService | Vanhoutte supplier integration |
VivaWalletService | Viva Wallet payment integration |
Other Domain Services
| Service | Purpose |
|---|---|
AuthService | Authentication logic |
AuthCustomerService | Customer-facing authentication |
AuthDeviceService | Kiosk device authentication |
JwtService | JWT token issuance and validation |
PasskeyService | WebAuthn/passkey operations |
PermissionService | Role-based permission checking |
TransactionService | Transaction processing |
BaseMerchantService | Shared merchant-level operations |
RepresentativeService | Sales representative management |
DescriptionGeneratorService | AI-powered description generation |
RestaurantSuggestionService | Restaurant discovery/suggestions |
TableQrOrderingService | QR-based table ordering flow |
ThirdPartyAuthService | Third-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
CONNECTIONandCOLLECTION - Soft deletes -- Controlled via
SOFT_DELETEconstant - Archiving -- Some models support
ARCHIVEconstant 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:
menuscollection (tenant) stores: Item, Menu, Modifier, ModifierGroup, DisplayGroup, VariantGroup, VariantOption, Offer, GiftCard, ComboDefinition, UpsellGroupsettingscollection (tenant) stores: Category, Language, DeviceProfile, BrandingProfile, BillingProfile, TableSection, Loyalty, Reward, BirthdayBonus, and other settings entitiestransactionscollection (tenant) stores: Transaction, TransactionItem, OrderSession, ThirdPartyTransaction, Invoice, SplitPaymentSession and the loyalty/offer redemption recordsuserscollection (central) stores: User, Customer, CustomerMerchant, Devicelocationscollection (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 inconfig/queue.php). That connection is configured with'driver' => 'mongodb'and writes to thejobscollection on themongodbconnection. - Driver class:
app/Queue/RetryableMongoQueue.php, registered viaapp/Queue/RetryableMongoConnector.phpinAppServiceProvider::boot()so it wins over the package's stockmongodbconnector (the stock driver'sdeleteManyis not retried byretryWrites, 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 byAppServiceProvider::boot()extending thequeue.failercontainer 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:
| Provider | Purpose |
|---|---|
AppServiceProvider | Container bindings (Lightspeed K Series / Odoo API clients) + the MongoDB queue driver and failed-job provider (queue.failer) |
QueueMetricsServiceProvider | Wires the queue lifecycle to JobMetricsRecorder (job duration + queue wait time for the async-processing monitor) |
SessionServiceProvider | Custom session handling |
SentryServiceProvider | Configures Sentry error tracking |
ToolServiceProvider | Registers 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:
| Class | File | Purpose |
|---|---|---|
ExistsInConnectionWithModel | app/Rules/ExistsInConnectionWithModel.php | Value exists in a given connection/collection for a model type |
ExistsInConnectionArrayWithModel | app/Rules/ExistsInConnectionArrayWithModel.php | Same check across an array field |
UniqueInConnectionWithModel | app/Rules/UniqueInConnectionWithModel.php | Uniqueness within a connection for a model type |
ActiveUserId | app/Rules/ActiveUserId.php | Value references a real, active, non-soft-deleted User |
EuVatNumber / ValidEuVat | app/Rules/EuVatNumber.php, ValidEuVat.php | EU VAT number format / validity |
SafeLinkUrl | app/Rules/SafeLinkUrl.php | Only relative paths/fragments or absolute http(s) URLs -- blocks javascript:, data:, etc. |
ValidIpOrCidr | app/Rules/ValidIpOrCidr.php | IP 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 byroutes/api.php)routes/api/reseller/-- Reseller portal routesroutes/api/dev/-- Developer-only routesroutes/api/field.php-- Field Ops rep app routesroutes/api/guest.php-- Unauthenticated/public endpointsroutes/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:
Always check which database connection a model uses. Central (
mongodb) vs tenant (tenant) changes where data is stored and queried.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.
The polymorphic
menusandsettingscollections mean multiple model types share a collection. Always filter bymodeltype when querying.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.
Factory defaults matter. When creating new records, check
app/RawFactories/for the factory class to understand default field values.Webhook idempotency. Transaction processing uses
idempotency_keyto prevent duplicate processing. Webhook orchestrators should always check for duplicates.Price calculations involve tax rates. The
PriceTraithandles tax-inclusive and tax-exclusive pricing per dining option (dine-in, takeout, delivery). Bugs in pricing often involve incorrect tax rate resolution.