log in
consulting hosting industries the daily tools about contact

Roll Your Own Feature Flags vs. LaunchDarkly: The 12-Month Reckoning

I've built the DIY feature flag store twice. Here's the honest ops cost after 12 months that nobody talks about when they say 'it's just a database table'.

The first time I built a feature flag system from scratch, I was proud of it. Took maybe a day and a half. Fit neatly in one migration and a service class. The second time I built one, I knew better — and I still did it anyway, for a client who didn't want to pay $300/month for LaunchDarkly. Twelve months later I had a clear-eyed answer to the build-vs-buy question, and it wasn't the one either of us expected.

What Feature Flags Actually Are (In Practice)

Market copy will tell you feature flags are about progressive delivery and trunk-based development and all that. Fine. In practice, here's when I actually reach for them:

  • I want to deploy code on a Friday without turning it on until Monday morning
  • A healthcare client needs me to enable a new billing module for their test accounts before it goes live system-wide
  • I'm doing a phased rollout to 5% of users and I need to be able to kill it from a dashboard, not a deploy
  • A feature is 80% done and I want it in main without it showing up in production

That's it. Real uses. The problem a flag system solves is decoupling your deploy from your release, and giving a non-engineer a lever they can pull without calling you.

The DIY Version That Looks Totally Fine

Here's what I built the first time, and I'll be honest — it's not bad:

