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.