Trunk-Based Development in Practice

Reverend Philip Jan 2, 2026 13 min read

Ship faster with trunk-based development. Learn short-lived branches, feature flags, and continuous integration practices.

Trunk-based development keeps all developers working on a single main branch with short-lived feature branches. This practice enables continuous integration, reduces merge conflicts, and accelerates delivery compared to long-lived feature branches.

The Problem with Feature Branches

Long-Lived Branches

When feature branches live for weeks, they diverge significantly from main. The longer they live, the more painful the eventual merge becomes. Code reviews become overwhelming when branches contain thousands of lines of changes.

The following diagram illustrates how parallel feature branches create an integration nightmare. You can see how the divergence compounds over time, making eventual merges increasingly difficult.

main ─────────────────────────────────────────────────→
        \                                    /
         feature-a (3 weeks) ───────────────┘
              \                         /
               feature-b (2 weeks) ────┘

Problems:
- Merge conflicts grow exponentially
- Integration bugs discovered late
- Features pile up waiting for release
- Code reviews become overwhelming

Trunk-Based Alternative

Trunk-based development takes the opposite approach. Changes merge to main within hours or days, not weeks. Small, frequent integrations prevent the merge debt that accumulates with long-lived branches.

This visualization shows the contrast with trunk-based development. Notice how each integration point is small and frequent, keeping the codebase continuously integrated.

main ──●──●──●──●──●──●──●──●──●──●──●──●──●──●──●──●──→
       │  │  │  │  │  │
       └──┘  └──┘  └──┘
     (hours) (hours) (hours)

Benefits:
- Small, frequent integrations
- Conflicts resolved immediately
- Continuous feedback
- Always releasable

Core Practices

1. Short-Lived Branches

The fundamental rule is simple: branches should live for at most one or two days. Create a branch, make your change, get it reviewed, and merge it. Then start fresh.

Here's a typical workflow for a short-lived branch. You'll create the branch, make your changes, and merge back to main all within the same day or the next day at latest.

# Create feature branch
git checkout -b feature/add-user-avatar

# Work in small increments
git add .
git commit -m "Add avatar upload endpoint"

# Merge same day (or next day at latest)
git checkout main
git pull origin main
git merge feature/add-user-avatar
git push origin main
git branch -d feature/add-user-avatar

Maximum branch lifetime: 1-2 days.

2. Small, Frequent Commits

Break work into small, independently valuable changes. Each commit should be a complete, working increment. This makes code review easier and enables faster feedback.

The contrast below shows the difference between a monolithic commit and properly broken-down incremental commits. You can see how each smaller commit tells a story and is easier to review and revert if needed.

# Bad: One giant commit after a week
git commit -m "Add user management feature"
# 47 files changed, 2,847 insertions(+), 412 deletions(-)

# Good: Multiple small commits throughout the day
git commit -m "Add User model avatar field"
git commit -m "Add avatar upload validation"
git commit -m "Add avatar storage service"
git commit -m "Add avatar upload API endpoint"
git commit -m "Add avatar display in profile view"

3. Commit Directly to Main (for Experienced Teams)

Mature teams with strong test coverage can skip branches entirely for small changes. This requires discipline and trust, but eliminates the overhead of branch management for trivial fixes.

For simple changes like typo fixes or small bug corrections, experienced teams can commit directly to main. This workflow assumes you have strong automated testing in place.

# Senior developers can commit directly
git checkout main
git pull origin main
# Make small change
git add .
git commit -m "Fix typo in user validation message"
git push origin main

This requires:

  • Strong test coverage
  • CI pipeline that runs fast
  • Team trust and discipline

Feature Flags

Incomplete Features in Production

How do you merge incomplete features to main without exposing them to users? Feature flags let you deploy code without activating it. The code exists in production but remains invisible until you flip the flag.

This example shows a basic feature flag configuration in Laravel. You'll define your flags in a config file and check them in your controllers to conditionally enable new functionality.

// config/features.php
return [
    'new_checkout_flow' => env('FEATURE_NEW_CHECKOUT', false),
    'user_avatars' => env('FEATURE_AVATARS', false),
];