CREATE TABLE feature_flags (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    `key` VARCHAR(100) NOT NULL UNIQUE,
    enabled TINYINT(1) NOT NULL DEFAULT 0,
    rollout_percentage TINYINT UNSIGNED NOT NULL DEFAULT 100,
    allowed_user_ids JSON NULL,
    allowed_env JSON NULL,
    notes TEXT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

And the Laravel service class:

<?php

namespace App\Services;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use App\Models\User;

class FeatureFlags
{
    private const CACHE_TTL = 60; // seconds

    public function isEnabled(string $key, ?User $user = null): bool
    {
        $flag = Cache::remember("feature_flag:{$key}", self::CACHE_TTL, function () use ($key) {
            return DB::table('feature_flags')->where('key', $key)->first();
        });

        if (!$flag || !$flag->enabled) {
            return false;
        }

        // Environment gate
        $allowedEnvs = json_decode($flag->allowed_env ?? 'null', true);
        if ($allowedEnvs !== null && !in_array(app()->environment(), $allowedEnvs)) {
            return false;
        }

        // Specific user allowlist
        if ($user) {
            $allowedUsers = json_decode($flag->allowed_user_ids ?? 'null', true);
            if ($allowedUsers !== null && in_array($user->id, $allowedUsers)) {
                return true; // bypass percentage
            }
        }

        // Percentage rollout
        if ($flag->rollout_percentage < 100) {
            $bucket = $user
                ? (crc32($user->id . $key) % 100)
                : rand(0, 99);
            return abs($bucket) < $flag->rollout_percentage;
        }

        return true;
    }
}

Usage in a controller:

if (app(FeatureFlags::class)->isEnabled('new_billing_module', $request->user())) {
    return redirect()->route('billing.v2.dashboard');
}

This works. I shipped this to production. It handled a healthcare client's billing rollout without incident. I'm not going to pretend it's broken code.

Here's Where the 12-Month Clock Starts

Month one is fine. Month three is when it starts.

The cache invalidation problem. That 60-second TTL means when the client's ops manager toggles a flag off in response to a live incident, the feature keeps serving for up to a minute. Sixty seconds feels fine in planning. It feels like an eternity when there's a bad UX bug showing for 40% of logged-in users. So you drop the TTL to 10 seconds. Now you're hammering the database every 10 seconds across every app server. You add a Redis pub/sub invalidation hook. That's a real afternoon of work and a new infrastructure dependency.

The audit trail problem. Someone — always someone — will change a flag and not remember doing it. updated_at tells you when. It doesn't tell you who or why. So you add an audit log table. Then you add a policy observer in Laravel to write to it. Then the client wants to see the audit log in the admin panel. That's a day of UI work. Then they want email notifications when a flag is toggled in production. Another hour. This creep is real.

The targeting rules problem. The JSON allowlist I built covers "specific users" and "percentage." Six months in, the biotech client wants: "only users in org_id 14, AND whose account tier is 'enterprise', AND who are in the Pacific timezone." Now you're either cramming business logic into the flag evaluator or you're building a rules engine. I've done both. Neither is fun.

The multi-environment problem. You have local, staging, and production. The flags table is per-database. So now you need a way to sync flag definitions across environments without syncing the enabled state. Migrations don't work well here because the toggle state is data, not schema. I ended up with a seeder that upserts definitions and a documented convention that you never toggle flags in staging to mirror production state. That convention breaks constantly.

The SDK problem. LaunchDarkly publishes SDKs for PHP, Node, Python, Go, and a dozen others. They all speak the same flag definitions. If you have a Laravel app talking to a Python data pipeline and a React frontend, your homegrown system requires you to either expose a flag API endpoint (auth, rate limiting, latency) or reimplement the evaluation logic in each runtime. I watched a client's team spend three days building a REST wrapper around the MariaDB flags table so their data team could query it from Python. That's not free.

What LaunchDarkly Actually Costs You

The Starter plan is $10/seat/month with a 5-seat minimum as of when I'm writing this — call it $50-$100/month for a small team. The Growth plan scales up from there. For a small NWOS client on a $2-5k/month retainer, that's real money as a percentage.

But here's what I logged on two DIY flag projects over 12 months:

Work Item Hours
Initial build 10
Redis pub/sub invalidation 4
Audit log + admin UI 8
Multi-env sync tooling 5
Percentage rollout bug fix (crc32 distribution issue) 2
Targeting rules expansion 6
Python REST wrapper (second project) 7
Debugging production incidents related to stale cache 3
Total 45

At my billing rate, 45 hours is not a rounding error. Even at an internal cost of $75/hour, that's $3,375 over 12 months — against $600-$1,200 for LaunchDarkly Starter. The math is ugly.

And that's the work I tracked. It doesn't include the hour I spent explaining to a client why a flag didn't take effect immediately, or the time I spent writing documentation so their ops person could actually use the admin panel we built.

When I'd Still Roll My Own

I'm not saying never. There are cases where DIY makes sense:

You have exactly one flag type: on/off, no targeting, no rollout. If all you need is "deploy dark, flip a bit, watch metrics, flip back" — and you have one environment and one runtime — the MariaDB approach is fine. I use a stripped-down version of this for my own internal tooling.

You're in a regulated environment with strict data residency requirements. I have healthcare clients where SaaS vendor data flows require legal review. Rolling your own keeps flag evaluation fully inside your VPC.

Your client genuinely cannot or will not pay for third-party tooling. Sometimes the budget is what it is. In that case, go in clear-eyed: budget the 12-month ops cost explicitly in your contract, don't treat it as zero.

When I'd Reach for LaunchDarkly

Anytime I have more than one language runtime touching the same flags. Anytime a non-engineer needs to operate the system. Anytime there's a rollout percentage or targeting segment involved. Anytime a production incident is plausible and you need kill-switch latency measured in milliseconds, not seconds.

The LaunchDarkly SDK integration in Laravel is genuinely straightforward:

use LaunchDarkly\LDClient;

$client = new LDClient(config('services.launchdarkly.sdk_key'));

$context = \LaunchDarkly\LDContext::builder($user->id)
    ->set('email', $user->email)
    ->set('plan', $user->plan_tier)
    ->build();

if ($client->variation('new_billing_module', $context, false)) {
    return redirect()->route('billing.v2.dashboard');
}

Targeting rules, percentage rollouts, audit history, the dashboard for your ops person — all of that is just there. I didn't build it. I didn't maintain it. When the client's ops manager toggles the flag, it propagates via the streaming connection in milliseconds, not after a cache TTL expires.

The Honest Conclusion

The DIY feature flag system is one of those builds that feels cheap until you account for all the work it quietly generates over a year. I've done it twice. I'll probably do it once more for the right client in the right situation — but I'll budget it honestly instead of pretending a migration and a service class is the whole story. For most production systems serving real users, LaunchDarkly earns its fee before Q2.

Related

Need help shipping something like this? Get in touch.