Skip to content
Backend & Architecture•14 min read•Published September 24, 2026

Feature Flags & Progressive Delivery in Laravel SaaS

Shipping behind an if statement isn't a rollout strategy. Here's how flags become an actual release mechanism instead of permanent code debt.

Aqib Javaid
Aqib Javaid
Senior Full-Stack Engineer
Dark tech blog cover of a Laravel dashboard with feature-flag toggles rolling out gradually across percentage rings of tenants

Introduction: A Config Boolean Is Not a Rollout Strategy#

Every Laravel codebase eventually grows a config('features.new_thing') boolean, flipped in .env, checked with an if, and deployed. It works for exactly one scenario: everyone gets the feature, or nobody does, at the moment of deploy. The first time that stops being enough — one dealer wants to pilot a new carrier integration before the rest of the network sees it, one enterprise customer needs a new AI workflow held back until their compliance team signs off, or a change that looked safe in staging needs to come back off in production right now, with no deploy in between — a .env boolean has nothing to offer. Someone SSHes in, edits a file, and reloads PHP-FPM, and the "rollback" is itself now a deployment, with all the risk that a real incident is the worst possible time to be taking on.

The actual failure isn't the boolean itself — it's that a boolean can't answer any of the questions a real rollout needs answered. Who gets this, specifically? What percentage of everyone else? Can it come back off in under a second if it's wrong, without touching a deploy pipeline at all? And — the question almost nobody asks until the flag has been at 100% for eight months — when does this flag itself get deleted, along with the legacy branch it was protecting?

I've had to get this right on products where a rollout mistake has a real cost attached: SwapPad, the bulk SIM activation platform inside CelleUp's dealer back office, where a new carrier routing path needs to run against a handful of pilot dealers for a week before it touches the other four hundred; SafetySpace, the AI-driven safety platform I run as CTO, where a new AI-generated document workflow can't go live for every enterprise customer at once — it needs staged exposure to customers who've explicitly opted into testing, with an instant kill switch if a generation quality regression shows up; MindWrite AI, the subscription AI writing tool, where migrating to a new model provider means running both providers side by side for a slice of traffic before trusting the new one with everyone's usage; SignageFlow, the digital signage platform, where a new player firmware behavior needs to roll out to a percentage of the screen fleet, not all of it, because a bad firmware push to every screen at once is a very different incident than a bad push to five percent; and ReplyVibe, where a new sentiment-classification model gets A/B tested against the one already in production before it's trusted to drive automated replies.

This is a breakdown of how to build feature flags in Laravel that are tenant-aware, roll out by percentage without flapping, kill a feature in under a second without a deploy, and — the part most flag systems never get around to — get deleted once they've done their job.

Architecture: What a Flag System Needs Beyond an if Statement#

Laravel Pennant gives you the primitives — a Feature facade, a persistence store, a clean way to define resolution logic per feature. It doesn't give you the discipline of using those primitives so a flag behaves like a release mechanism instead of a second, hidden configuration file. That discipline comes down to four things.

1. Flags are scoped to a tenant (or user) and resolved from data, not .env#

The moment a flag needs to differ between customers, an environment variable is structurally the wrong tool — it's one process-wide value, not a per-tenant decision. Pennant's scoping is built for exactly this: define the feature once, resolve it per tenant, and let the resolution logic live in a class instead of scattered if checks.

php
// app/Features/NewCarrierRouting.php
class NewCarrierRouting
{ 
    public function resolve(Tenant $tenant): bool
    { 
        $rollout = FeatureRollout::query()->where('key', 'new-carrier-routing')->first();

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

        // Explicit opt-in — the pilot dealers who asked to test this early
        if (in_array($tenant->id, $rollout->pinned_tenant_ids ?? [])) {
            return true;
        }

        return RolloutBucket::for('new-carrier-routing', $tenant->id) < $rollout->percentage;
    }
}
php
// app/Providers/AppServiceProvider.php
Feature::define('new-carrier-routing', fn (Tenant $tenant) =>
    app(NewCarrierRouting::class)->resolve($tenant)
);
php
// Anywhere in the codebase — a controller, a job, a console command
if (Feature::for($dealer->tenant)->active('new-carrier-routing')) {
    return app(NexioCarrierClient::class)->activate($order);
}

