Picture this: You're three months into a new job, feeling pretty good about yourself. The codebase seems clean, the team is friendly, and then someone casually mentions "Oh yeah, we have a dozen different branches for our customers."
A dozen. Different. Branches.
That's when you realize you've walked into a house of horrors where every customer gets their own special snowflake version of the application. Customer A doesn't have the reporting feature "for some reason." Customer B has a completely different navigation because their CEO's nephew thought it looked cooler. Customer C is still running code from 2019 because nobody wants to risk breaking their weird custom workflow.
Sound familiar? Yeah, I thought so.
How We Got Here
It always starts innocently enough. A big customer waves some money around and asks for "just a small change." Maybe they want their logo in the header. Maybe they need a custom field in the signup form. Easy enough, right? Just branch the code, make the change, ship it.
Then another customer wants something slightly different. Another branch. Then another. Before you know it, you're maintaining more branches than a tree farm, and every single feature request becomes an archaeological expedition to figure out which customers have what.
The worst part? Most of these "custom" features are 95% identical. You've got a dozen different user dashboards that all show the same data in slightly different colors. A dozen signup forms that collect the same information with different field labels. It's like maintaining a dozen nearly-identical houses, each with their own unique way of turning on the lights.
In my last role, we tracked our sprint velocity before and after consolidating our custom branches. The result? We were spending nearly 40% of our development time just keeping customer branches in sync. Every security patch became a two-day affair instead of a two-hour deployment.
There's a Better Way
Let me show you how to dig out of this hole without losing your sanity (or your customers).