// Usage in code
class CheckoutController
{
    public function index()
    {
        if (Feature::enabled('new_checkout_flow')) {
            return $this->newCheckoutFlow();
        }

        return $this->legacyCheckoutFlow();
    }
}

Feature Flag Service

A robust feature flag system supports multiple rollout strategies. You can target specific users, percentage rollouts, or beta groups without deploying new code.

The following service class demonstrates a sophisticated feature flag implementation. You can use it to roll out features gradually to specific users, percentage-based audiences, or beta testers.

class FeatureFlag
{
    public function enabled(string $feature, ?User $user = null): bool
    {
        $config = $this->getConfig($feature);

        // Global kill switch
        if (!$config['enabled']) {
            return false;
        }

        // Percentage rollout
        if ($config['rollout_percentage'] < 100) {
            $hash = crc32($user?->id ?? request()->ip());
            if ($hash % 100 >= $config['rollout_percentage']) {
                return false;
            }
        }

        // User allowlist
        if ($user && in_array($user->id, $config['allowed_users'])) {
            return true;
        }

        // Beta group
        if ($user && $user->isBetaTester() && $config['beta_enabled']) {
            return true;
        }

        return $config['enabled'];
    }
}

The hash-based percentage rollout ensures users get a consistent experience. The same user always sees the same version rather than randomly switching between them.

Gradual Rollout

Feature flags enable progressive rollout where you start with internal testing and gradually expand to all users. If problems emerge, you can disable the feature instantly without a deployment.

This timeline shows a typical gradual rollout strategy. You'll start with team-only access, expand to beta users, then progressively increase the percentage until full rollout.

// Day 1: Internal testing
'new_checkout' => [
    'enabled' => true,
    'rollout_percentage' => 0,
    'allowed_users' => [1, 2, 3],  // Team only
],

// Day 2: Beta users
'new_checkout' => [
    'enabled' => true,
    'rollout_percentage' => 0,
    'beta_enabled' => true,
],

// Day 3: 10% of users
'new_checkout' => [
    'enabled' => true,
    'rollout_percentage' => 10,
],

// Day 5: 50% of users
'rollout_percentage' => 50,

// Day 7: All users
'rollout_percentage' => 100,

// Day 14: Remove flag, delete old code

Branch by Abstraction

Replacing a Component Safely

For larger refactoring that spans multiple commits, branch by abstraction lets you incrementally replace a component. You create an abstraction layer, implement the new version behind it, and switch over when ready.

The following pattern walks through a five-step process for safely replacing a payment processor. Each step can be merged to main independently, keeping the codebase always deployable.

// Step 1: Create abstraction
interface PaymentProcessor
{
    public function charge(Order $order): PaymentResult;
}

// Step 2: Wrap existing implementation
class LegacyPaymentProcessor implements PaymentProcessor
{
    public function charge(Order $order): PaymentResult
    {
        return $this->oldPaymentSystem->processPayment($order);
    }
}

// Step 3: Create new implementation
class StripePaymentProcessor implements PaymentProcessor
{
    public function charge(Order $order): PaymentResult
    {
        return $this->stripe->charges()->create([
            'amount' => $order->total_cents,
            'currency' => 'usd',
        ]);
    }
}

// Step 4: Feature flag to switch
class PaymentProcessorFactory
{
    public function make(): PaymentProcessor
    {
        if (Feature::enabled('stripe_payments')) {
            return new StripePaymentProcessor();
        }

        return new LegacyPaymentProcessor();
    }
}

// Step 5: After full rollout, remove old code

Each step merges to main independently. The old and new implementations coexist safely until you are confident in the replacement.

CI/CD Requirements

Fast Build Pipeline

Trunk-based development requires fast CI. If builds take 30 minutes, developers cannot get rapid feedback on their changes. Target under 10 minutes for the full pipeline.

This GitHub Actions workflow demonstrates the essential CI setup for trunk-based development. You'll want to cache dependencies and run tests in parallel to keep build times under 10 minutes.

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'
          coverage: xdebug

      - name: Cache dependencies
        uses: actions/cache@v3
        with:
          path: vendor
          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}

      - name: Install dependencies
        run: composer install --prefer-dist --no-progress

      - name: Run tests
        run: php artisan test --parallel

      # Target: < 10 minutes total