return app(LegacyCarrierClient::class)->activate($order);

Pennant persists the resolved value per scope the first time it's computed, so the same tenant gets the same answer on every subsequent check without re-running the resolver on every request — which matters more than it sounds, because it's also the mechanism that keeps a percentage rollout from flapping, covered next.

2. Percentage rollouts use a stable hash bucket, not a fresh coin flip per request#

The naive version of a percentage rollout — roll a random number between 1 and 100 on every request, compare it to the target percentage — has an obvious bug: the same tenant gets a different answer on every single request, which means a customer's UI can flip between the old and new experience mid-session. Pennant's own persistence would normally paper over this, since it stores the first resolution and reuses it — but that creates a second problem: raising a rollout from 10% to 25% shouldn't re-shuffle who's already in, it should only ever add more tenants, never drop the ones already exposed.

The fix is a deterministic hash bucket, computed once from the tenant and the flag's own key, that never changes for that pairing:

php
// app/Support/RolloutBucket.php
class RolloutBucket
{ 
    public static function for(string $flagKey, int $tenantId): int
    { 
        // Stable across every call, and across the rollout percentage changing —
        // a tenant's bucket never moves, so 10% -> 25% only ever adds tenants,
        // it never bumps one who was already in back out.
        return crc32("{$flagKey}:{$tenantId}") % 100;
    }
}

RolloutBucket::for('new-carrier-routing', 4821) < 25 is the entire percentage check — no external state to keep in sync, no risk of a re-shuffle every time an admin nudges the percentage up, and no coordination needed across app servers because the calculation is pure and identical everywhere it runs.

3. Release flags, kill switches, and experiment flags are different lifecycles, not one is_enabled column#

A feature_rollouts table with a single boolean tempts every flag into looking the same, but they don't behave the same and don't deserve the same lifecycle:

Flag typePurposeLifecycle
Release flagGate a new feature during rolloutDeleted once it reaches 100% and stays there
Kill switchTurn off a risky existing capability instantly during an incidentPermanent — lives as long as the capability it protects
Experiment flagCompare two behaviors (a new model, a new UI)Deleted once the experiment concludes and a winner ships

A release flag left in the codebase at 100% forever is silent debt — the if branch it guards, and the dead code path on the other side of it, both keep getting maintained (or worse, quietly diverge) long after the decision was actually made. A kill switch treated as temporary is the opposite mistake — SafetySpace keeps disable-ai-generation and disable-swms-export permanently in place specifically because they're insurance against an incident, not a rollout mechanism, and insurance you delete after using it once isn't insurance.

php
// A kill switch — checked directly, not resolved per-tenant, because an
// incident needs the answer to change instantly for everyone at once
class KillSwitch
{ 
    public static function isActive(string $key): bool
    { 
        return (bool) Cache::remember("kill-switch:{$key}", 30, fn () =>
            FeatureRollout::where('key', $key)->value('enabled') ?? false
        );
    }
}
php
// Checked at the top of the AI generation pipeline on SafetySpace
if (KillSwitch::isActive('disable-ai-generation')) {
    throw new ServiceUnavailableException(
        'Document generation is temporarily paused. Existing documents are unaffected.'
    );
}

The 30-second cache TTL is deliberate: flipping the switch also clears the cache key immediately for near-instant effect, and the short TTL is the fallback safety net for the rare case where that cache-clear doesn't reach every app server the moment it fires — worst case, every server has caught up within thirty seconds, not thirty minutes.

4. Every flag change is audited, and propagates without a deploy#

The entire value of a flag system evaporates if flipping a flag in production is invisible — "who turned this on, and when" needs to be a fast lookup during an incident, not a Slack archaeology exercise. This ties directly into the audit-log discipline from SaaS authorization architecture: a flag change is a privileged action, and it gets the same treatment as a permission grant.

