Production B2B SaaS — Multi-tenant white-label platform where merchant companies create customizable apparel products, their customers design and order them, and factories fulfill the production workflow. Live at custimoo.com | 243+ merchant companies in production | Denmark
- What is Custimoo?
- System Architecture
- Tech Stack
- Multi-Tenant Architecture
- Authentication — Three Systems
- Database Schema
- API Structure
- Order Lifecycle
- Stripe Dual-Account System
- eCommerce Integrations
- Background Jobs & Scheduling
- Third-Party Integrations
- Environment Variables
Custimoo is a white-label product customization SaaS for the apparel industry. The platform has three distinct user types:
| Actor | Who | What they do |
|---|---|---|
| Super Admin | Custimoo staff | Manages all companies, billing, Stripe config, system settings |
| Merchant Company | Custimoo's B2B customers | Creates products, manages customers, views orders, receives payments |
| End Customer | The merchant's customers | Browses products, customizes (colors/logos/text/roster), orders |
| Factory | Production partner | Receives orders, uploads production files, ships |
Core flow:
- Merchant onboards → gets their own domain-scoped workspace (multi-tenant)
- Merchant creates Products with Styles, Designs, SKUs, pricing
- Customer logs in, browses products, customizes using the builder (Fabric.js + Three.js)
- Customer saves designs to Locker Rooms → organizes into Collections → places Orders
- Orders split by factory → go through a 12-stage production workflow
- Billing via Stripe (subscriptions for merchants, direct charges for shop orders)
- Integrates with Shopify / WooCommerce / BigCommerce for existing merchant stores
+-------------------------------------- CUSTIMOO PLATFORM --------------------------------------+
| |
| Merchant Companies (Tenants) Customers (End Users) Factory Vendors |
| +--------------------------+ +---------------------+ +------------------+ |
| | Admin SPA (Vue 3) | | Customizer (Vue 3) | | Vendor API | |
| | Pinia + TanStack Query | | Fabric.js + Three.js| | /api/vendor/* | |
| +-----------+--------------+ +---------+-----------+ +--------+---------+ |
| | | | |
| +-----------v-----------------------------------------------------------------------------------+
| | Laravel 12 REST API (PHP 8.2) |
| | |
| | /api/admin/* /api/v2/* /api/ecommerce/* /api/vendor/* |
| | (Passport OAuth) (Custom JWT) (Webhook Ingestion) (Passport OAuth) |
| +--+---------------+----+-----------+--------+------------------+-+----------------------------+
| | | | | | | |
| +--v---+ +------v-+ | +--------v----+ | +-------------+ | +----------+ |
| |Redis | |AWS S3 | | | Shopify | | |WooCommerce | | |BigCommerce| |
| |Queue | |Storage | | | Webhooks | | |Webhooks | | |Webhooks | |
| |Horizon +--------+ | +-------------+ | +-------------+ | +-----------+ |
| +--+---+ | | |
| | | +-------------------------------------------------+ |
| +--v-------------------v--v---+ | Third-party Services | |
| | MySQL (Shared DB) | | Stripe x2 SharePoint Asana | |
| | ~125 tables, 421 migr. | | SendGrid OpenAI FedEx | |
| | company_id scoping | | DHL Bugsnag Qarma | |
| +-----------------------------+ +-------------------------------------------------+ |
+-----------------------------------------------------------------------------------------------+
| Package | Version | Purpose |
|---|---|---|
laravel/framework |
^12.0 | Core framework |
laravel/passport |
^13.0 | OAuth2 — staff/factory token auth |
laravel/horizon |
^5.31 | Redis queue monitoring dashboard |
spatie/laravel-permission |
^6.17 | RBAC roles & permissions |
stripe/stripe-php |
^17.4 | Stripe payments (subscriptions + Connect) |
signifly/laravel-shopify |
^1.2 | Shopify API integration |
aws/aws-sdk-php-laravel |
^3.10 | AWS S3 file storage |
firebase/php-jwt |
^6.11 | Custom JWT auth for end customers |
barryvdh/laravel-dompdf |
^3.1 | PDF generation (invoices, techpacks) |
maatwebsite/excel |
^3.1 | Excel export/import |
sendgrid/sendgrid |
^8.1 | Transactional email |
spatie/icalendar-generator |
^3.0 | iCal event exports |
intervention/image |
^2.7 | Server-side image processing |
darkaonline/l5-swagger |
^9.0 | OpenAPI/Swagger documentation |
bugsnag/bugsnag-laravel |
^2.29 | Error tracking |
doctrine/dbal |
^4.2 | Schema introspection |
ezyang/htmlpurifier |
^4.18 | HTML sanitization |
lcobucci/jwt |
^5.5 | JWT library (secondary) |
- Admin panel:
development_admin_v2— Vue 3, Pinia, TanStack Query v5, TypeScript, Tailwind - Product builder:
development_custimoo_builder_v2— Vue 3, Fabric.js, Three.js, Pinia, Tailwind v4
- Queue: Redis + Laravel Horizon (64 job classes)
- Storage: AWS S3 (presigned URLs for direct browser upload)
- Email: SendGrid
- Errors: Bugsnag
- PDF: External Node.js service (
NODE_PDF_URL)
Custimoo uses domain-based soft multi-tenancy — all tenants share one MySQL database, isolated by company_id. Tenant resolution happens per-request in the CheckCompany middleware.
Inbound Request
|
v
+------+-------------------------------+
| CheckCompany Middleware |
| |
| DomainCompany::resolve($request) |
| |
| Resolution priority: |
| 1. x-shopify-shop-domain (webhooks) |
| 2. producer + scope params (WP) |
| 3. Origin header (browser SPA) |
| 4. SubpageUrl + domain combo |
| 5. ORIGIN env fallback (local) |
+------+-------------------------------+
|
v
companies table
WHERE company_domains @> origin <-- JSON array column
|
v
$request->auth_company = $company
|
v
All controllers scope queries by company_id
Key column: companies.company_domains is a JSON array of allowed origins (e.g. ["https://shop.client.dk", "https://app.client.com"]). Each company can have multiple domains.
Scoped tables (~25 tables carry company_id):
customers, orders, products, customer_shops, factories, locker_rooms, collections, categories, email_templates, translations, brands, pricelists, settings, company_currency, company_languages, etc.
POST /api/admin/login --> Passport Bearer Token
stored in oauth_access_tokens
passed as: Authorization: Bearer {token}
Roles (via Spatie Laravel-Permission):
| Role | Scope |
|---|---|
SuperAdmin |
Full system — all companies |
admin |
Own company only |
OrderAdministrator |
Order management |
Factory |
Assigned factories only |
Manager |
Specific customers |
LimitedSuperAdmin |
Specific companies |
AdminSalesRep |
Own company data |
CSManager |
Super-admin scope |
LogisticAssistant |
Specific factories |
MerchantGroupAdmin |
Group-level admin |
The CheckUserEntities middleware populates role_company_ids, role_customer_ids, role_factory_ids on the request for fine-grained data scoping.
POST /api/v2/customer/login --> HS256 JWT (SECRET_KEY env)
passed as: CustomerToken: {token}
stored in: customers.jwt_token
- Customer model does not extend Laravel's
Authenticatable - Token decoded in
CheckCompanymiddleware, customer hydrated on$request->auth_customer - Anonymous pre-login state tracked by
browser_key
customer_shops.password (bcrypt hashed — for validation)
customer_shops.plain_password (AES-256-CBC encrypted — shown to merchant)
AccessShopV2 middleware validates shop password on each request
421 migrations applied | ~125 tables | Shared MySQL DB
users company_id, name, email, password, commission, asana_user_id
customers company_id, first_name, last_name, email, browser_key, jwt_token, role_id
roles / permissions Spatie RBAC (guard: api)
model_has_roles role_id, model_type, model_id (polymorphic)
oauth_access_tokens Passport tokens
ip_white_lists company_id, IP allowlist
user_entities user_id, sourceable_id, sourceable_type (polymorphic scope)
user_tasks user_id, assigned_to, task_details(JSON), relation_type, relation_id
companies company_name, company_domains(JSON), admin_domains(JSON),
platform(enum: self/wordpress/cdnExceptLogin/shopify),
stripe_account_id, stripe_charges_enabled,
stripe_onboarding_completed_at, region, pricelist_id
billing_data company_id, stripe_api_integration(enum: DEFAULT/CANADIAN),
stripe_id, invoice_prefix, tax_identifier,
payment_method(enum), stripe_tax_id
company_subscription_details company_id, subscription_status, stripe plan fields
factories company_id, name, email, asana_project_id, asana_workspace_id
pricelists name, currency_id
rules pricelist_id, pricing rule definitions
brands company_id, name, logo
buying_groups_sales_reps type(enum: Buying Group / External Sales Rep)
products company_id, factory_id, parent_id, product_type(enum),
is_published, is_private, allowed_logos_count,
allow_name_number, merge_styles, svg_group_color_container(JSON)
product_styles product_id, name, image, three_d_properties(JSON)
product_designs product_id, product_style_id, svg_parts(JSON), custom_text(JSON)
[83K rows -- design state snapshots]
product_sizes product_id, size definitions
product_colors product_id, color definitions
product_logos_setting product_id, product_style_id, logo_technologies(JSON)
sku_ids name, parent_id (self-ref), production_days,
asana_task_template_id, customized_sku_info(JSON)
[837 SKUs]
sku_currency sku_id, currency_id, price, net_price
addon_groups grouping for addons
add_ons sku_id, name, details
ecommerce_products product_id, company_id, ecommerce_product_id, size_variants(JSON)
customer_shops company_id, customer_id, name, status(enum: draft/live/expired),
is_price_visible, template(enum), publish_at, unpublish_at,
password (hashed), plain_password (AES encrypted)
shop_slugs customer_shop_id, name (URL slug, unique)
shop_products customer_shop_id, product_id, title, images(JSON), sizes(JSON)
shop_settings shop_id, key, value(JSON)
product_locker_room customer_id, company_id, product_id, product_style_id,
colors(JSON), custom_logos(JSON), defaultcolors(JSON),
product_roster_detail(JSON), text(JSON), images(JSON)
[55K saved customizations]
locker_rooms customer_id, company_id, name
locker_room_assets locker_room_id, asset_url, type [88K assets]
collections customer_id, company_id, locker_room_id, name,
pdf_link, ecommerce_collection_id [34K collections]
design_files name, url, type, svg_parts(JSON), customer_id
[275K design files on S3]
customer_logos customer_id, url, logo_colors(JSON), is_ai_generated
[192K logos]
ai_logo_metas customer_logo_id, prompt, mood(JSON), generation metadata
fixed_logos company_id, product_style_id, url, is_customizable
data_containers company_id, name, type
container_files data_container_id, file URLs, SVG paths
carts customer_id, company_id, customer_shop_id
cart_items cart_id, factory_id, factory_products(JSON)
orders order_no, customer_id, company_id, billing_company_id,
ecommerce_order_id, shipping_address(JSON),
techpack_data(JSON), price_info(JSON),
stripe_invoice_info(JSON), payment_status,
sharepoint_path, customer_shop_id, is_shop_order
order_items order_id, factory_id, status(enum -- 12 stages),
factory_products(JSON), <-- full snapshot at order time
price_info(JSON), techpack_data(JSON),
stripe_invoice_id, asana_task_gid,
tracking_link, tracking_no, shipping_company
order_item_invoices order_item_id, stripe_invoice_id(unique),
stripe_invoice_number, status, amount_due, amount_paid,
currency, hosted_invoice_url, allocations_snapshot(JSON),
idempotency_key, requires_manual_review
order_item_invoice_line_allocations order_item_invoice_id, order_item_id,
line_key, quantity
stripe_payment_attempts order_id, stripe_checkout_session_id(unique),
connected_account_id, currency, amount_minor,
status(enum: pending/succeeded/failed/canceled/expired)
stripe_webhook_events event_id(PK=Stripe event id), event_type,
connected_account_id, payload(JSON),
processing_status -- idempotency deduplication
order_item_activities order_item_id, status(enum), action_log(JSON) [65K rows]
order_item_activity_comments activity_id, comment, files(JSON) [33K rows]
order_activity_logs order_id, action_type(enum), activity_meta_data(JSON)
complaint_attachments complaint_id, file_url
settings sourceable_id, sourceable_type (polymorphic), key_name, value(JSON)
-- per-entity key-value store (4.4K rows)
translations company_id, locale, key, value -- 11 languages supported
jobs / failed_jobs Laravel queue tables
notifications notifiable_type, notifiable_id, data(JSON) [56K rows]
email_templates company_id, key_name, subject, body
| Prefix | Auth | Description |
|---|---|---|
POST /api/admin/login |
Public | Staff login → Passport token |
/api/admin/* |
Passport Bearer | Admin portal — companies, orders, products, customers, factories |
/api/v2/* |
Custom JWT (CustomerToken) | Customer-facing — customizer, locker rooms, cart, orders, shops |
/api/ecommerce/* |
API secret header | Shopify, WooCommerce, BigCommerce webhooks |
/api/vendor/* |
Passport Bearer | Factory vendor — order detail, techpack, QA report |
Key admin endpoints:
GET/POST/PUT /api/admin/orders Order management
GET/POST/PUT /api/admin/products Product CRUD + styles/designs/SKUs
GET/POST/PUT /api/admin/customers Customer management
GET/POST/PUT /api/admin/factories Factory management
POST /api/admin/stripe-connect/* Stripe Express onboarding
GET/POST /api/admin/roles RBAC management
Key customer V2 endpoints:
POST /api/v2/customer/login JWT auth
POST /api/v2/ai/generate-logo OpenAI logo generation
POST /api/v2/ai/redesign-logo AI logo redesign
GET /api/v2/locker-rooms Saved designs
POST /api/v2/collections Create collections
POST /api/v2/cart/add Add to cart
POST /api/v2/orders Place order
GET /api/v2/customer-shops Browse B2B shops
POST /api/v2/stripe-connect/checkout/create-session Shop payment
Customer places order
|
v
+----------+----------+
| Order created |
| Splits by factory |
+----------+----------+
|
+--------------v--------------+
| submitted_for_factory_review |
+--------------+--------------+
|
+------------+------------+
| |
+-------v------+ +--------v-------+
|factory_approved| |factory_rejected |---> end
+-------+------+ +-----------------+
|
v
+-------+-------------------+
|submitted_for_customer_review|
+-------+-------------------+
|
+--------+---------+
| |
v v
customer_approved customer_rejected
| |
| +---> back to factory
v
production_files_uploaded
|
v
quality_control
|
v
in_production
|
v
awaiting_consolidation
|
v
shipped (tracking_link + tracking_no added)
|
v
completed
Each status change creates a row in order_item_activities (65K rows) with full action_log JSON. Comments with file attachments tracked in order_item_activity_comments (33K rows).
Asana task created per order item (asana_task_gid stored). SharePoint path stored per order for techpack/file archival.
Custimoo operates two legal Stripe entities for EU and Canadian billing:
Company
|
v
billing_data.stripe_api_integration
|
+-- DEFAULT --> Custimoo ApS (Denmark)
| env: DEFAULT_STRIPE_PRIVATE_KEY
| env: DEFAULT_STRIPE_CONNECT_WEBHOOK_SECRET
|
+-- CANADIAN --> Custimoo Canada Ltd
env: CANADIAN_STRIPE_PRIVATE_KEY
env: CANADIAN_STRIPE_CONNECT_WEBHOOK_SECRET
StripeConnectKeyResolver static class handles routing — all Stripe API calls call StripeConnectKeyResolver::client($company) which returns the correct StripeClient instance.
Webhook deduplication: Both accounts post to the same endpoint. The handler tries all configured webhook secrets until one verifies, then checks stripe_webhook_events table by event_id (PK) for idempotency before processing.
Stripe Connect flow (merchant onboarding):
1. Admin: POST /api/admin/stripe-connect/connect
--> StripeConnectOnboardingService::ensureConnectedAccount()
--> Creates Stripe Express account on correct platform
--> Stores stripe_account_id on companies table
2. Admin: POST /api/admin/stripe-connect/account-link
--> Generates hosted Stripe onboarding URL
--> Merchant completes KYC on Stripe
3. Webhook: account.updated
--> stripe_charges_enabled, stripe_payouts_enabled updated on company
4. Customer: POST /api/v2/stripe-connect/checkout/create-session
--> Creates Checkout Session as direct charge on merchant's account
--> Records attempt in stripe_payment_attempts
5. Webhook: checkout.session.completed
--> Order marked paid
POST /api/ecommerce/shopify-order-webhook
|
v
company.settings['customizer_version']
|
+-- v1 --> OrderController (legacy)
+-- v2 --> ShopifyOrderService (extracts _custimoo_cart_id from line item properties)
- SDK:
signifly/laravel-shopify - Settings stored:
settingstable — keyshopify_setting(store domain, access token, API version2024-07) - Webhooks registered: order create, product update
- Bidirectional product sync via
sync_idcolumn on products - Customer sync:
CreateShopifyUserJob - Collection export:
export-collection-to-shopifyendpoint - Token refresh: daily
ShopifyRefreshTokenCommand
- Settings:
woocommerce_setting(api_username, api_password, store_url, plugin_api_secret) - Order import via
_custimoo_cart_idWooCommerce order meta - Product sync:
POST /api/ecommerce/woocommerce-update-product-webhook - Customer sync:
CreateWooCommerceCustomerJob
- Settings:
bigcommerce_setting(access_token, store_hash, client_id, channel_id) - Product sync:
POST /api/ecommerce/bigcommerce-update-product-webhook - Order import:
POST /api/ecommerce/bigcommerce-order-webhook - Addon modifiers:
ecommerce_products.ecommerce_modifier_id
64 queue job classes processed via Redis + Laravel Horizon.
| Schedule | Job / Command |
|---|---|
| Daily 00:00 | FindMissingSharepointPathOrderIdsJob |
| Daily 03:00 | CreateMissingSharepointAssetsOrderIdsJob |
| Daily 00:00 | UpdateHolidays — sync public holidays from Calendarific API |
| Daily | HandleShopStatusJob — activate/expire shops by publish_at/unpublish_at |
| Daily | app:delete-expired-entity-tokens |
| Daily | ShopifyRefreshTokenCommand — refresh Shopify API tokens |
| Daily 08:00 | GenerateShipmentCsvJob |
| Daily | ConsolidateOrdersDailyJob — merge shop order items |
| Daily 02:00 | stripe-connect:cancel-unpaid-orders |
| Twice daily | UpdateNewTrackingJob — FedEx/DHL tracking updates |
Key jobs:
CreateShopifyUserJob/CreateWooCommerceCustomerJob/CreateBigCommerceCustomerJobPostOrderDataToQarma— QA gateway integrationVectorizeOrderLogos— AI logo vectorizationCopyWoffFilesToS3DevelopBucket— font asset syncGenerateTechpackPdfJob— order production PDFSendOrderStatusEmailJob— SendGrid transactional emails
| Service | Purpose | Auth |
|---|---|---|
| Stripe (x2) | Subscriptions + Connect direct charges | Secret key (per entity) |
| AWS S3 | All file/asset storage, presigned upload URLs | IAM key/secret |
| Shopify | Order + product + customer sync | Per-store access token |
| WooCommerce | Order + product sync | API key/secret |
| BigCommerce | Order + product sync | Access token + store hash |
| SharePoint (Graph API) | Techpack + order file archival | TENANT_ID, CLIENT_ID, CLIENT_SECRET |
| Asana | Order workflow task creation | ASANA_TOKEN |
| OpenAI | AI logo generation (generate/redesign/refine) | OPENAI_API_KEY |
| SendGrid | Transactional email | SENDGRID_API_KEY |
| FedEx | Shipment tracking | FEDEX_CLIENT_ID/SECRET |
| DHL | Shipment tracking | DHL_API_KEY |
| Calendarific | Public holiday calendar sync | CALENDER_API_KEY |
| Bugsnag | Error monitoring | BUGSNAG_API_KEY |
| Graip | Factory QA data exchange | Webhook-based |
| Qarma | QA gateway | QARMA_API_URL/USERNAME/PASSWORD |
| Node.js PDF service | PDF/techpack generation | NODE_PDF_URL, NODE_TECHPACK_URL |
# App
APP_KEY=
APP_URL=
APP_ENV=production
# Database
DB_CONNECTION=mysql
DB_HOST=
DB_DATABASE=
DB_USERNAME=
DB_PASSWORD=
# Redis (Queue + Cache)
REDIS_HOST=
REDIS_PASSWORD=
QUEUE_CONNECTION=redis
# AWS S3
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=
AWS_BUCKET=
S3_PRESIGNED_UPLOAD_ACL=public-read
# Stripe — DEFAULT account (EU/Denmark)
DEFAULT_STRIPE_PRIVATE_KEY=
DEFAULT_STRIPE_CONNECT_WEBHOOK_SECRET=
# Stripe — CANADIAN account
CANADIAN_STRIPE_PRIVATE_KEY=
CANADIAN_STRIPE_CONNECT_WEBHOOK_SECRET=
# Stripe Connect feature flag
STRIPE_CONNECT_PAYMENTS_ENABLED=false
# Shopify
SHOPIFY_API_VERSION=2024-07
# SharePoint (Microsoft Graph)
TENANT_ID=
CLIENT_ID=
CLIENT_SECRET=
# Asana
ASANA_TOKEN=
ASANA_WORKSPACE_ID=
# OpenAI
OPENAI_API_KEY=
# SendGrid
SENDGRID_API_KEY=
# FedEx
FEDEX_CLIENT_ID=
FEDEX_CLIENT_SECRET=
# DHL
DHL_API_KEY=
# Calendarific (holidays)
CALENDER_API_KEY=
# External PDF service
NODE_PDF_URL=http://localhost:3000
NODE_TECHPACK_URL=http://localhost:3001
# Bugsnag
BUGSNAG_API_KEY=
LOG_EXCEPTION_TO_BUGSNAG=true
# Qarma QA
QARMA_API_URL=
QARMA_USERNAME=
QARMA_PASSWORD=
# JWT (customer auth)
SECRET_KEY=
# Passport
PASSPORT_PRIVATE_KEY=
PASSPORT_PUBLIC_KEY=composer install
cp .env.example .env
php artisan key:generate
php artisan passport:keys
php artisan migrate
php artisan passport:client --personal
php artisan horizon # queue dashboard
php artisan serveProprietary — all rights reserved.