SPIKE · SPPLT-20034

In-House Feature Flags: off LaunchDarkly, managed in our admin

Status Proposal  ·  Owner Kevin Pimentel
Dated 2026-10-23  ·  Doc design-docs/feature-flags/in-house-feature-flags-proposal.md

00 Why do this

Feature flags are a simple idea: a named switch, an on/off or a value, sometimes different for a few accounts or users. We use a third-party service for this today, and every browser that loads one of our apps talks to it directly.

ownership
No vendor in the loop

Flags live in our database and are evaluated by our backend. There's no SDK in the browser and no external dependency at runtime.

self-serve
Anyone authorized can manage flags

A simple dashboard in our admin, gated by a manage-feature-flags permission. Content Dev can change their own settings.

trust
A flag either works or it doesn't exist

Flags are defined in code, so the admin only shows flags the code actually reads, and every change is audit-logged.

one path
One way to read a flag

Backend code uses Feature::active(), and every frontend gets the same resolved values from one endpoint.

Real example from this investigation

skillAssessmentRetakeDelaySeconds was believed to be Content Dev's way to remove the 14-day retake lock. No code had read it since Oct 2024 (SPPLT-11751 moved retake timing to the backend), so flipping it did nothing. In-house flags defined in code prevent this kind of silent drift.

01 Where we are today verified 2026-10-23

AreaToday
Flags121 active (118 boolean, 3 multivariate). 94 serve one value to everyone in production.
Targeting27 flags. Segments (k12, commercial, post-secondary, reseller, test users) on 11, role on 5, individual users on several (55 users total), account on 3, plus a handful of reseller, company, language and platform clauses. 1 percentage rollout.
BackendLaravel Pennant with a custom LD driver (LaunchDarklyDriver.php). 19 keys in app/Enums/Feature.php, about 10 raw string keys, and dynamic skill keys. Pennant's features table already exists.
Frontends9 apps in 3 shapes. Four Nuxt 2 apps share one plugin ($getFeatureFlag, mapFeatureFlags, Vuex). The Vue 3 apps use useLDFlag via shared-components. vilt-frontend uses vue-ld.
Prior artlcms already resolves flags on the server and passes them to its frontend (ResolveFeatures.php → useFeatureFlags.ts).
Done alreadydone Usage baseline, dead flags archived, shared anonymous key in all apps (SPPLT-20034 children).

02 Target architecture

Today

Nuxt 2 apps
Vue 3 apps
Backend
↓ browser SDKs    ↓ browser SDKs    ↓ server SDK
LaunchDarkly (flags, rules, evaluation, UI)
↑ managed in a vendor dashboard
Engineers with LD seats

Proposed

Admin app (Vue 3) · Feature Flags module
↓ /api/v1/admin/feature-flags (permission + audit log)
Our DB: flags · audiences · overrides
↓ InHouseFlagDriver (Pennant, cached)
platform-backend
↓ GET /api/v1/features  ·  /features/public
Nuxt 2 plugin
useFeatureFlag
vilt wrapper

Browsers only call our API. The backend is the single place flags are evaluated, and changing storage later is a backend-only change.

03 How a flag is evaluated

For the current user and active account, the first match wins:

  1. Kill switch off or archived: serve the off value.
  2. User override, e.g. a tester or a customer-specific exception.
  3. Account override, e.g. one company or school gets the feature early.
  4. Audience rules in priority order: K-12, post-secondary, commercial, reseller users, role, company, platform, test users.
  5. Percentage rollout: crc32(flag_key + user_uuid) % 100 < pct, stable for each user.
  6. Default value
Audiences, not a rule builder

Audiences are a fixed list defined in code (a PHP enum, each with a tested resolver). The admin picks from a dropdown. This covers every targeting rule in production today without rebuilding LD's rule engine.

04 Data model platform-backend

-- one row per flag
feature_flags
  id, key UNIQUE  -- letters, numbers, . - _ (no colons)
  type            -- boolean | string | number | json
  enabled         -- kill switch
  default_value, off_value, rollout_percentage, rollout_value
  description, owner_team, tags, expires_at
  client_visible, public  -- served to browsers / pre-login
  archived_at, timestamps

-- ordered audience rules
feature_flag_audiences
  id, feature_flag_id, audience, audience_params, value, priority

-- per-user / per-account exceptions
feature_flag_overrides
  id, feature_flag_id, scope_type (user|account), scope_id (uuid), value, note, created_by
  UNIQUE (feature_flag_id, scope_type, scope_id)

Change history uses the existing spatie/laravel-activitylog: who, what, when, plus the old and new values. Flags are defined in code with FeatureFlag::define(...) in a migration, so every environment gets them with safe defaults. flags:export and flags:import promote values between environments.

05 APIs

MethodPathNotes
GET/api/v1/featuresLogged-in user. Returns all client-visible flags resolved for the user and active account. All keys, because solidcareer builds skill-assessment-${tag} dynamically.
GET/api/v1/features/publicNo auth. Only flags marked public (e.g. anonymousVisitorsCanSeeLibraryContent, safeLoginRedirect). Edge-cached for 60 seconds.
CRUD/api/v1/admin/feature-flagsRequires the manage-feature-flags permission. Delete means archive.
POST / DELETE…/{key}/overridesAdd or remove a user or account override (look up by email or uuid; stored by uuid).
PUT…/{key}/audiencesReplace the ordered audience rules.
GET…/{key}/historyActivity log.

