Blog
Ship It Turned Off
- software-engineering
- laravel
- typescript
- deployment
There's a branch somewhere called new-checkout. It's three weeks old. It touches forty files, it conflicts with everything, and the plan is to merge it on Thursday and hope.
That's not a release plan. That's a coin flip with extra steps.
The fix is to stop treating "deploy" and "release" as the same event. Merge the code. Ship it to production. Leave it turned off. Then turn it on when you're ready, for who you're ready for.
Deploy is not release
A deploy puts code on servers. A release puts a feature in front of people.
When those are the same moment, every deploy is a big bet. When they're separate, a deploy is boring (good) and a release is a switch you can flip back.
That switch is a feature flag. Nothing fancy. An if with a name.
The simplest flag is an if statement
if (Feature::active('new-checkout')) {
return view('checkout.new');
}
return view('checkout.legacy');That's Laravel Pennant, which ships as a first party package. The new checkout can now live on main, get merged in small pieces, and run in production for nobody until you say so.
You define what "active" means in one place, usually a service provider:
use Illuminate\Support\Lottery;
use Laravel\Pennant\Feature;
Feature::define('new-checkout', fn (User $user) => match (true) {
$user->isInternal() => true, // your own rule, however you decide that
default => Lottery::odds(1, 10),
});Your team sees it first. Then one in ten users. Then everybody.
Rollouts need to be sticky
A rollout where the same user flips between old and new on every request is worse than no rollout. Their cart disappears. Support gets a screenshot nobody can reproduce.
Pennant handles this by storing the resolved value per user, so once someone lands in the 10%, they stay there.
If you're rolling your own in TypeScript, don't reach for Math.random(). Hash something stable:
import { createHash } from 'node:crypto';
function inRollout(flag: string, userId: string, percent: number): boolean {
const hash = createHash('sha256').update(`${flag}:${userId}`).digest();
return hash.readUInt32BE(0) % 100 < percent;
}Same user, same flag, same answer every time. Putting the flag name in the hash means the same unlucky 10% aren't the test group for every feature you ever ship.
Know how you'll turn it off
The whole point of a flag is that you can back out. So figure out how before you need to.
If your flag is an env variable and you run php artisan config:cache on deploy, changing it means editing the env and rebuilding the config cache. That's basically a deploy. Fine for some things. Not a kill switch.
Pennant's stickiness has a flip side too. Editing the definition doesn't change anyone who already resolved. To actually roll back:
Feature::deactivateForEveryone('new-checkout');That updates the stored value for everyone who has one. Or purge the stored values so everyone resolves again against the current definition:
php artisan pennant:purge new-checkoutTry it in staging once. Finding out how your off switch works during an incident is a bad time to learn.
Flag the door, not every room
Sprinkling if (flag) across thirty files makes both versions hard to read and harder to delete.
Check the flag once, as close to the edge as you can. A route, a controller, a component near the top of the tree. Then branch into two clean implementations:
export function CheckoutPage({ user }: { user: User }) {
return isEnabled('new-checkout', user) ? <NewCheckout /> : <LegacyCheckout />;
}Old code stays untouched. New code doesn't have to know the old code exists.
Jobs don't have a logged-in user
Feature::active() checks against the authenticated user by default. A queued job has no authenticated user. So pass the scope in explicitly:
public function handle(): void
{
if (Feature::for($this->order->user)->active('new-checkout')) {
// new path
}
}And decide on purpose whether the job should follow the flag at dispatch time or at run time. If someone started checkout on the old flow, finishing it on the new one might not be what you want.
A flag doesn't fix your schema
Flags turn behavior on and off. They don't make a migration safe. If the new feature needs a new column, the old code still has to survive it, flag or no flag. Same rules as any other deploy: add first, switch, remove last.
Test both sides
While the flag exists, both paths are production code. Test both.
public function test_new_checkout_renders_when_enabled(): void
{
Feature::define('new-checkout', true);
$this->actingAs(User::factory()->create())
->get('/checkout')
->assertViewIs('checkout.new');
}Then the same test with false and checkout.legacy. Two tests, and you'll know the day one side quietly breaks.
Delete the flag
Every flag is a fork in your code. Leave enough of them around and nobody knows which combination is actually running in production.
When the rollout hits 100% and stays there for a bit, remove it. Delete the old path, delete the check, purge the stored values.
In TypeScript you can make the compiler help:
type Flag = 'new-checkout' | 'search-v2';
// however your app stores and resolves flags
declare function isEnabled(flag: Flag, user: User): boolean;Remove 'new-checkout' from the union and every leftover call fails to compile. That's your cleanup list, generated for free.
The habit
- Merge unfinished work behind a flag instead of keeping it on a long branch.
- Make rollouts sticky per user.
- Know exactly how to turn it off before you turn it on.
- Check the flag once, near the edge.
- Delete it when the rollout's done.
Ship it turned off. Turn it on when you mean it.