Skip to content

Architecture

System overview

Easy OTC is a B2B OTC pharmacy storefront. It serves members of partnered health plans (e.g. HealthInsurance.com / GoldKidney) who use a benefit allowance to purchase eligible over-the-counter pharmacy items — drugs, supplements, devices, and personal care. The product surface is a public catalog with member-gated checkout: agents at the partner health plan log into Easy OTC via Google OAuth on behalf of a member, impersonate that member, and place orders against the member's allowance.

The system is split into two deployable applications. The API is a Laravel 12 monolith (eliinova/the-one-otc-api) that owns the database, the Filament admin, business logic, and all third-party integrations. The storefront is a Nuxt 3 application (easty-otc) that is statically generated (bun run generate) and deployed to S3 + CloudFront. The API integrates with ShipHero (3PL — inventory source of truth and fulfillment), HealthInsurance.com (SSO + member origin), AWS S3 (image storage), Meilisearch (product search), and Stripe / WEX (payment, currently planned). All asynchronous work — image variant generation, search indexing, subscription billing — runs through Laravel Horizon on Redis.

Architecture diagram

                         ┌──────────────────────────────────────────────┐
                         │                  END USER                    │
                         │       (Agent impersonating a Member)         │
                         └────────────────────┬─────────────────────────┘
                                              │ HTTPS

        ┌──────────────────────────────────────────────────────────────────────┐
        │   CloudFront / S3 static site  (stage.easyotc.com, easyotc.com)      │
        │   Nuxt 3 SSG output (.output/public)                                 │
        │   - NuxtImg → easyotc-s3 provider rewrites to *_sm/_md/_lg.webp      │
        └──────────────┬──────────────────────────────┬────────────────────────┘
                       │ /api/* (XHR)                 │ <img src>
                       ▼                              ▼
        ┌─────────────────────────────────┐   ┌─────────────────────────────┐
        │   Laravel API (Forge VPS)       │   │  S3 image bucket (+ CDN)    │
        │   - HTTP routes / Actions       │   │  otc-stage / easyotc        │
        │   - Filament admin (/admin)     │   │  originals + 3 webp variants│
        │   - Passport (auth:api)         │   └──────────────▲──────────────┘
        └──┬──────┬──────┬──────┬─────────┘                  │ put variants
           │      │      │      │                            │
           │      │      │      │ dispatch                   │
           │      │      │      ▼                            │
           │      │      │   ┌──────────────────────────┐    │
           │      │      │   │  Redis (Horizon queue)   │    │
           │      │      │   └────────────┬─────────────┘    │
           │      │      │                │                  │
           │      │      │                ▼                  │
           │      │      │   ┌──────────────────────────┐    │
           │      │      │   │  Horizon worker (Forge)  │────┘
           │      │      │   │  GenerateImageVariants   │
           │      │      │   │  Scout index sync        │
           │      │      │   │  Subscription billing    │
           │      │      │   └────┬─────────────────────┘
           │      │      │        │
           │      │      ▼        ▼
           │      │   ┌──────────────────────────┐
           │      │   │  Postgres (Forge / RDS)  │
           │      │   └──────────────────────────┘
           │      │
           │      ▼
           │   ┌──────────────────────────┐
           │   │  Meilisearch             │
           │   │  indexes: products,      │
           │   │  categories, members     │
           │   └──────────────────────────┘

           ├─────────► ShipHero GraphQL (orders out, inventory in, webhooks in)
           ├─────────► HealthInsurance.com → Google OAuth callback (SSO)
           ├─────────► Stripe   (planned: card payments)
           └─────────► WEX      (planned: benefit card payments)

Components

ComponentTechWhat it doesWhere it lives
StorefrontNuxt 3 (SSG), Pinia, Nuxt UI, @nuxt/image, i18n (en/es)Public catalog, cart, member dashboard. Pre-rendered HTML; talks to API over /api/*.S3 bucket + CloudFront. Buckets: stage-otc, stage-goldkidney, prod (default). DNS via Cloudflare.
APILaravel 12, PHP 8.2, Passport (auth:api)REST API for storefront + Filament admin at /admin. Action classes per route (see routes/api.php).Laravel Forge VPS (Ubuntu).
Admin panelFilament 4 + spatie-tags pluginCatalog management, member/agent admin, orders, promotions, RBAC (spatie-permission).Same Laravel app, /admin.
DatabasePostgresSource of truth for catalog, orders, members, subscriptions, purses (benefit allowances).Forge-managed Postgres (or RDS in prod).
Cache + queue brokerRedis (predis)Cache, sessions, and Horizon queue backend. Note: config/queue.php ships QUEUE_CONNECTION=database as default — production sets redis via env to drive Horizon.Forge-managed Redis.
Queue workerLaravel Horizon 5Runs GenerateImageVariantsJob, GenerateProductImagesJob, Scout indexing, subscription order creation.Forge daemon on API box.
SearchMeilisearch 1.15 (SCOUT_DRIVER=meilisearch)Product / category / member search with filter + sort attributes defined in config/scout.php.Self-hosted Meilisearch (Forge or sidecar).
Image storeAWS S3 + CloudFrontOriginals + 3 WebP variants (_sm 256, _md 512, _lg 1024).S3 buckets otc-stage.s3.amazonaws.com, stage-otc.s3.amazonaws.com, easyotc.s3.amazonaws.com (allow-listed in nuxt.config.ts).
ShipHeroGraphQL 3PLInventory truth, order fulfillment, shipment webhooks.External SaaS. See app/Services/ShipHeroService.php.
HealthInsurance.comGoogle OAuth bridgeAgents enter Easy OTC via SSO; member info travels in the OAuth state.External. Callback: GET /api/oauth/healthcareinsurance/callback.
Stripe (planned)stripe/stripe-php already in composerCard payments at checkout.External SaaS.
WEX (planned)Benefit card processorBenefit-card payments for member allowances.External SaaS.
Activity logspatie-activitylogAdmin audit trail.DB tables in Postgres.
SchedulerLaravel scheduler (routes/console.php)health:check hourly (non-prod), subscriptions:process-due 06:00, subscriptions:send-reminders 09:00.Cron on API box (* * * * * php artisan schedule:run).

Data flow: a customer order

  1. Browse — Storefront fetches GET /api/store/popular-categories, GET /api/store/top-pick-products, GET /api/store/recommended-products. Product images render through <NuxtImg> which calls the easyotcS3 provider.
  2. Search — Storefront queries Meilisearch directly using the public key from runtimeConfig.public.meilisearchUrl. Filters: category_slugs, tag_slugs, in_stock, price, is_available, requires_prescription, is_subscribe_save_eligible (see config/scout.php).
  3. Add to cartPOST /api/cart-items/{product} (auth:api). Cart persisted server-side via CartService.
  4. CheckoutPOST /api/ordersStoreOrderActionOrderService::createOrder. Resolves shipping/billing address, applies promotions, computes totals, debits the member's purse (benefit allowance) via PurseService.
  5. Payment (planned) — Stripe for card balance; WEX for the benefit-card portion. Today the order is created on the strength of the purse balance only.
  6. Order created — Order row written in Postgres with status=pending.
  7. ShipHero order pushOrderService::createShipHeroOrder calls ShipHeroService (GraphQL mutation). Failure is logged but does not fail the order (see OrderService.php:328) — known risk surface.
  8. Shipment — ShipHero picks, packs, ships. Generates a tracking number.
  9. Tracking webhookPOST /api/webhooks/shiphero/shipment-updateShipHeroWebhookController::shipmentUpdate updates the order with tracking info.
  10. Email confirmation (planned) — Transactional order-confirmation and shipment-notification email is not yet wired. SendPromotionalEmailCommand and SendUpcomingDeliveryRemindersCommand exist; per-order receipts are TODO.

Image pipeline

The variant pipeline lives end-to-end between the API and the storefront and was built specifically so the static frontend never does runtime image processing.

  1. Upload — Admin uploads an image through a Filament FileUpload field (product, category, manufacturer). The file lands on S3 under e.g. images/products/abc.jpg.
  2. Dispatch — The model observer dispatches GenerateImageVariantsJob($path) onto the Horizon queue. (Backfill: app:generate-image-variants and app:generate-category-image-variants Artisan commands.)
  3. GenerateGenerateImageVariantsJob::handle() (see app/Jobs/GenerateImageVariantsJob.php) pulls the original from S3, runs Intervention Image, and writes three WebP variants: abc_sm.webp (256px), abc_md.webp (512px), abc_lg.webp (1024px). Quality fixed at 80. Skips existing variants unless force=true.
  4. CDN — CloudFront fronts the S3 bucket, so variants are cached at edge after the first request.
  5. Frontend rewrite<NuxtImg> calls the easyotcS3 provider (providers/easyotc-s3.ts). It looks at the requested width modifier, maps it through SIZE_BUCKETS (<=256 → _sm, <=512 → _md, else _lg), and rewrites …/abc.jpg…/abc_md.webp. The suffix table in the provider must stay in sync with GenerateImageVariantsJob::SIZES — comment in the provider calls this out.
  6. Serve — Browser pulls the rewritten URL straight from CloudFront. No Laravel image endpoint involved at request time.

Failure mode to know: if the job fails or hasn't run yet, the storefront will request _lg.webp from S3 and 404. There is no fallback to the original. force=true re-runs are the recovery.

Search pipeline

  • Driver: SCOUT_DRIVER=meilisearch (config/scout.php). Index settings (filterable, sortable, searchable attributes) are declared in the same config file and pushed to Meilisearch via scout:sync-index-settings.
  • Product write path: Product uses Laravel\Scout\Searchable. On save, Scout calls shouldBeSearchable() — which requires both at least one tag and at least one category (app/Models/Product.php:377). A product with no tags or no categories is silently absent from search. This is the most common "product missing from storefront" cause.
  • Indexed shape: toSearchableArray() denormalizes categories, tags, manufacturer, pricing, regulatory fields, identifiers (UPC/NDC), and inventory into one Meilisearch document. So edits to a related model do not automatically refresh the product document.
  • Category/SubCategory / tag / manufacturer edits: The fix for the stale-related-data problem is the app:index-models command. Observers / admin actions dispatch Artisan::queue('app:index-models', ['model' => 'Product']) which runs scout:flushscout:importscout:sync-index-settings. This is a full reindex, not a delta — it is the right hammer when category names or tag slugs change.
  • Search read path: The Nuxt storefront talks directly to Meilisearch using NUXT_PUBLIC_MEILISEARCH_URL and NUXT_PUBLIC_MEILISEARCH_KEY (a public/search-only key, not the master key). The Laravel API is not in the search hot path.

Integrations

ShipHero (3PL)

ShipHero is the source of truth for inventory and fulfillment only — not catalog. Outbound: OrderService::createShipHeroOrder pushes new orders via GraphQL through ShipHeroService. Inbound: shipment status arrives on POST /api/webhooks/shiphero/shipment-update. A family of Artisan commands handles sync in both directions: SyncProductsFromShipHero, SyncManufacturersToShipHero, SyncMetadataToShipHero. ShipHero cannot store pharmacy-specific fields (NDC, active ingredient, regulatory flags, multi-tier pricing) — those live only in Postgres and must be loaded from CSV or the Filament admin. Full reference: /SHIPHERO.md.

HealthInsurance.com SSO

Agents at HealthInsurance.com / partner carriers initiate login while on the phone with a member. The partner redirects to Google OAuth with agent and member info base64-encoded in the state parameter; Google returns to GET /api/oauth/healthcareinsurance/callback (HealthcareInsuranceCallbackAction). The API exchanges the code for a Google token, upserts the agent and member, and starts an impersonation session so the agent acts as the member for the rest of the visit. POST /api/sso/exchange is the storefront-facing token exchange. Full reference: /HEALTHCAREINSURANCE.md.

Stripe (planned)

stripe/stripe-php is already a composer dependency and PaymentService is in place as a scaffold. No live Stripe routes yet — card capture at checkout is not wired. When wired this will be the second payment leg after WEX/purse for any amount above the benefit allowance.

WEX (planned)

WEX is the benefit-card processor that will let members spend their plan-issued card directly. Today the purse (PurseService) acts as an internal stand-in for the benefit balance — orders are gated on purse balance, not a real-time WEX authorization. WEX integration replaces that with a real authorization call.

AWS S3 + CloudFront

S3 holds product, category, and manufacturer images. CloudFront fronts the buckets for delivery. The Laravel disk is s3 (league/flysystem-aws-s3-v3), used both by the Filament uploads and by GenerateImageVariantsJob. The storefront's nuxt.config.ts allow-lists the three bucket hosts (otc-stage, stage-otc, easyotc) for @nuxt/image.

Deployment topology

EnvStorefrontAPISearchImage bucketNotes
devbun run dev (Vite on local), proxies /apihttp://localhost:8000 (see nuxt.config.ts vite proxy)composer dev runs artisan serve + queue:listen + pail + npm run dev concurrentlyLocal Meilisearch on :7700Local S3 disk or shared otc-stage bucketSQLite supported but Postgres recommended for parity.
stagestage.easyotc.com (Nuxt SSG → S3 bucket stage-otc + CloudFront, DNS via Cloudflare). Tenant-specific: stage-goldkidney.easyotc.com → bucket stage-goldkidney.Forge VPS, auth:api, Horizon daemon, hourly health:check scheduled.Self-hosted Meilisearch on the same or a sidecar Forge box.otc-stage / stage-otc S3 buckets.Deploy: bun run generate produces .output/public, uploaded by the @stacksjs/ts-cloud config in cloud.config.ts.
productioneasyotc.com (Nuxt SSG → S3 + CloudFront).Forge VPS (separate from stage). Horizon daemon. Scheduler runs subscription billing daily (06:00 process-due, 09:00 reminders).Self-hosted Meilisearch.easyotc.s3.amazonaws.com.AWS region us-east-1, profile eliinova. DNS: Cloudflare.

Recurring jobs in every long-running env (from routes/console.php and Horizon):

  • subscriptions:process-due daily at 06:00 — generates subscription orders, pushes to ShipHero.
  • subscriptions:send-reminders daily at 09:00 — upcoming-delivery reminders.
  • health:check --notify-on-success hourly — non-production only.
  • Horizon workers: image variants, Scout reindex (app:index-models), subscription order creation.

Where things break first, in practice:

  • Horizon stopped → images render but new uploads have no variants → storefront 404s on _lg.webp.
  • Meilisearch down → storefront search returns empty; /store/* recommendation endpoints (which hit Postgres directly) still work.
  • ShipHero outage → orders still succeed in Postgres (failure is swallowed in OrderService::createShipHeroOrder) but never fulfill. Watch failed_jobs and the ShipHero error logs.
  • Product missing from storefront → 90% of the time shouldBeSearchable() returned false (no tags or no categories). Run php artisan app:index-models Product after fixing.