Automated Deployment

With trunk-based development, main should always be deployable. Automated deployment on every merge to main ensures you are continuously delivering value.

Add this deployment job to your workflow to automatically deploy when tests pass. This completes the continuous delivery loop.

deploy:
  needs: test
  if: github.ref == 'refs/heads/main'
  runs-on: ubuntu-latest
  steps:
    - name: Deploy to production
      run: |
        # Automatic deployment on every main push
        ./deploy.sh production

Pre-Commit Hooks

Local validation catches issues before they reach CI. Pre-commit hooks run fast checks that prevent obviously broken code from being committed.

Set up this pre-commit hook to run linting and unit tests locally before each commit. This catches issues early and saves CI time.

#!/bin/bash
# .git/hooks/pre-commit

# Run linting
./vendor/bin/pint --test
if [ $? -ne 0 ]; then
    echo "Code style issues found. Run ./vendor/bin/pint to fix."
    exit 1
fi

# Run fast tests
php artisan test --filter=Unit
if [ $? -ne 0 ]; then
    echo "Unit tests failed."
    exit 1
fi

Code Review Practices

Small Pull Requests

Small pull requests get reviewed quickly and thoroughly. Large pull requests get skimmed or rubber-stamped because reviewers cannot maintain focus through thousands of lines.

This comparison highlights the stark difference between reviewable and unreviewable pull requests. Aim for the top example to get quality feedback.

## Good PR (easy to review)
- 50-200 lines changed
- Single concern
- Clear description
- Review time: 15-30 minutes

## Bad PR (hard to review)
- 1000+ lines changed
- Multiple features
- Vague description
- Review time: hours (or rubber-stamped)

Pair Programming Alternative

Pair programming provides real-time review, eliminating the asynchronous review bottleneck. Both developers understand the code, so it can merge immediately.

Consider this alternative workflow when review bottlenecks slow your team down. Pair programming front-loads the review process.

Traditional: Write code → Create PR → Wait for review → Address comments → Merge

Pair Programming: Write code together → Both understand it → Merge immediately

Benefits:
- Real-time review
- Knowledge sharing
- No review bottleneck
- Higher quality (two perspectives)

Ship/Show/Ask

Not all changes need the same review level. Ship/Show/Ask categorizes changes by risk level to optimize reviewer time.

Use this framework to decide how much review each change needs. Low-risk changes can ship immediately while high-risk changes get thorough review.

## Ship (merge immediately)
- Typo fixes
- Dependency updates
- Simple refactors
- Well-tested features

## Show (merge, then review async)
- Straightforward changes
- Following established patterns
- Good test coverage

## Ask (wait for approval)
- New patterns/architecture
- Security-sensitive code
- Complex business logic
- Database migrations

Handling Large Features

Incremental Delivery

Large features do not require large branches. Break them into small deliverables, each valuable on its own. Feature flags hide incomplete work while each piece merges to main.

This breakdown shows how to transform a three-week feature branch into two weeks of daily incremental deliveries. Each day produces a mergeable, valuable increment.

## Feature: User Dashboard Redesign

Traditional approach:
- 3-week branch
- 50+ files changed
- Big-bang release

Trunk-based approach:

Week 1:
- Day 1: Add new dashboard route (behind flag)
- Day 2: Add basic layout component
- Day 3: Add statistics cards (empty data)
- Day 4: Wire up statistics queries
- Day 5: Add activity feed component

Week 2:
- Day 1: Add charts component
- Day 2: Wire up chart data
- Day 3: Add filters
- Day 4: Polish and fix bugs
- Day 5: Enable for beta users

Week 3:
- Day 1-3: Gradual rollout (25%, 50%, 100%)
- Day 4-5: Remove flag, delete old code

Dark Launching

For risky migrations, dark launching writes to both old and new systems simultaneously. You can verify the new system works correctly without affecting users.

This pattern lets you test a new system in production with real traffic while the old system continues serving users. Discrepancies are logged but don't affect the user experience.