php
class ToggleFeatureRollout
{ 
    public function handle(FeatureRollout $rollout, int $newPercentage, User $actor): void
    { 
        DB::transaction(function () use ($rollout, $newPercentage, $actor) {
            $previous = $rollout->percentage;
            $rollout->update(['percentage' => $newPercentage]);

            AuditLog::record(
                action: 'feature_rollout.percentage_changed',
                actor: $actor,
                subject: $rollout,
                changes: ['from' => $previous, 'to' => $newPercentage],
            );
        });

        Cache::forget("kill-switch:{$rollout->key}");
    }
}

Step-by-Step: Building the Pipeline#

  1. Install Laravel Pennant and back it with the database driver, not the in-memory array driver — a flag's resolved state needs to persist across requests and survive a deploy, which the array driver was never built to do.
  1. Model rollouts as their own table, separate from Pennant's own storage, with key, enabled, percentage, pinned_tenant_ids, and type (release, kill_switch, experiment) columns. Pennant's feature definitions read from this table; the table is what an admin panel actually edits.
  1. Define every feature in one place (AppServiceProvider::boot()), resolving through the RolloutBucket helper for percentage rollouts and a direct kill-switch check for incident-response flags — never inline if logic scattered across controllers.
  1. Build (or reuse) a small admin UI for flipping flags, gated behind the same RBAC permission layer as any other privileged action — a percentage slider and a pinned-tenant list, writing through ToggleFeatureRollout so every change is audited automatically.
  1. Write both branches into the test suite, the same discipline as testing strategy in Laravel SaaS — a flag with only its "on" path tested means the "off" path, the one every existing customer is still running, silently stops being covered the moment the flag is introduced:
php
public function test_new_carrier_routing_is_used_when_flag_active(): void
{
    Feature::define('new-carrier-routing', fn () => true);

    $order = Order::factory()->create();

    $this->mock(NexioCarrierClient::class)->shouldReceive('activate')->once();

    app(ActivateSimJob::class)->handle($order);
}

public function test_legacy_carrier_routing_is_used_when_flag_inactive(): void
{
    Feature::define('new-carrier-routing', fn () => false);

    $order = Order::factory()->create();

    $this->mock(LegacyCarrierClient::class)->shouldReceive('activate')->once();

    app(ActivateSimJob::class)->handle($order);
}
  1. Wire kill switches into every path a genuine incident would need to stop, tested with a fire drill, not just a code review — the check above is worthless if nobody has actually confirmed, before the incident, that flipping it produces the expected ServiceUnavailableException rather than an unrelated 500.
  1. Schedule a job that reports stale flags, because a rollout at 100% for a month is a decision that's already been made and just hasn't been cleaned up yet:
php
class ReportStaleFeatureFlags extends Command
{
    protected $signature = 'flags:report-stale';

    public function handle(): int
    {
        FeatureRollout::query()
            ->where('type', 'release')
            ->where('percentage', 100)
            ->where('updated_at', '<', now()->subDays(30))
            ->each(fn (FeatureRollout $rollout) => Log::channel('ops')->warning(
                "Release flag [{$rollout->key}] has been at 100% for 30+ days — remove the flag and the legacy branch it guards."
            ));

        return self::SUCCESS;
    }
}
  1. Delete the flag and the losing branch together, in the same pull request, once a release flag reaches 100% and stays there, or an experiment concludes — not as a follow-up ticket that sits in the backlog for two quarters while both code paths keep getting maintained in parallel.

Real-World Pitfalls to Avoid#

A percentage rollout that re-shuffles on every change. Recomputing a random bucket each time an admin adjusts the percentage means raising 10% to 15% can silently remove some tenants who were already exposed, which is the opposite of what "progressive" is supposed to mean — a stable hash bucket per tenant avoids this entirely.

Only testing the "off" branch. The "off" branch is what most of your customers are running for most of the flag's life — an untested legacy path is exactly where a regression hides until the flag flips to 100% and every customer hits it at once.

