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
| Component | Tech | What it does | Where it lives |
|---|---|---|---|
| Storefront | Nuxt 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. |
| API | Laravel 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 panel | Filament 4 + spatie-tags plugin | Catalog management, member/agent admin, orders, promotions, RBAC (spatie-permission). | Same Laravel app, /admin. |
| Database | Postgres | Source of truth for catalog, orders, members, subscriptions, purses (benefit allowances). | Forge-managed Postgres (or RDS in prod). |
| Cache + queue broker | Redis (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 worker | Laravel Horizon 5 | Runs GenerateImageVariantsJob, GenerateProductImagesJob, Scout indexing, subscription order creation. | Forge daemon on API box. |
| Search | Meilisearch 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 store | AWS S3 + CloudFront | Originals + 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). |
| ShipHero | GraphQL 3PL | Inventory truth, order fulfillment, shipment webhooks. | External SaaS. See app/Services/ShipHeroService.php. |
| HealthInsurance.com | Google OAuth bridge | Agents 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 composer | Card payments at checkout. | External SaaS. |
| WEX (planned) | Benefit card processor | Benefit-card payments for member allowances. | External SaaS. |
| Activity log | spatie-activitylog | Admin audit trail. | DB tables in Postgres. |
| Scheduler | Laravel 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
- 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 theeasyotcS3provider. - 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(seeconfig/scout.php). - Add to cart —
POST /api/cart-items/{product}(auth:api). Cart persisted server-side viaCartService. - Checkout —
POST /api/orders→StoreOrderAction→OrderService::createOrder. Resolves shipping/billing address, applies promotions, computes totals, debits the member's purse (benefit allowance) viaPurseService. - 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.
- Order created — Order row written in Postgres with
status=pending. - ShipHero order push —
OrderService::createShipHeroOrdercallsShipHeroService(GraphQL mutation). Failure is logged but does not fail the order (seeOrderService.php:328) — known risk surface. - Shipment — ShipHero picks, packs, ships. Generates a tracking number.
- Tracking webhook —
POST /api/webhooks/shiphero/shipment-update→ShipHeroWebhookController::shipmentUpdateupdates the order with tracking info. - Email confirmation (planned) — Transactional order-confirmation and shipment-notification email is not yet wired.
SendPromotionalEmailCommandandSendUpcomingDeliveryRemindersCommandexist; 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.
- Upload — Admin uploads an image through a Filament
FileUploadfield (product, category, manufacturer). The file lands on S3 under e.g.images/products/abc.jpg. - Dispatch — The model observer dispatches
GenerateImageVariantsJob($path)onto the Horizon queue. (Backfill:app:generate-image-variantsandapp:generate-category-image-variantsArtisan commands.) - Generate —
GenerateImageVariantsJob::handle()(seeapp/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 unlessforce=true. - CDN — CloudFront fronts the S3 bucket, so variants are cached at edge after the first request.
- Frontend rewrite —
<NuxtImg>calls theeasyotcS3provider (providers/easyotc-s3.ts). It looks at the requestedwidthmodifier, maps it throughSIZE_BUCKETS(<=256 → _sm,<=512 → _md, else_lg), and rewrites…/abc.jpg→…/abc_md.webp. The suffix table in the provider must stay in sync withGenerateImageVariantsJob::SIZES— comment in the provider calls this out. - 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 viascout:sync-index-settings. - Product write path:
ProductusesLaravel\Scout\Searchable. On save, Scout callsshouldBeSearchable()— 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-modelscommand. Observers / admin actions dispatchArtisan::queue('app:index-models', ['model' => 'Product'])which runsscout:flush→scout:import→scout: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_URLandNUXT_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
| Env | Storefront | API | Search | Image bucket | Notes |
|---|---|---|---|---|---|
| dev | bun run dev (Vite on local), proxies /api → http://localhost:8000 (see nuxt.config.ts vite proxy) | composer dev runs artisan serve + queue:listen + pail + npm run dev concurrently | Local Meilisearch on :7700 | Local S3 disk or shared otc-stage bucket | SQLite supported but Postgres recommended for parity. |
| stage | stage.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. |
| production | easyotc.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-duedaily at 06:00 — generates subscription orders, pushes to ShipHero.subscriptions:send-remindersdaily at 09:00 — upcoming-delivery reminders.health:check --notify-on-successhourly — 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. Watchfailed_jobsand theShipHeroerror logs. - Product missing from storefront → 90% of the time
shouldBeSearchable()returned false (no tags or no categories). Runphp artisan app:index-models Productafter fixing.