// Write to both old and new systems
class OrderService
{
    public function create(array $data): Order
    {
        // Always use old system for reads
        $order = $this->legacyOrderService->create($data);

        // Write to new system too (dark launch)
        if (Feature::enabled('dual_write_orders')) {
            try {
                $this->newOrderService->create($data);
            } catch (Exception $e) {
                // Log but don't fail
                Log::warning('New order system failed', [
                    'order_id' => $order->id,
                    'error' => $e->getMessage(),
                ]);
            }
        }

        return $order;
    }
}

This lets you compare results and fix discrepancies before switching reads to the new system.

Team Practices

Daily Integration

Make integration a daily habit. Branches older than a day should trigger discussion about how to break the work into smaller pieces.

Use this checklist at the end of each day to maintain the discipline of continuous integration.

## End of Day Checklist

- [ ] All local changes committed
- [ ] Branch merged to main (or PR opened)
- [ ] CI passing on main
- [ ] No work-in-progress left on branch > 1 day old

Communication

Large changes benefit from upfront discussion. Share your approach before implementing to catch design issues early and agree on how to break work into mergeable increments.

Follow this process before starting any substantial work to ensure alignment and identify integration points early.

## Before Large Changes

1. Discuss approach in team chat/meeting
2. Create design document if needed
3. Break into small deliverables
4. Agree on feature flag strategy
5. Start implementing incrementally

Dealing with Conflicts

Frequent rebasing keeps branches close to main and conflicts small. If you encounter large conflicts, your branch has lived too long.

Rebase frequently to keep your branch synchronized with main. Small, frequent rebases result in small, manageable conflicts.

# Frequent rebasing prevents large conflicts
git checkout feature/my-work
git fetch origin
git rebase origin/main

# Small conflicts are easy to resolve
# Large conflicts indicate branches lived too long

Metrics

Key Indicators

Track metrics that indicate trunk-based development health. These numbers reveal whether the team is actually practicing continuous integration.

This metrics class collects the key indicators for trunk-based development. You can use these measurements to identify bottlenecks and track improvement over time.

class TrunkBasedMetrics
{
    public function collect(): array
    {
        return [
            // Branch metrics
            'avg_branch_lifetime_hours' => $this->avgBranchLifetime(),
            'branches_older_than_day' => $this->staleBranchCount(),

            // Integration metrics
            'commits_to_main_per_day' => $this->dailyMainCommits(),
            'avg_pr_size_lines' => $this->avgPullRequestSize(),

            // Quality metrics
            'main_broken_incidents' => $this->mainBrokenCount(),
            'time_to_fix_main_minutes' => $this->avgFixTime(),

            // Delivery metrics
            'lead_time_hours' => $this->commitToProductionTime(),
            'deploy_frequency_per_day' => $this->dailyDeployments(),
        ];
    }
}

Targets

These targets represent mature trunk-based development. Start with where you are and improve incrementally toward these goals.

Use these benchmarks to measure your team's progress. Most teams won't hit all targets immediately, but they provide a north star for continuous improvement.

## Trunk-Based Development Targets

- Branch lifetime: < 24 hours
- PR size: < 200 lines
- Commits to main: 10+ per day (team)
- Main broken: < 1x per week
- Fix time: < 15 minutes
- Deploy frequency: Multiple times per day

Conclusion

Trunk-based development accelerates delivery by keeping all work on a single branch with short-lived feature branches. Feature flags enable incomplete work to merge safely, while strong CI/CD ensures main stays releasable. The key is small, frequent integrations rather than big-bang merges. Start by shortening branch lifetimes, add feature flags for larger changes, and invest in fast automated testing. The result is less merge pain, faster feedback, and more frequent releases.

Share this article

Related Articles

Distributed Locking Patterns

Coordinate access to shared resources across services. Implement distributed locks with Redis, ZooKeeper, and databases.

Jan 16, 2026

API Design First Development

Design APIs before implementing them. Use OpenAPI specifications, mock servers, and contract-first workflows.

Jan 15, 2026

Need help with your project?

Let's discuss how we can help you build reliable software.