Frontends keep their current API

  • Nuxt 2 (platform-frontend, solidcareer, var-portal, platform-admin): plugins/feature-flags.js now fetches from /features. $getFeatureFlag, mapFeatureFlags, the featureFlags mixin and the Vuex module stay the same.
  • Vue 3 (library, talent-marketplace, splt-frontend): shared-components exports useFeatureFlag(key, default) plus a useLDFlag-compatible shim, so GlobalHeader and usePermissions switch over in one release.
  • vilt-frontend: a small wrapper exposes $ld.flags backed by the API.
  • Flags refresh on login, account switch and navigation. No app depends on live streaming today.

06 The admin module

The upgraded platform-admin, a new Vue 3 app with Vite, Pinia, TypeScript, @solidprofessorhub/auth, authz and shared-components, with Feature Flags as its first module. Other platform-admin pages move over later. Try the prototype →

dashboard
Find any flag in seconds

Summary cards, search, filters by status and team, and a quick toggle with confirmation.

detail
Everything on one screen

Kill switch, default value, rollout slider, audiences, user and account overrides, and visibility.

history
Who changed what

Every change shows its old and new values, author and time.

hygiene
Stale flags surface themselves

Expiry dates and "fully rolled out for 30+ days" badges prompt cleanup.

Reuses BaseBadge, FormModal, ConfirmationModal, the form fields, Pagination and BaseButton from shared-components. Adds two shared components: Switch and DataTable.

07 Phases

0–1
Baseline and quick wins done

Usage baseline, dead flags archived, shared anonymous key in every app.

Phase 2
The backend serves flags next
  • /features and /features/public through Pennant, with LD still the store behind it.
  • Drop-in frontend adapters, migrated one app per PR: library → TMP → splt → var-portal → platform-admin → platform-frontend → solidcareer.
  • Re-point the Playwright mocks, then remove the browser SDKs. Browsers stop talking to LD.
Phase 3
In-house store parallel with 2
  • Tables, models, InHouseFlagDriver (cached, versioned cache key), the Audience enum and resolvers.
  • A FlagScope object replaces LaunchDarklyContext.
  • Admin API with policy, permission and activity log. flags:export and flags:import.
Phase 4
Admin app after 3's API
  • Vue 3 admin scaffold with auth and navigation; Switch and DataTable in shared-components.
  • Dashboard, flag detail, audiences and overrides, history. Seed the manage-feature-flags permission.
Phase 5
Migrate and exit LD
  • flags:import-launchdarkly, two weeks of shadow mode, then cut over (staging, then prod).
  • Remove LD code, packages and secrets. Don't renew at the Jul 31, 2027 term end.

08 Migrating off LD safely

  1. Import: php artisan flags:import-launchdarkly reads the LD API and copies keys, types, prod values and descriptions. Individual targets become overrides; segments and role clauses become audiences.
  2. Review: an engineer checks each of the 27 targeted flags in the new admin against LD.
  3. Shadow mode (2 weeks): the driver evaluates both LD and in-house and logs any mismatch to Datadog. LD's answer is still served.
  4. Cutover: set PENNANT_STORE=inhouse in Doppler, staging first, then prod. Rollback is setting it back.
  5. Remove: the LD driver, service provider, SDK packages and Doppler keys. Archive the LD project.

09 Ticket breakdown

#TicketSize
2.1Backend: GET /features and /features/publicM
2.2shared-components: useFeatureFlag plus useLDFlag shim, releaseM
2.3–2.5library, talent-marketplace and splt-frontend move to the shared composableS each
2.6–2.9Nuxt 2 plugin rewrite: var-portal, platform-admin, platform-frontend, solidcareerS–M each
2.10vilt-frontend wrapper replacing vue-ldS
3.1–3.6Migrations and models · driver and caching · audiences · FlagScope · admin API and audit · export/importS–M each
4.1–4.5Admin scaffold · Switch and DataTable · dashboard · flag detail · historyS–L
5.1–5.4LD importer · shadow mode · cutover runbook · LD removalS–M each

10 Risks & open questions

  • No live updates. A toggle shows up on the next fetch, not instantly. No code depends on streaming, but product should confirm this is acceptable.
  • Admin authorization. Gate::before gives every SolidProfessor-account user every ability. The flags policy must check the permission explicitly.
  • Pre-login flags. /features/public is opt-in per flag, so internal flags never leak to logged-out visitors.
  • Audience parity. 11 segment-based flags must resolve exactly as they do in LD. Shadow mode is the safety net.
  • PII. Some LD rules target individual emails. These become uuid-keyed overrides, with no emails stored in flag rows.
  • Experiments. $trackLaunchDarklyEvent in platform-frontend: confirm it's unused or move it to Amplitude.
  • Ownership. The new admin app needs an owning team (SP Admin / Core Platform).

11 Success criteria

  • No requests to LaunchDarkly from any app (browser network tab and LD usage page).
  • Shadow mode shows zero mismatches for 2 weeks across all flags, including dynamic skill-assessment keys.
  • Content Dev changes a flag without engineering, and the change appears in history.
  • The LD subscription isn't renewed at Jul 31, 2027.
Interactive prototype

Mock data using real flag keys. Changes stay in this browser tab and reset on reload. Try toggling, rollouts, overrides and creating a flag.