Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Feature Flags in Laravel: Ship Safely Without Long-Lived Branches

About Post

The feature branch was supposed to live for a week. It's now six weeks old, and main has moved on without it. The merge conflict list scrolls. Two people are scared to touch it. Someone suggests "just merging it on Friday and seeing what happens."

Long-lived branches feel safe because unfinished work stays out of production. But they move the risk to the merge, which is the worst possible moment to discover it.

Feature flags flip that around. You merge small pieces into main every day, deploy them, and keep them switched off until they're ready. The code ships continuously. The feature launches when you decide.

A feature flag is just an if

That's honestly all it is at its core:

if (Feature::active('new-payment-page')) {
    return view('payments.new');
}

return view('payments.legacy');

What makes it powerful is who decides the answer, and when. A good flag system lets you change the answer without deploying, per user, per group, or for a percentage of everyone. Deploy and release become two separate events.

Laravel has an official package for this: Laravel Pennant. It's small, it's first-party, and it does exactly what most apps need.

Setting up Pennant

composer require laravel/pennant
php artisan vendor:publish --provider="Laravel\Pennant\PennantServiceProvider"
php artisan migrate

That creates a features table. With the default database driver, Pennant stores the resolved value of each flag for each user, which matters more than it sounds. We'll come back to that.

Define flags in a service provider's boot() method:

use Illuminate\Support\Lottery;
use Laravel\Pennant\Feature;

Feature::define('new-payment-page', fn (User $user) => match (true) {
    $user->isStaff() => true,
    default => Lottery::odds(1 / 10),
});

Staff always get the new page, so your own team tests it first in production. Everyone else has a one-in-ten chance. Pennant checks against the authenticated user by default, and Feature::for($tenant)->active(...) lets you check a specific one.

Using flags in the right places

Besides the plain if, Pennant gives you a Blade directive for views:

@feature('new-payment-page')
    @include('payments.partials.new-summary')
@else
    @include('payments.partials.summary')
@endfeature

And middleware for whole routes, which returns a 400 response by default when the flag is off (you can customise it):

use Laravel\Pennant\Middleware\EnsureFeaturesAreActive;

Route::get('/payments/v2', NewPaymentController::class)
    ->middleware(EnsureFeaturesAreActive::using('new-payment-page'));

Try to check a flag in as few places as possible. One check at the entry point (a route, a controller, a top-level component) is easy to remove later. Twenty checks scattered through the code are a cleanup project.

The kill switch

This is the part that makes Friday deploys boring. If the new page misbehaves, you don't revert and redeploy. You run one line, from Tinker or a small admin screen:

Feature::deactivateForEveryone('new-payment-page');

Everyone is back on the old path in seconds, while you fix the problem calmly. One detail from the docs: this updates the values Pennant has already stored. A user who has never been evaluated still goes through your definition, so follow up by changing the definition too (or have it read a setting you can flip).

For risky integrations, like a new payment gateway or an external API, it's worth adding a permanent "operational" flag for exactly this reason.

The gradual rollout gotcha

Here's what catches people. Because Pennant stores each user's resolved value, changing the odds from 1 in 10 to 1 in 2 does nothing for users who already got false. Their answer is saved.

To re-evaluate, you purge the stored values with php artisan pennant:purge new-payment-page. But with a lottery, purging re-rolls everyone, so some users who had the new page will lose it. That's a confusing experience.

The fix is to make the rollout deterministic. Put each user in a stable bucket from 0 to 99 based on their ID:

Feature::define('new-payment-page', fn (User $user) => match (true) {
    $user->isStaff() => true,
    default => crc32((string) $user->id) % 100 < 10, // 10%
});

Now when you change 10 to 50 and purge, everyone who had the feature still has it, because their bucket hasn't moved. The rollout only ever grows.

Every flag is a small piece of technical debt. When you create one, decide when it dies: "remove two weeks after 100% rollout". Put it in the ticket. Flags without an end date become permanent branches hiding inside your code.

Testing both paths

A flag means two versions of your app exist at once, and both need to work. In tests, redefine the flag to force each path:

public function test_new_payment_page_is_shown_when_enabled(): void
{
    Feature::define('new-payment-page', true);

    $this->actingAs(User::factory()->create())
        ->get('/payments')
        ->assertViewIs('payments.new');
}

Write the matching test with false. When you remove the flag, the old test goes with it.

Cleaning up

Once a feature is fully rolled out and stable, finish the job:

  1. Remove the old code path and every check of the flag.
  2. Remove the definition.
  3. Run php artisan pennant:purge new-payment-page to clear its stored values.
  4. Delete the tests for the old path.

Kinds of flags age differently. Release flags should live for weeks. Experiment flags live until you've decided. Operational kill switches and permission-style flags ("this client has the reports module") can live for years, and that's fine, as long as you know which kind each one is.

In short

  • Merge small, deploy daily, release with a flag.
  • Check flags at entry points, not everywhere.
  • Keep a kill switch for anything risky.
  • Use stable buckets for percentage rollouts.
  • Give every release flag an expiry date, and keep it.

How many feature flags are hiding in your codebase right now that should have been removed months ago? Be honest. I'll go first: more than zero.

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close