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.
Flags live in our database and are evaluated by our backend. There's no SDK in the browser and no external dependency at runtime.
A simple dashboard in our admin, gated by a manage-feature-flags permission. Content Dev can change their own settings.
Flags are defined in code, so the admin only shows flags the code actually reads, and every change is audit-logged.
Backend code uses Feature::active(), and every frontend gets the same resolved values from one endpoint.
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
| Area | Today |
|---|---|
| Flags | 121 active (118 boolean, 3 multivariate). 94 serve one value to everyone in production. |
| Targeting | 27 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. |
| Backend | Laravel 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. |
| Frontends | 9 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 art | lcms already resolves flags on the server and passes them to its frontend (ResolveFeatures.php → useFeatureFlags.ts). |
| Done already | done Usage baseline, dead flags archived, shared anonymous key in all apps (SPPLT-20034 children). |
02 Target architecture
Today
Proposed
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:
- Kill switch off or archived: serve the off value.
- User override, e.g. a tester or a customer-specific exception.
- Account override, e.g. one company or school gets the feature early.
- Audience rules in priority order: K-12, post-secondary, commercial, reseller users, role, company, platform, test users.
- Percentage rollout:
crc32(flag_key + user_uuid) % 100 < pct, stable for each user. - Default value
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
| Method | Path | Notes |
|---|---|---|
| GET | /api/v1/features | Logged-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/public | No auth. Only flags marked public (e.g. anonymousVisitorsCanSeeLibraryContent, safeLoginRedirect). Edge-cached for 60 seconds. |
| CRUD | /api/v1/admin/feature-flags | Requires the manage-feature-flags permission. Delete means archive. |
| POST / DELETE | …/{key}/overrides | Add or remove a user or account override (look up by email or uuid; stored by uuid). |
| PUT | …/{key}/audiences | Replace the ordered audience rules. |
| GET | …/{key}/history | Activity log. |
Frontends keep their current API
- Nuxt 2 (platform-frontend, solidcareer, var-portal, platform-admin):
plugins/feature-flags.jsnow fetches from/features.$getFeatureFlag,mapFeatureFlags, thefeatureFlagsmixin and the Vuex module stay the same. - Vue 3 (library, talent-marketplace, splt-frontend): shared-components exports
useFeatureFlag(key, default)plus auseLDFlag-compatible shim, soGlobalHeaderandusePermissionsswitch over in one release. - vilt-frontend: a small wrapper exposes
$ld.flagsbacked 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 →
Summary cards, search, filters by status and team, and a quick toggle with confirmation.
Kill switch, default value, rollout slider, audiences, user and account overrides, and visibility.
Every change shows its old and new values, author and time.
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
Usage baseline, dead flags archived, shared anonymous key in every app.
/featuresand/features/publicthrough 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.
- Tables, models,
InHouseFlagDriver(cached, versioned cache key), the Audience enum and resolvers. - A
FlagScopeobject replacesLaunchDarklyContext. - Admin API with policy, permission and activity log.
flags:exportandflags:import.
- 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-flagspermission.
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
- Import:
php artisan flags:import-launchdarklyreads the LD API and copies keys, types, prod values and descriptions. Individual targets become overrides; segments and role clauses become audiences. - Review: an engineer checks each of the 27 targeted flags in the new admin against LD.
- Shadow mode (2 weeks): the driver evaluates both LD and in-house and logs any mismatch to Datadog. LD's answer is still served.
- Cutover: set
PENNANT_STORE=inhousein Doppler, staging first, then prod. Rollback is setting it back. - Remove: the LD driver, service provider, SDK packages and Doppler keys. Archive the LD project.
09 Ticket breakdown
| # | Ticket | Size |
|---|---|---|
| 2.1 | Backend: GET /features and /features/public | M |
| 2.2 | shared-components: useFeatureFlag plus useLDFlag shim, release | M |
| 2.3–2.5 | library, talent-marketplace and splt-frontend move to the shared composable | S each |
| 2.6–2.9 | Nuxt 2 plugin rewrite: var-portal, platform-admin, platform-frontend, solidcareer | S–M each |
| 2.10 | vilt-frontend wrapper replacing vue-ld | S |
| 3.1–3.6 | Migrations and models · driver and caching · audiences · FlagScope · admin API and audit · export/import | S–M each |
| 4.1–4.5 | Admin scaffold · Switch and DataTable · dashboard · flag detail · history | S–L |
| 5.1–5.4 | LD importer · shadow mode · cutover runbook · LD removal | S–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::beforegives every SolidProfessor-account user every ability. The flags policy must check the permission explicitly. - Pre-login flags.
/features/publicis 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.
$trackLaunchDarklyEventin 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.