A kill switch nobody has actually fired outside of a real incident. A switch that's never been tested is a switch you're trusting for the first time at the worst possible moment — a quarterly fire drill, flipping it in staging and confirming the expected failure mode, is what makes it trustworthy when it matters.

Flags that gate authorization instead of rollout. A feature flag answers "is this capability available yet"; a permission answers "is this specific user allowed to use it." Conflating the two — checking a flag where a policy belongs — means a customer whose subscription tier doesn't include a feature can still see it the moment the flag reaches 100% for everyone else.

No expiration discipline. A flag with no owner and no review date becomes exactly the kind of permanent .env boolean this whole approach was meant to replace — just with extra indirection. Every release and experiment flag needs a name attached and an expectation of when it goes away.

Resolving flags with a fresh database query on every single check. A flag consulted inside a hot loop or a high-traffic middleware needs its resolution cached or backed by Pennant's own persistence — not a FeatureRollout::where(...)->first() re-run on every request across every tenant.

Treating an experiment flag's "loser" branch as disposable during the test, then keeping both branches forever after. The whole point of an A/B test is that it ends — ReplyVibe's sentiment-model comparisons run for a fixed, decided-in-advance window, not indefinitely, precisely so the losing model's code path gets removed on a known date instead of lingering as an unused option nobody's brave enough to delete.

Key Takeaways#

A feature flag system earns its place in a Laravel SaaS product the same way rate limiting and subscription entitlements do — as a policy resolved from data, tied to who's actually asking, not a .env boolean bolted onto a route.

  • Flags resolve per tenant from a database-backed source, never a process-wide environment variable, because the moment two customers need different answers, .env is structurally the wrong tool
  • Percentage rollouts use a stable hash bucket per tenant so raising the percentage only ever adds exposure, never re-shuffles who's already in
  • Release flags, kill switches, and experiment flags are different lifecycles with different owners and different expiration expectations — one is_enabled column can't represent all three honestly
  • Every flag change is audited and propagates without a deploy, so "who turned this on, and when" is a lookup during an incident, not a guess
  • A flag that reaches 100% and stays there is a decision that's already been made — the flag and the branch it guarded get deleted together, not left as permanent, silently diverging debt

I've built this into a bulk SIM activation platform piloting new carrier routes with a handful of dealers before the rest of the network sees them, an AI safety platform where a generation quality regression needs a switch that works in under a second, a subscription AI tool migrating model providers under live traffic, a signage platform rolling firmware to a percentage of a physical fleet instead of all of it at once, and a sentiment-analysis product running real A/B tests with a fixed end date. The products change; the requirement that a rollout be reversible, tenant-aware, and eventually deleted doesn't.

If your last risky release still shipped behind an .env boolean and a prayer — get in touch about your release architecture or see the full case studies from platforms running tenant-aware, auditable feature flags in production today.

Share this technical insight with your network

Share to LinkedIn or Facebook with key takeaways, featured media, and direct links.

📁 Production Case Study

Case Study: SafetySpace AI-Powered Safety Management Platform & Workflow Automation

SafetySpace is an AI-powered safety management platform designed to help organizations manage safety processes, access critical safety information, and streamline documentation through intelligent, configurable workflows. As CTO, I led the technical direction and development of the platform across backend, frontend, architecture, and AI integrations—turning complex safety workflows into a more intelligent and streamlined digital experience.

Related Technical Articles

View all articles →
✦ Let's Build Together

Have a complex technical project in mind?

Available for full-stack engineering, performance audits, cloud deployments, and high-concurrency systems architecture.

Need a web or software development partner?

Tell me what you’re building, what’s getting in the way, and where you need help. Whether you need a custom web application, SaaS platform, API integration, or full-stack development, I’ll give you a clear answer on scope, cost, and timeline usually within one business day.

AqibJavaid

Senior Full-Stack Engineer building backend systems, cloud infrastructure and product platforms for teams that need them to stay up.

Available for new projects

Get in touch

© 2026 Aqib Javaid. All rights reserved.

Built and maintained by Aqib Javaid