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) |
Request flow
Section titled “Request flow”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.
URL map
Section titled “URL map”| 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 |
Domain model
Section titled “Domain model”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 : remindedOrders 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_movementsis the only stock writer, with batch and flower totals kept in step.order_allocationsholds what confirmed orders reserve.order_transitionsrecords every status change.- Idempotency keys make a repeated action apply once.
Around them:
order_paymentsrecords offline payments, with reversals.uklon_deliveriestracks couriers.storefront_versionsholds the website’s draft and published configurations.workspace_domainsholds a shop’s own domain and its state (ADR-022).expensesfeeds Finance.workspace_invitationsandonboarding_remindersserve the team and onboarding.
Integrations
Section titled “Integrations”- AI providers.
AiServiceFactorybuilds a provider adapter from the keyAiCredentialsresolves: the shop’s own encrypted key, or the platform’s key from the environment whenAI_GLOBAL_CREDENTIALSis on (ADR-017).FreepikServicetalks to Magnific (Freepik’s API renamed;api.magnific.com, headerx-magnific-api-key) for image and video generation with asynchronous task polling, andOpenAiServicewrites descriptions and analyses images. Endpoints require theai.generatepermission 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_IDandGOOGLE_CLIENT_SECRETare 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;DnsProvidernames the DNS provider from the name servers. For any provider that supports Domain Connect,DomainConnectfinds it from the zone’s_domainconnectrecord 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:purgenightly;uklon:syncevery five minutes;domains:checkevery five minutes;onboarding:reminddaily at 10:00 Kyiv (deployment).