Is it right for everybody?
Before going custom, figure out how this could benefit others. Sometimes what looks like a one-off request is actually a feature half your customers would love.
War Story: A customer asked for the ability to send submitted form data back into their EMR software. This seemed like such a specific request that we just branched their code and built it in. Six months later, we had five more customers asking for EMR integrations - all slightly different, all in separate branches, all sharing 95% of the same integration logic.
Custom Views: The Easy Win
Most "customization" is just cosmetic anyway. Different colors, different layouts, maybe some extra fields. Views are the safest place to let customers go wild.
Option 1: Fallback Views
return View::first(['custom.dashboard', 'dashboard'], $data);
Create custom views only when needed, fall back to the default. Great for one-off customizations.
Maintenance reality: You update the base dashboard template. Customers using the default get the update automatically. Customers with custom views need to manually update theirs if they want the new features.
Option 2: Customer-Specific View Paths
return [
'paths' => [
resource_path("views/{$customer}"),
resource_path('views'),
],
];
Each customer gets their own view directory. Perfect when most customers need visual customization.
Maintenance reality: You update a base view, then copy those changes to customer directories that need them. More work upfront, but customers can completely customize their UI.
Testing reality:
// Visual regression testing
class DashboardScreenshotTest extends TestCase
{
/** @dataProvider customerProvider */
public function test_dashboard_looks_correct_for_customer($customer)
{
config(['app.customer' => $customer->value]);
$this->browse(function (Browser $browser) use ($customer) {
$browser->visit('/dashboard')
->screenshot("dashboard-{$customer->value}");
});
}
public function customerProvider()
{
return [
[Customer::AcmeCorp],
[Customer::GlobalTech],
[Customer::NexusInc],
];
}
}
Custom Services: The Sweet Spot
$this->app->bind(DeploymentService::class, function ($app) {
$customer = Customer::from(config('app.customer'));
return match ($customer) {
Customer::AcmeCorp => new Vercel,
Customer::GlobalTech => new AWS,
Customer::NexusInc => new DigitalOcean,
Customer::ZenithLtd => new GoogleCloud,
default => new Heroku,
};
});
This is where things get interesting. Instead of customizing the entire application, you just swap out the services that handle customer-specific integrations. Each customer might deploy differently, but your application doesn't care - it just calls $deploymentService->deploy()
and lets the implementation worry about the details.
Maintenance reality: AWS changes their API. You update one class. The rest of your customers keep working while you fix the AWS implementation. Compare that to finding every AWS reference across a dozen branches.
Testing reality:
// Mock the contract for main app tests
class DeploymentTest extends TestCase
{
public function test_app_can_deploy_code()
{
$mockDeployment = Mockery::mock(DeploymentService::class);
$mockDeployment->shouldReceive('deploy')->once()->andReturn(true);
$this->app->instance(DeploymentService::class, $mockDeployment);
$result = $this->deploymentManager->deployLatest();
$this->assertTrue($result);
}
}
// Test each implementation separately
class VercelDeploymentTest extends TestCase
{
public function test_vercel_deployment_calls_correct_api()
{
config(['app.customer' => Customer::AcmeCorp->value]);
Http::fake(['api.vercel.com/*' => Http::response(['success' => true])]);
$vercel = app(DeploymentService::class);
$result = $vercel->deploy('main');
Http::assertSent(function ($request) {
return $request->url() === 'https://api.vercel.com/deployments';
});
}
}
Custom Controllers: When You Need Full Control
$this->app->bind(DashboardControllerContract::class, function ($app) {
$customer = Customer::from(config('app.customer'));
return match ($customer) {
Customer::AcmeCorp => new AcmeDashboardController,
Customer::GlobalTech => new GlobalTechDashboardController,
default => new DashboardController,
};
});
This is the nuclear option. When customers need completely different application behavior, you can swap entire controllers. Your routes need to point to the contract, not the concrete class:
Route::get('/dashboard', [DashboardControllerContract::class, 'index']);
Laravel's service container handles the dependency injection so you don't have to manually instantiate anything.
War Story: We spent multiple development cycles trying to merge customer branches after the fact. What should have been a one-day security patch turned into a two-week nightmare of resolving merge conflicts, testing each customer's unique setup, and fixing broken customizations.
Maintenance reality: You push a security fix to the base DashboardController
. Instead of merging into a dozen branches, you test three controllers. One fails because it overrode a method you just patched. You fix one file instead of potentially a dozen.
Testing reality:
// Base test class with all the common tests
class DashboardControllerTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
// Default customer - no config needed
}
public function test_dashboard_displays_user_info()
{
$this->actingAs($user)
->get('/dashboard')
->assertSee($user->name);
}
public function test_dashboard_shows_recent_activity()
{
$this->actingAs($user)
->get('/dashboard')
->assertSee('Recent Activity');
}
}
// Acme-specific tests inherit all the base functionality
class AcmeDashboardControllerTest extends DashboardControllerTest
{
protected function setUp(): void
{
parent::setUp();
config(['app.customer' => Customer::AcmeCorp->value]);
}
public function test_acme_dashboard_shows_growth_metrics()
{
$this->actingAs($user)
->get('/dashboard')
->assertSee('Monthly Growth');
}
// Override when Acme needs different behavior
public function test_dashboard_has_navigation()
{
$this->actingAs($user)
->get('/dashboard')
->assertSee('Analytics') // Acme uses "Analytics" not "Reports"
->assertSee('Integrations'); // Acme has an extra nav item
}
}
The Pattern Works Everywhere
This isn't just a Laravel problem or solution. The core pattern - injecting different implementations of the same interface - works in most modern languages:
Go:
type DeploymentService interface {
Deploy(version string) error
}
func NewDeploymentService(customer string) DeploymentService {
switch customer {
case "acme-corp":
return &VercelService{}
case "global-tech":
return &AWSService{}
default:
return &HerokuService{}
}
}
Rust:
trait DeploymentService {
fn deploy(&self, version: &str) -> Result<(), Error>;
}
fn create_deployment_service(customer: &str) -> Box<dyn DeploymentService> {
match customer {
"acme-corp" => Box::new(VercelService::new()),
"global-tech" => Box::new(AWSService::new()),
_ => Box::new(HerokuService::new()),
}
}
The beauty is that your application code doesn't care which implementation it gets - it just calls the methods it needs. Laravel's service container just makes this pattern particularly elegant to implement.
I might follow up with an article showing how to implement these same patterns in Go - let me know if that would be useful.
The Real Talk
Here's how these patterns work together to solve the branch nightmare:
Start with Views for cosmetic changes - different logos, colors, layouts. This handles 80% of "customization" requests without touching any business logic.
Move to Services when customers need different integrations - payment processors, deployment platforms, external APIs. Your application logic stays the same, only the implementation changes.
Use Controllers when customers need fundamentally different workflows - different form fields, approval processes, or user journeys. This is the most invasive but sometimes necessary.
The key insight? You can combine all three patterns. A customer might have custom views (their branding), custom services (their deployment pipeline), and one custom controller (their unique approval workflow). But instead of maintaining an entire branch, you're only customizing the specific pieces that actually differ.
Remember that 40% of development time we were wasting on branch merges? These patterns eliminate that entirely. Security patches go to one codebase. New features get built once. Customer-specific code stays isolated and testable.
The goal isn't to eliminate customization - it's to make it sustainable. When the next customer asks for "just a small change," you'll have the architecture to handle it properly from day one.
Because trust me, a dozen branches is not a problem that fixes itself.
The Custom Tenant Code Nightmare (And How to Wake Up)
You're three months into a new job when someone mentions "we have a dozen different branches for our customers." Learn how to escape the custom code nightmare with four proven patterns that actually scale, plus real Laravel examples.