Skip to content

System overview

Flowery is a single Laravel application serving two front ends through Inertia and React: the public storefront and the control panel. There is no separate API service and no microservice split.

Layer Choice
Backend Laravel 12 on PHP 8.3, Fortify for authentication and two-factor
Frontend bridge Inertia 2 with the React adapter, Laravel Wayfinder for typed routes
UI React 19, TypeScript, Tailwind 4, shadcn and Radix primitives, i18next (English and Ukrainian)
Build Vite 7 with the React Compiler plugin
Database PostgreSQL only: 17 in production and CI, 18 in local development (ADR-007)
Tests Pest 4, run in parallel against real PostgreSQL databases
Hosting Laravel Forge, deployed by a CI-triggered hook (ADR-004)
flowchart LR
B[Browser] --> H{Hostname}
H -->|flowery.test| R[Root routes: login, register, onboarding]
H -->|slug.flowery.test| T[IdentifyTenant]
T --> S[Storefront routes /]
T --> C[Control panel routes /control]
C --> M[Membership and capability middleware]
M --> P[Controllers: authorize, validate, act]
P --> D[(PostgreSQL, tenant-scoped models)]

The root domain handles authentication and onboarding. Everything a shop does happens on its subdomain. Redirects between the two origins use Inertia::location() because Inertia’s XHR cannot follow a cross-origin 302. The session cookie is set on the parent domain so a login on the root is valid on the subdomain.

Host Path Audience
flowery.test /, /en The landing page for visitors; signed-in users go on to onboarding or their shop
flowery.test /sitemap.xml, /robots.txt, /docs/* Search engines and readers: the landing page and the docs are the indexable pages; the account pages below say noindex (landing page)
flowery.test /login, /register, /forgot-password, /two-factor-challenge, /email/verify Guests and unconfirmed accounts
flowery.test /auth/google/redirect, /auth/google/callback Sign in with Google (ADR-019)
flowery.test /onboarding/* New owners: the shop step
flowery.test /invitations/{token} People invited to a team (ADR-021)
flowery.test /reminders/stop/{user} (signed) Turning off onboarding reminders (ADR-020)
flowery.test /webhooks/uklon/{account} Uklon courier events (signed)
{slug}.flowery.test /, /catalogue, /bouquets/{id}, /flowers/{id}, /builder, /cart, /checkout, /orders/{token}, served by the Astro storefront (runtime) Customers
{slug}.flowery.test /storefront-api/v1/*, the Storefront API Storefront themes
{slug}.flowery.test /control/**: dashboard and Getting started, orders, catalogue, supplies, Attention, Finance, settings (including Team and the website) Shop team
www.shop.ua (a shop’s own domain, ADR-022) The storefront paths and /storefront-api/v1/* only, while the domain is live; the subdomain’s storefront redirects here Customers, storefront themes
erDiagram
WORKSPACE ||--o{ WORKSPACE_USER : has
USER ||--o{ WORKSPACE_USER : joins
WORKSPACE ||--o{ FLOWER : owns
WORKSPACE ||--o{ BOUQUET : owns
BOUQUET }o--o{ FLOWER : composed_of
BOUQUET ||--o{ BOUQUET_IMAGE : has
WORKSPACE ||--o{ PACKAGING : owns
WORKSPACE ||--o{ SUPPLIER : owns
SUPPLIER ||--o{ SUPPLY : delivers
SUPPLY ||--o{ SUPPLY_ITEM : contains
FLOWER ||--o{ SUPPLY_ITEM : batched_as
WORKSPACE ||--o{ ORDER : owns
ORDER ||--o{ ORDER_ITEM : has
WORKSPACE ||--o{ CART : owns
CART ||--o{ CART_ITEM : has
WORKSPACE ||--o{ WORKSPACE_AI_SETTING : configures
WORKSPACE ||--o{ WORKSPACE_INVITATION : invites
WORKSPACE ||--o{ EXPENSE : records
WORKSPACE ||--o{ STOREFRONT_VERSION : publishes
WORKSPACE ||--o| WORKSPACE_DOMAIN : served_from
SUPPLY_ITEM ||--o{ INVENTORY_MOVEMENT : moves
ORDER ||--o{ ORDER_ALLOCATION : reserves
ORDER ||--o{ ORDER_REVISION : agreed_as
ORDER ||--o{ ORDER_TRANSITION : moves_through
ORDER ||--o{ ORDER_PAYMENT : paid_by
ORDER ||--o| UKLON_DELIVERY : delivered_by
USER ||--o{ ONBOARDING_REMINDER : reminded

Orders and stock follow Stage 2’s model (see Stock and money today):

  • order_revisions, with their items and components, record each agreed version of an order and its flower composition.
  • inventory_movements is the only stock writer, with batch and flower totals kept in step.
  • order_allocations holds what confirmed orders reserve.
  • order_transitions records every status change.
  • Idempotency keys make a repeated action apply once.

Around them:

  • order_payments records offline payments, with reversals.
  • uklon_deliveries tracks couriers.
  • storefront_versions holds the website’s draft and published configurations.
  • workspace_domains holds a shop’s own domain and its state (ADR-022).
  • expenses feeds Finance.
  • workspace_invitations and onboarding_reminders serve the team and onboarding.
  • AI providers. AiServiceFactory builds a provider adapter from the key AiCredentials resolves: the shop’s own encrypted key, or the platform’s key from the environment when AI_GLOBAL_CREDENTIALS is on (ADR-017). FreepikService talks to Magnific (Freepik’s API renamed; api.magnific.com, header x-magnific-api-key) for image and video generation with asynchronous task polling, and OpenAiService writes descriptions and analyses images. Endpoints require the ai.generate permission and are throttled to 30 requests per minute per user. Provider exception text is redacted, with platform keys included, before logging. Provider response bodies are neither logged nor shown; the browser gets a translated category (see Security).
  • Google Places. A browser key stored in workspace settings powers structured delivery addresses. It is shared with signed-in members only and should be referrer-restricted in Google Cloud.
  • Google sign-in. Laravel Socialite, server-side code flow on the root domain; off until GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are set (ADR-019).
  • Uklon. Couriers are estimated at checkout and booked at dispatch through each shop’s own Uklon Delivery account; status comes by signed webhook and polling (ADR-015).
  • Cloudflare for SaaS. A shop’s own domain becomes a custom hostname on the platform zone through CloudflareClient (one zone-scoped token); ownership and the certificate are polled, never pushed (ADR-022). For a domain whose DNS is on Cloudflare, “Connect with Cloudflare” (our OAuth client, CloudflareDns, CloudflareUserClient) adds the shop’s CNAME records with a short-lived token the app never stores; DnsProvider names the DNS provider from the name servers. For any provider that supports Domain Connect, DomainConnect finds it from the zone’s _domainconnect record and sends the owner there with a request signed by our key (ADR-022 §9).
  • Mail. Resend in production. Verification, password, welcome, team-invitation, onboarding-reminder and order emails are queued where they can be, and written in the reader’s language.
  • Scheduled work. The scheduler runs:
    • carts:purge nightly;
    • uklon:sync every five minutes;
    • domains:check every five minutes;
    • onboarding:remind daily at 10:00 Kyiv (deployment).