Project Flavor
Project Flavor is a deployment-identity switch — "which build of the product is this?". The current flavor is fixed for the lifetime of a deploy: it's read from an env var at boot, not flipped at runtime.
| Feature Flags (Pennant) | Project Flavor | |
|---|---|---|
| What it is | A runtime capability switch | A deployment-identity constant |
| Use when | A whole capability, domain, or surface area is ON or OFF | A sub-feature, field, column, validation rule, or sidebar item differs between builds that otherwise share the capability |
| Flip at runtime? | Yes (per env / tenant / user, eventually) | No — fixed per deploy via APP_FLAVOR |
| Examples | The winch_branches domain, internal-invoices group | One field hidden on order create, an extra column in a table, a different validation rule, a route that doesn't exist in saas |
| Question to ask | "Would the user see a capability appear or disappear?" | "Would the user see a cosmetic difference inside a capability that still exists?" |
Allowed values: winch (default, full product) and saas (white-label fork). Unknown values fall back to winch (fail-open). Single source of truth: APP_FLAVOR env var → config('app.flavor') → ProjectFlavor enum → isWinch() / isSaas() helpers.
The shape
use Domain\Core\Enums\ProjectFlavor;
ProjectFlavor::current(); // ProjectFlavor::WINCH | ProjectFlavor::SAAS
ProjectFlavor::current()->isSaas();
isWinch(); // global helper, equivalent to the above
isSaas();
projectFlavor(); // returns the enum
Prefer the helpers in route files and Blade. Prefer the enum (ProjectFlavor::current()) inside domain code where you'd import the class anyway.
Hiding a single field on a form (Blade)
Two equivalent options — pick whichever reads better in context:
{{-- helper-based --}}
@if (isWinch())
<div class="form-group">
<label>{{ __('inputs.winch_internal_code') }}</label>
<input name="winch_internal_code" />
</div>
@endif
{{-- directive-based --}}
@flavor('winch')
<div class="form-group">
<label>{{ __('inputs.winch_internal_code') }}</label>
<input name="winch_internal_code" />
</div>
@endflavor
@flavor also supports @else:
@flavor('winch')
<span>Full Winch experience</span>
@else
<span>SaaS edition</span>
@endflavor
Hiding one column in a table
<thead>
<tr>
<th>{{ __('inputs.name') }}</th>
@if (isWinch())
<th>{{ __('inputs.winch_branch') }}</th>
@endif
</tr>
</thead>
Different validation rule per flavor
public function rules(): array
{
$rules = [
'name' => 'required|string',
];
if (isWinch()) {
$rules['winch_internal_code'] = 'required|string|max:50';
}
return $rules;
}
Hiding one sub-menu item (without removing the parent group)
Add the route name to route_flavors in config/cp-menu.php:
'route_flavors' => [
'cp.some_resource.index' => ['saas'],
],
If every sub-item ends up hidden, CpMenu auto-prunes the parent group.
Hiding a whole sidebar group (when the underlying domain still exists)
'group_flavors' => [
SomeGroup::class => ['saas'],
],
If the whole domain is off (no routes, no controllers needed), use a Feature Flag instead.
Gating routes by flavor
Use the flavor route middleware (registered as the EnsureFlavor alias in bootstrap/app.php). It mirrors the feature: middleware used by Pennant and returns 404 when the current flavor is not in the allow-list:
Route::middleware('flavor:winch')->group(function () {
Route::resource('thing', ThingController::class);
});
// Multiple flavors:
Route::middleware('flavor:winch,saas')->group(fn () => ...);
The route still appears in route:list (so route('cp.thing.index') works in Blade and menu helpers and won't blow up at compile time), but any request under the wrong flavor 404s before the controller runs. The sidebar is pruned separately via route_flavors / group_flavors in config/cp-menu.php so hidden routes never appear in the menu.
When the route should literally not exist in a build (e.g., for route:list cleanliness on a saas-only deploy), wrap the registration as well:
if (isWinch()) {
Route::resource('winch_only_thing', ThingController::class);
}
In most cases the middleware is enough — reach for the registration-time wrapper only when you have a concrete reason to keep the route out of the route table entirely.
Quick lookup
| Scenario | Use |
|---|---|
| One field on a form | ProjectFlavor (isWinch()) |
| One column in a table | ProjectFlavor |
| One sub-menu item | ProjectFlavor (route_flavors) |
| Whole sidebar group, but the underlying domain still exists | ProjectFlavor (group_flavors) |
| Different validation rule | ProjectFlavor |
| Routes reachable only in one flavor (404 otherwise) | Route::middleware('flavor:winch')->group(...) |
| Routes that should literally not exist in a build | if (isWinch()) { Route::... } (registration-time skip) |
| Whole domain on/off | Feature Flag |
| Per-tenant or per-user toggle | Feature Flag |
Anti-patterns
- ❌ Branching on
ProjectFlavorto gate a whole domain. Use a Feature Flag — it has middleware support,@feature, menu pruning viagroup_features/route_features, and may be made per-tenant later. - ❌ Relying on the
flavormiddleware to keep menu links from rendering. The middleware only 404s the request — it does not prune the sidebar. Always add a matching entry toroute_flavors/group_flavorsso the menu builder doesn't generate the link in the first place. - ❌ Hardcoded
if (config('app.flavor') === 'winch'). UseisWinch()/ProjectFlavor::current()for type safety and a single source of truth. - ❌ Using
ProjectFlavorfor per-user toggles. Flavor is deployment-wide. If different users in the same deploy need different behaviour, that's a Feature Flag (or a real authorization check).
Reference files
src/Domain/Core/Enums/ProjectFlavor.php— the enum.config/app.php—'flavor'key reading fromAPP_FLAVOR.config/cp-menu.php—group_flavors/route_flavorsmaps.app/Packages/Menus/CpMenu.php— menu builder that consumes the maps.app/helpers.php—projectFlavor(),isWinch(),isSaas().bootstrap/app.php—EnsureFlavormiddleware alias asflavor:.
What to read next
- Feature Flags — the runtime capability switch counterpart.
- Configuration — where
APP_FLAVORis set per environment. - Domain boundaries — why flavor branching usually lives at the edge, not inside a domain.