HasWorkflow Trait
The HasWorkflow trait binds an Eloquent model to a workflow flow, automatically selecting and binding the appropriate flow when a model is created. This trait provides a seamless way to integrate workflow management into your models.
When to Use HasWorkflow
Use HasWorkflow when you need:
- Complex flow selection logic: Multiple flows for the same model type based on different criteria
- Dynamic flow selection: Flows selected based on subject scope, collection, environment, channel, or rollout percentage
- Multiple workflow variants: Different workflows for the same model based on business rules
- Advanced features: Active time windows, canary deployments, environment-specific flows
- Flexible configuration: Need to customize flow picking based on runtime conditions
Example scenarios:
- E-commerce orders with different workflows for premium vs standard customers
- Content approval workflows that vary by department or region
- Multi-tenant applications where each tenant has different workflows
- A/B testing workflows with rollout percentages
- Environment-specific workflows (development, staging, production)
HasWorkflow vs HasFlow
Choose HasWorkflow if:
- You have multiple flows for the same model type
- Flow selection depends on dynamic criteria (user, collection, environment, etc.)
- You need advanced features like rollout percentages or active windows
- Your workflow requirements are complex and may change over time
Choose HasFlow if:
- You have a single, fixed flow per model type
- Flow selection is simple and doesn't change
- You prefer explicit flow ID assignment
- Your project is straightforward with minimal workflow complexity
For most simple projects (90% of use cases), HasFlow is recommended. For complex scenarios with multiple flows and dynamic selection, use HasWorkflow.
Namespace
JobMetric\Flow\HasWorkflow
Basic Usage
To use the HasWorkflow trait, simply add it to your model:
use Illuminate\Database\Eloquent\Model;
use JobMetric\Flow\HasWorkflow;
class Order extends Model
{
use HasWorkflow;
protected $fillable = [
'user_id',
'status',
];
}
Requirements
Status Column
Your model must have a status column in the database table. The trait will automatically check for this column when the model is created.
// Migration example
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('status')->nullable();
// ... other columns
});
If you need to use a different column name, override the flowStatusColumn() method:
class Order extends Model
{
use HasWorkflow;
protected function flowStatusColumn(): string
{
return 'order_status'; // Custom column name
}
}
Flow Configuration
Make sure you have created flows with the correct subject_type matching your model class. You can create multiple flows for different scenarios:
use JobMetric\Flow\Facades\Flow;
// Production flow
$prodFlow = Flow::store([
'subject_type' => Order::class,
'subject_scope' => null,
'subject_collection' => null,
'environment' => 'production',
'channel' => 'web',
'version' => 1,
'status' => true,
'is_default' => true,
]);
// Staging flow
$stagingFlow = Flow::store([
'subject_type' => Order::class,
'subject_scope' => null,
'subject_collection' => null,
'environment' => 'staging',
'channel' => 'web',
'version' => 1,
'status' => true,
'is_default' => true,
]);
// Premium collection flow
$premiumFlow = Flow::store([
'subject_type' => Order::class,
'subject_scope' => null,
'subject_collection' => 'premium',
'environment' => 'production',
'channel' => 'web',
'version' => 1,
'status' => true,
'is_default' => true,
]);
Database Migration Example
Here's a complete migration example for a model using HasWorkflow:
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->string('collection')->nullable(); // For subject_collection
$table->string('status')->nullable(); // Required for HasWorkflow
$table->decimal('total', 10, 2);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('orders');
}
};
Required columns:
status: Required for workflow state management
Optional columns:
collection: If you useflowSubjectCollection()- Any other columns your model needs
Automatic Flow Binding
When a model using HasWorkflow is created, the trait automatically:
- Picks the appropriate flow based on the model's configuration
- Binds the flow to the model via the
flow_usestable - Sets the initial status if the flow has a START state
$order = Order::create([
'user_id' => 1,
'status' => null, // Will be set automatically if flow has START state
]);
// Flow is automatically bound
$boundFlow = $order->boundFlow();
Customizing Flow Selection
Override buildFlowPicker
You can customize how flows are selected by overriding the buildFlowPicker() method. This method receives a FlowPickerBuilder instance that you can configure with various options:
use JobMetric\Flow\Support\FlowPickerBuilder;
class Order extends Model
{
use HasWorkflow;
protected function buildFlowPicker(FlowPickerBuilder $builder): void
{
// Call parent to set defaults
parent::buildFlowPicker($builder);
// Customize the builder
$builder
->subjectScope($this->user_id ? (string)$this->user_id : null)
->environment('production')
->channel('web')
->preferEnvironments(['production', 'staging'])
->preferChannels(['web', 'api'])
->rolloutNamespace('order')
->rolloutKeyResolver(function ($model) {
return (string)$model->getKey();
});
}
}
FlowPickerBuilder Options
The FlowPickerBuilder provides extensive configuration options for flow selection:
Subject Scope
Use subjectScope() to partition flows by tenant, organization, or any logical grouping:
protected function buildFlowPicker(FlowPickerBuilder $builder): void
{
parent::buildFlowPicker($builder);
// Partition flows by user/tenant
$builder->subjectScope($this->user_id ? (string)$this->user_id : null);
// Or by organization
$builder->subjectScope($this->organization_id ? (string)$this->organization_id : null);
}
Use case: Multi-tenant applications where each tenant has different workflows.
Environment
Filter flows by environment (production, staging, development):
$builder->environment(config('app.env'));
// Or based on request
$builder->environment(request()->header('X-Environment', 'production'));
Use case: Different workflows for different deployment environments.
Channel
Filter flows by channel (web, api, mobile, etc.):
$builder->channel(request()->header('X-Channel', 'web'));
// Or based on route
$builder->channel(request()->is('api/*') ? 'api' : 'web');
Use case: Different workflows for web vs API requests.
Preferred Environments and Channels
Use preferEnvironments() and preferChannels() for ordering (not filtering). Earlier items rank higher:
$builder
->preferEnvironments(['production', 'staging', 'development'])
->preferChannels(['web', 'api', 'mobile']);
This means if multiple flows match, the one with the preferred environment/channel will be selected first.
Rollout Configuration
Configure rollout (canary deployment) for gradual feature rollouts:
$builder
->evaluateRollout(true)
->rolloutNamespace('order') // Isolate rollout buckets
->rolloutSalt('v2') // Additional salt for stability
->rolloutKeyResolver(function ($model) {
// Return a stable key (e.g., user_id, order_id)
return (string)$model->getKey();
});
Rollout Namespace: Isolates rollout buckets across different features/domains. This ensures that rollout percentages are independent for different features.
Rollout Salt: Additional salt for hashing to further stabilize or segregate rollout buckets.
Rollout Key Resolver: Returns a stable key (like user_id or order_id) that determines which bucket the model falls into. The same key will always fall into the same bucket.
Example: If a flow has rollout_pct: 50, only 50% of models (based on the rollout key) will be assigned to this flow.
Fallback Cascade
Define fallback steps to progressively relax constraints if no flow matches:
use JobMetric\Flow\Support\FlowPickerBuilder;
$builder->fallbackCascade([
FlowPickerBuilder::FB_DROP_CHANNEL, // Remove channel filter
FlowPickerBuilder::FB_DROP_ENVIRONMENT, // Remove environment filter
FlowPickerBuilder::FB_IGNORE_TIMEWINDOW, // Ignore active window
FlowPickerBuilder::FB_DISABLE_ROLLOUT, // Disable rollout checks
FlowPickerBuilder::FB_DROP_REQUIRE_DEFAULT, // Don't require is_default
]);
The system will try each step in order until a flow is found.
Active Window
Control time-based flow activation:
$builder
->onlyActive(true) // Require status=true and active window
->ignoreTimeWindow(false) // Enforce active_from/active_to
->timeNow(Carbon::now('UTC')); // Reference time for evaluation
Use case: Schedule workflows to activate/deactivate at specific times.
Custom Where Clauses
Add custom query constraints to filter flows:
protected function buildFlowPicker(FlowPickerBuilder $builder): void
{
parent::buildFlowPicker($builder);
$builder->where(function ($query, $model) {
// Filter flows based on model attributes
$query->where('custom_field', $model->some_attribute);
// Or complex conditions
if ($model->is_premium) {
$query->where('is_premium_flow', true);
}
});
}
Use case: Custom business logic that doesn't fit standard criteria.
Version Constraints
Control which flow versions are eligible:
$builder
->versionEquals(2) // Exact version
// OR
->versionMin(1) // Minimum version
->versionMax(3); // Maximum version
Use case: Gradual migration between flow versions.
Flow ID Whitelist/Blacklist
Include or exclude specific flows:
$builder
->includeFlowIds([1, 2, 3]) // Only these flows
->excludeFlowIds([4, 5]) // Exclude these flows
->preferFlowIds([1, 2]); // Prefer these (for ordering)
Use case: Testing specific flows or excluding deprecated flows.
Custom Ordering
Override default ordering:
$builder->orderBy(function ($query) {
$query->orderBy('priority', 'desc')
->orderBy('created_at', 'asc');
});
Default ordering is: version DESC, is_default DESC, ordering DESC, id DESC.
Match Strategy
Control how flows are selected when multiple matches exist:
// STRATEGY_BEST: Returns the best candidate based on ordering rules (default)
$builder->matchStrategy(FlowPickerBuilder::STRATEGY_BEST);
// STRATEGY_FIRST: Returns the first matching record (minimal ordering)
$builder->matchStrategy(FlowPickerBuilder::STRATEGY_FIRST);
STRATEGY_BEST (default):
- Applies full ordering (version, is_default, ordering, id)
- Returns the highest-ranked flow
- Best for production use
STRATEGY_FIRST:
- Minimal ordering (just enough to get first match)
- Faster but less predictable
- Useful for testing or when order doesn't matter
Require Default
Only select flows marked as default:
$builder->requireDefault(true);
This ensures only flows with is_default = true are selected. Useful when you want to enforce a single default flow per scope.
Request Caching
Enable per-request caching for better performance:
$builder->cacheInRequest(true);
Warning: Only enable if your builder doesn't use dynamic callbacks that depend on request state. If callbacks access $request, auth(), or other request-specific data, caching may return incorrect results.
Subject Collection
If your model has a collection field, you can use it for flow selection. Collections allow you to have different workflows for the same model type:
class Order extends Model
{
use HasWorkflow;
protected $fillable = [
'collection', // e.g., 'premium', 'standard', 'enterprise'
'status',
];
protected function flowSubjectCollection(): ?string
{
return $this->getAttribute('collection');
}
}
How it works:
When you create flows, set the subject_collection field:
// Premium order flow
$premiumFlow = Flow::store([
'subject_type' => Order::class,
'subject_collection' => 'premium',
'version' => 1,
]);
// Standard order flow
$standardFlow = Flow::store([
'subject_type' => Order::class,
'subject_collection' => 'standard',
'version' => 1,
]);
When creating orders:
// Premium order - gets premium flow
$premiumOrder = Order::create([
'collection' => 'premium',
'user_id' => 1,
]);
// Standard order - gets standard flow
$standardOrder = Order::create([
'collection' => 'standard',
'user_id' => 1,
]);
Advanced Collection Scenarios:
You can combine collections with other criteria:
protected function buildFlowPicker(FlowPickerBuilder $builder): void
{
parent::buildFlowPicker($builder);
$builder
->subjectCollection($this->collection)
->subjectScope((string)$this->tenant_id)
->environment(config('app.env'));
}
This allows you to have:
- Different flows per collection (premium vs standard)
- Different flows per tenant
- Different flows per environment
All combined for maximum flexibility.
Working with Bound Flows
Get Bound Flow
Retrieve the currently bound flow for a model:
$order = Order::find(1);
// Get bound flow (lazy loading)
$flow = $order->boundFlow();
// Eager load for better performance
$order = Order::withFlow()->find(1);
$flow = $order->boundFlow();
Flow Use Relation
Access the FlowUse relationship directly:
$order = Order::find(1);
// Get the FlowUse record
$flowUse = $order->flowUse;
// Access the flow through the relation
$flow = $order->flowUse->flow;
Eager Loading
Use the withFlow() scope to efficiently load flows. This prevents N+1 query problems:
// Single model
$order = Order::withFlow()->find(1);
// Multiple models (prevents N+1 queries)
$orders = Order::withFlow()->get();
// In queries
$orders = Order::withFlow()
->where('status', 'pending')
->get();
// Combined with other relations
$orders = Order::withFlow()
->with('user', 'items')
->get();
What withFlow() does:
// Internally, it does:
$query->with(['flowUse.flow']);
// This eager loads:
// 1. FlowUse records (polymorphic relation)
// 2. Flow models (through FlowUse)
Performance Tip:
Always use withFlow() when loading multiple models:
// ❌ Bad: N+1 queries
$orders = Order::all();
foreach ($orders as $order) {
$flow = $order->boundFlow(); // Query for each order
}
// ✅ Good: Single query
$orders = Order::withFlow()->get();
foreach ($orders as $order) {
$flow = $order->boundFlow(); // Already loaded
}
Manual Flow Binding
Bind Flow
Manually bind a specific flow to a model:
use JobMetric\Flow\Facades\Flow;
$order = Order::find(1);
$flow = Flow::show($flowId)->getData();
$order->bindFlow($flow);
// With custom timestamp
$order->bindFlow($flow, Carbon::now()->subDays(1));
Rebind Flow
Re-pick and rebind a flow (useful when flow configuration changes):
// Rebind with default picker
$flow = $order->rebindFlow();
// Rebind with custom tuner
$flow = $order->rebindFlow(function ($builder) {
$builder->environment('staging');
});
Unbind Flow
Remove the flow binding:
$order->unbindFlow();
Flow Picking
Pick Flow
Manually pick a flow without binding it. This uses the configured buildFlowPicker() logic:
$order = Order::find(1);
// Pick with default configuration (uses buildFlowPicker())
$flow = $order->pickFlow();
// The flow is not automatically bound
// You can manually bind it if needed:
if ($flow) {
$order->bindFlow($flow);
}
How it works:
- Creates a new
FlowPickerBuilderinstance - Calls
buildFlowPicker()to configure it - Uses
FlowPickerto select the best matching flow - Returns the selected Flow or
null
Understanding makeFlowPicker()
The makeFlowPicker() method creates and configures a FlowPickerBuilder:
// Internal implementation
protected function makeFlowPicker(): FlowPickerBuilder
{
$builder = new FlowPickerBuilder();
$this->buildFlowPicker($builder); // Your custom configuration
return $builder;
}
You can use this in custom scenarios:
$order = Order::find(1);
// Get the configured builder
$builder = $order->makeFlowPicker();
// Modify it further
$builder->environment('staging');
// Use it with FlowPicker
$flow = (new FlowPicker())->pick($order, $builder);
Preview Flow Selection
Use the Flow service to preview which flow would be selected:
use JobMetric\Flow\Facades\Flow;
$order = Order::find(1);
// Preview without binding
$flow = Flow::previewPick($order);
// Preview with custom tuner
$flow = Flow::previewPick($order, function ($builder) {
$builder->environment('staging');
});
Status Management
Current Status Value
Get the current status as a scalar value (handles enum casting):
$order = Order::find(1);
// Returns the status value (works with enums too)
$status = $order->flowCurrentStatusValue();
// Returns: 'pending', 'processing', etc.
Status Enum Support
The trait automatically detects if your status column uses an enum:
use App\Enums\OrderStatus;
class Order extends Model
{
use HasWorkflow;
protected $casts = [
'status' => OrderStatus::class,
];
}
// Get enum class
$enumClass = $order->flowStatusEnumClass();
// Returns: OrderStatus::class
// Get enum values
$values = $order->flowStatusEnumValues();
// Returns: ['pending', 'processing', 'shipped', 'delivered']
Custom Status Enum
If you're using a custom enum with a values() method:
enum OrderStatus: string
{
case PENDING = 'pending';
case PROCESSING = 'processing';
case SHIPPED = 'shipped';
case DELIVERED = 'delivered';
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}
The trait will automatically use this method to get enum values.
Enum Detection Priority
The flowStatusEnumValues() method uses this priority:
- Custom
values()method: If your enum definesstatic values(), it's used first - Backed Enum values: If it's a
BackedEnum, returns scalar values from cases - Pure Enum names: Otherwise, returns case names
// Backed Enum
enum OrderStatus: string
{
case PENDING = 'pending';
case PROCESSING = 'processing';
}
$values = $order->flowStatusEnumValues();
// Returns: ['pending', 'processing']
// Pure Enum
enum OrderStatus
{
case PENDING;
case PROCESSING;
}
$values = $order->flowStatusEnumValues();
// Returns: ['PENDING', 'PROCESSING']
// Custom values() method (highest priority)
enum OrderStatus: string
{
case PENDING = 'pending';
case PROCESSING = 'processing';
public static function values(): array
{
return ['custom', 'values']; // This will be used
}
}
Getting Current Status
The flowCurrentStatusValue() method handles enum conversion:
$order = Order::find(1);
// If status is a Backed Enum
$order->status = OrderStatus::PENDING; // OrderStatus enum
$value = $order->flowCurrentStatusValue();
// Returns: 'pending' (scalar value)
// If status is a Pure Enum
$order->status = OrderStatus::PENDING; // OrderStatus enum
$value = $order->flowCurrentStatusValue();
// Returns: 'PENDING' (case name)
// If status is a string
$order->status = 'pending';
$value = $order->flowCurrentStatusValue();
// Returns: 'pending' (raw value)
Advanced FlowPickerBuilder Examples
Multi-Tenant Application
For a multi-tenant application where each tenant has different workflows:
class Order extends Model
{
use HasWorkflow;
protected function buildFlowPicker(FlowPickerBuilder $builder): void
{
parent::buildFlowPicker($builder);
$builder
->subjectScope((string)$this->tenant_id) // Partition by tenant
->environment(config('app.env'))
->channel('web')
->fallbackCascade([
FlowPickerBuilder::FB_DROP_CHANNEL,
FlowPickerBuilder::FB_DROP_ENVIRONMENT,
]);
}
}
A/B Testing with Rollout
For gradual rollout of new workflows:
class Order extends Model
{
use HasWorkflow;
protected function buildFlowPicker(FlowPickerBuilder $builder): void
{
parent::buildFlowPicker($builder);
$builder
->environment('production')
->channel('web')
->evaluateRollout(true)
->rolloutNamespace('order_v2') // Isolate from other features
->rolloutSalt('2024') // Version salt
->rolloutKeyResolver(function ($model) {
// Use user_id for consistent bucket assignment
return (string)$model->user_id;
})
->fallbackCascade([
FlowPickerBuilder::FB_DISABLE_ROLLOUT, // Fallback to 100% if needed
]);
}
}
How Rollout Works:
If you have two flows:
- Flow A:
rollout_pct: 50(old workflow) - Flow B:
rollout_pct: 50(new workflow)
The system will:
- Hash the rollout key (user_id) with namespace and salt
- Determine which bucket (0-100) the user falls into
- Assign Flow A to users in bucket 0-50
- Assign Flow B to users in bucket 51-100
The same user will always get the same flow (consistent bucket assignment).
Environment-Specific Workflows
For different workflows per environment:
class Order extends Model
{
use HasWorkflow;
protected function buildFlowPicker(FlowPickerBuilder $builder): void
{
parent::buildFlowPicker($builder);
$env = config('app.env');
$builder
->environment($env)
->preferEnvironments(['production', 'staging', 'development'])
->fallbackCascade([
FlowPickerBuilder::FB_DROP_ENVIRONMENT, // Fallback to any environment
]);
}
}
Channel-Based Workflows
For different workflows based on request channel:
class Order extends Model
{
use HasWorkflow;
protected function buildFlowPicker(FlowPickerBuilder $builder): void
{
parent::buildFlowPicker($builder);
$channel = request()->is('api/*') ? 'api' : 'web';
$builder
->channel($channel)
->preferChannels(['web', 'api', 'mobile'])
->fallbackCascade([
FlowPickerBuilder::FB_DROP_CHANNEL, // Fallback to any channel
]);
}
}
Collection-Based Workflows
For different workflows based on model collection:
class Order extends Model
{
use HasWorkflow;
protected $fillable = [
'collection', // 'premium', 'standard', 'enterprise'
'status',
];
protected function buildFlowPicker(FlowPickerBuilder $builder): void
{
parent::buildFlowPicker($builder);
$builder
->subjectCollection($this->collection)
->environment(config('app.env'));
}
protected function flowSubjectCollection(): ?string
{
return $this->getAttribute('collection');
}
}
Scheduled Workflows
For workflows that activate/deactivate at specific times:
class Order extends Model
{
use HasWorkflow;
protected function buildFlowPicker(FlowPickerBuilder $builder): void
{
parent::buildFlowPicker($builder);
$builder
->onlyActive(true)
->ignoreTimeWindow(false) // Enforce active_from/active_to
->timeNow(Carbon::now('UTC'))
->fallbackCascade([
FlowPickerBuilder::FB_IGNORE_TIMEWINDOW, // Fallback: ignore time window
]);
}
}
Complex Multi-Criteria Selection
Combining multiple criteria:
class Order extends Model
{
use HasWorkflow;
protected function buildFlowPicker(FlowPickerBuilder $builder): void
{
parent::buildFlowPicker($builder);
$builder
->subjectScope((string)$this->tenant_id)
->subjectCollection($this->order_type) // 'premium', 'standard'
->environment(config('app.env'))
->channel(request()->header('X-Channel', 'web'))
->preferEnvironments(['production', 'staging'])
->preferChannels(['web', 'api'])
->evaluateRollout(true)
->rolloutNamespace('order')
->rolloutKeyResolver(function ($model) {
return (string)$model->user_id;
})
->fallbackCascade([
FlowPickerBuilder::FB_DROP_CHANNEL,
FlowPickerBuilder::FB_DROP_ENVIRONMENT,
FlowPickerBuilder::FB_IGNORE_TIMEWINDOW,
FlowPickerBuilder::FB_DISABLE_ROLLOUT,
]);
}
}
Complete Example
Here's a complete example of using HasWorkflow in a real-world scenario:
use Illuminate\Database\Eloquent\Model;
use JobMetric\Flow\HasWorkflow;
use JobMetric\Flow\Support\FlowPickerBuilder;
use App\Enums\OrderStatus;
class Order extends Model
{
use HasWorkflow;
protected $fillable = [
'user_id',
'collection',
'status',
'total',
];
protected $casts = [
'status' => OrderStatus::class,
'total' => 'decimal:2',
];
protected function buildFlowPicker(FlowPickerBuilder $builder): void
{
parent::buildFlowPicker($builder);
$builder
->subjectScope($this->user_id ? (string)$this->user_id : null)
->subjectCollection($this->collection)
->environment(config('app.env'))
->channel(request()->header('X-Channel', 'web'))
->rolloutNamespace('order')
->rolloutKeyResolver(function ($model) {
return (string)$model->getKey();
});
}
protected function flowSubjectCollection(): ?string
{
return $this->getAttribute('collection');
}
}
// Usage
$order = Order::create([
'user_id' => 1,
'collection' => 'premium',
'total' => 100.00,
]);
// Flow is automatically bound
$flow = $order->boundFlow();
// Get current status
$status = $order->flowCurrentStatusValue();
// Execute a transition
use JobMetric\Flow\Facades\FlowTransition;
FlowTransition::runner('start_processing', $order);
Real-World Scenarios
Scenario 1: Multi-Tenant SaaS Application
Different workflows per tenant with environment-specific variants:
class Document extends Model
{
use HasWorkflow;
protected function buildFlowPicker(FlowPickerBuilder $builder): void
{
parent::buildFlowPicker($builder);
$builder
->subjectScope((string)$this->tenant_id) // Partition by tenant
->environment(config('app.env'))
->channel(request()->header('X-Channel', 'web'))
->preferEnvironments(['production', 'staging', 'development'])
->fallbackCascade([
FlowPickerBuilder::FB_DROP_CHANNEL,
FlowPickerBuilder::FB_DROP_ENVIRONMENT,
]);
}
}
Flow setup:
- Tenant 1, Production: Flow ID 1
- Tenant 1, Staging: Flow ID 2
- Tenant 2, Production: Flow ID 3
- etc.
Scenario 2: E-Commerce with A/B Testing
Gradual rollout of new checkout workflow:
class Checkout extends Model
{
use HasWorkflow;
protected function buildFlowPicker(FlowPickerBuilder $builder): void
{
parent::buildFlowPicker($builder);
$builder
->environment('production')
->channel('web')
->evaluateRollout(true)
->rolloutNamespace('checkout_v2')
->rolloutSalt('2024-01')
->rolloutKeyResolver(function ($model) {
// Use customer ID for consistent assignment
return (string)$model->customer_id;
})
->fallbackCascade([
FlowPickerBuilder::FB_DISABLE_ROLLOUT, // Fallback to 100%
]);
}
}
Flow setup:
- Old checkout:
rollout_pct: 50 - New checkout:
rollout_pct: 50
50% of customers get old workflow, 50% get new workflow, consistently assigned by customer ID.
Scenario 3: Content Management with Collections
Different approval workflows for different content types:
class Article extends Model
{
use HasWorkflow;
protected $fillable = [
'collection', // 'news', 'blog', 'press-release'
'status',
];
protected function buildFlowPicker(FlowPickerBuilder $builder): void
{
parent::buildFlowPicker($builder);
$builder
->subjectCollection($this->collection)
->environment(config('app.env'))
->channel('web');
}
protected function flowSubjectCollection(): ?string
{
return $this->getAttribute('collection');
}
}
Flow setup:
- News articles:
subject_collection: 'news' - Blog posts:
subject_collection: 'blog' - Press releases:
subject_collection: 'press-release'
Scenario 4: Scheduled Workflow Activation
Workflows that activate at specific times:
class Campaign extends Model
{
use HasWorkflow;
protected function buildFlowPicker(FlowPickerBuilder $builder): void
{
parent::buildFlowPicker($builder);
$builder
->onlyActive(true)
->ignoreTimeWindow(false) // Enforce active_from/active_to
->timeNow(Carbon::now('UTC'))
->fallbackCascade([
FlowPickerBuilder::FB_IGNORE_TIMEWINDOW, // Fallback if outside window
]);
}
}
Flow setup:
- Summer campaign:
active_from: '2024-06-01',active_to: '2024-08-31' - Winter campaign:
active_from: '2024-12-01',active_to: '2024-02-28'
Scenario 5: Complex Multi-Criteria Selection
Combining all features for maximum flexibility:
class Invoice extends Model
{
use HasWorkflow;
protected $fillable = [
'tenant_id',
'invoice_type', // 'recurring', 'one-time'
'status',
];
protected function buildFlowPicker(FlowPickerBuilder $builder): void
{
parent::buildFlowPicker($builder);
$builder
->subjectScope((string)$this->tenant_id)
->subjectCollection($this->invoice_type)
->environment(config('app.env'))
->channel(request()->header('X-Channel', 'web'))
->preferEnvironments(['production', 'staging'])
->preferChannels(['web', 'api'])
->evaluateRollout(true)
->rolloutNamespace('invoice')
->rolloutKeyResolver(function ($model) {
return (string)$model->customer_id;
})
->onlyActive(true)
->ignoreTimeWindow(false)
->timeNow(Carbon::now('UTC'))
->fallbackCascade([
FlowPickerBuilder::FB_DROP_CHANNEL,
FlowPickerBuilder::FB_DROP_ENVIRONMENT,
FlowPickerBuilder::FB_IGNORE_TIMEWINDOW,
FlowPickerBuilder::FB_DISABLE_ROLLOUT,
]);
}
protected function flowSubjectCollection(): ?string
{
return $this->getAttribute('invoice_type');
}
}
This configuration allows for:
- Different workflows per tenant
- Different workflows per invoice type
- Environment-specific workflows
- Channel-specific workflows
- Gradual rollout of new workflows
- Time-based activation
- Graceful fallbacks if no exact match
Lifecycle Events
The trait hooks into Eloquent's creating and created events to automatically bind flows:
Creating Event
During the creating event:
- Checks if the model has a status column (throws exception if missing)
- Checks if a flow binding already exists (skips if it does)
- Calls
pickFlow()to select the appropriate flow - Stores the selected flow ID in
selectedFlowIdForBindingproperty
// This happens automatically when you create a model
$order = Order::create([...]);
// Flow is picked and stored temporarily during 'creating' event
Created Event
During the created event:
- Checks if the model has a status column
- Checks if a flow binding already exists (skips if it does)
- Uses the stored
selectedFlowIdForBindingor callspickFlow()again - Creates a
FlowUserecord to bind the flow to the model
// Flow binding is persisted to database during 'created' event
// FlowUse record is created in flow_uses table
Important Notes:
- If a
FlowUserecord already exists, the trait skips automatic binding - If
pickFlow()returnsnull, no binding is created (no error thrown) - The
selectedFlowIdForBindingproperty is used to avoid picking the flow twice
Understanding selectedFlowIdForBinding
The selectedFlowIdForBinding property is a temporary storage mechanism:
- During
creatingevent: Flow is picked and ID is stored in this property - During
createdevent: The stored ID is used instead of picking again - Purpose: Avoids picking the flow twice (once in creating, once in created)
This is important because:
- Flow picking can be expensive (database queries, complex logic)
- Model attributes might change between
creatingandcreatedevents - Storing the ID ensures consistency
Manual Access:
You generally don't need to access this property directly, but if you do:
// This is set automatically during creating event
$order->selectedFlowIdForBinding; // int|null
Skipping Automatic Binding
If you want to skip automatic binding and handle it manually:
// Create FlowUse record before creating the model
$order = new Order([...]);
$order->flowUse()->create(['flow_id' => $flowId]);
$order->save();
// The trait will detect existing FlowUse and skip automatic binding
Performance Optimization
Caching Flow Picker Results
For models with complex flow selection logic, you can enable request-level caching:
protected function buildFlowPicker(FlowPickerBuilder $builder): void
{
parent::buildFlowPicker($builder);
// Enable caching if builder doesn't use dynamic callbacks
$builder->cacheInRequest(true);
}
Warning: Only enable if your callbacks don't depend on request state. If callbacks access $request, auth(), or other request-specific data, caching may return incorrect results.
Optimizing Flow Queries
The FlowPicker automatically optimizes queries, but you can help by:
-
Indexing database columns used in flow selection:
// Migration
Schema::table('flows', function (Blueprint $table) {
$table->index(['subject_type', 'subject_scope', 'subject_collection']);
$table->index(['environment', 'channel']);
$table->index(['status', 'active_from', 'active_to']);
}); -
Using specific criteria instead of broad filters:
// ✅ Good: Specific
$builder->environment('production')->channel('web');
// ⚠️ Less optimal: Broad
$builder->environment(null)->channel(null); -
Limiting fallback cascade to necessary steps:
// ✅ Good: Only necessary fallbacks
$builder->fallbackCascade([FlowPickerBuilder::FB_DROP_CHANNEL]);
// ⚠️ Less optimal: Too many fallbacks
$builder->fallbackCascade([
FlowPickerBuilder::FB_DROP_CHANNEL,
FlowPickerBuilder::FB_DROP_ENVIRONMENT,
FlowPickerBuilder::FB_IGNORE_TIMEWINDOW,
FlowPickerBuilder::FB_DISABLE_ROLLOUT,
FlowPickerBuilder::FB_DROP_REQUIRE_DEFAULT,
]);
Best Practices
-
Always use eager loading when working with multiple models:
$orders = Order::withFlow()->get(); -
Override
buildFlowPicker()to customize flow selection based on your business logic -
Use
flowSubjectCollection()if you have different workflow variants for the same model type -
Ensure status column exists before using the trait to avoid runtime errors
-
Use enums for status to get better type safety and automatic value detection
-
Index database columns used in flow selection for better performance
-
Keep
buildFlowPicker()efficient - avoid heavy computations in callbacks -
Use specific criteria instead of broad filters when possible
-
Limit fallback cascade to necessary steps only
-
Enable request caching only if callbacks don't depend on request state
Testing and Debugging
Testing Flow Selection
Use Flow::previewPick() to test which flow would be selected:
$order = Order::make(['user_id' => 1, 'collection' => 'premium']);
// Preview without creating the model
$flow = Flow::previewPick($order);
// Test with different configurations
$flow = Flow::previewPick($order, function ($builder) {
$builder->environment('staging');
});
Debugging Flow Picker Configuration
Log the builder configuration to see what's being used:
protected function buildFlowPicker(FlowPickerBuilder $builder): void
{
parent::buildFlowPicker($builder);
// Your configuration
$builder->subjectScope((string)$this->user_id);
// Debug: Log configuration
if (config('app.debug')) {
logger()->debug('FlowPicker configuration', [
'subject_type' => static::class,
'subject_scope' => $this->user_id,
'environment' => config('app.env'),
]);
}
}
Testing Rollout
To test rollout behavior, you can temporarily override the rollout key:
$order = Order::make(['user_id' => 1]);
// Test with specific rollout key
$flow = Flow::previewPick($order, function ($builder) use ($order) {
$builder->rolloutKeyResolver(function () {
return 'test-key-123'; // Fixed key for testing
});
});
Verifying Flow Binding
After creating a model, verify the flow was bound:
$order = Order::create([...]);
// Check if flow was bound
if ($order->boundFlow()) {
logger()->info('Flow bound successfully', [
'flow_id' => $order->boundFlow()->id,
]);
} else {
logger()->warning('No flow bound to order', [
'order_id' => $order->id,
]);
}
Common Issues and Solutions
Issue: Flow not binding automatically
// Check if flow exists
$flows = Flow::query()
->where('subject_type', Order::class)
->where('status', true)
->get();
// Check if flow matches criteria
$flow = Flow::previewPick($order);
if (!$flow) {
// No flow matches - check your buildFlowPicker() configuration
}
Issue: Wrong flow being selected
// Check all matching flows
$builder = $order->makeFlowPicker();
$candidates = (new FlowPicker())->pick($order, $builder->returnCandidates(true));
// See which flows match
foreach ($candidates as $candidate) {
logger()->info('Matching flow', [
'flow_id' => $candidate->id,
'version' => $candidate->version,
'is_default' => $candidate->is_default,
]);
}
Troubleshooting
Flow Not Binding
If a flow is not being bound automatically:
- Check that a flow exists with the correct
subject_type - Verify the flow is active and matches your selection criteria
- Check the
buildFlowPicker()configuration
Status Column Missing
If you get an error about missing status column:
// Error: Model Order must have a "status" column in table "orders"
// Solution: Add the column in a migration
Schema::table('orders', function (Blueprint $table) {
$table->string('status')->nullable();
});
Enum Not Detected
If enum values are not being detected:
- Ensure the enum class exists and is properly imported
- Check that the
$castsarray includes the status column - For custom enums, implement a
values()static method
Flow Picking Returns Null
If pickFlow() returns null, no binding is created (no error thrown):
$order = Order::create([...]);
// If pickFlow() returns null, no FlowUse record is created
// This is intentional - not all models need workflows
Common reasons for null:
- No flows match the selection criteria
- All matching flows are inactive
- Rollout percentage excludes this model
- Active window doesn't include current time
- Fallback cascade exhausted all options
Debugging:
// Preview which flow would be selected
$flow = Flow::previewPick($order);
if (!$flow) {
// No flow matches - check your buildFlowPicker() configuration
// Or check if flows exist with matching criteria
}
Multiple Flows Match
When multiple flows match the criteria, the system uses ordering to select the best one:
- Version (DESC): Higher versions preferred
- is_default (DESC): Default flows preferred
- ordering (DESC): Higher ordering preferred
- id (DESC): Higher ID as tiebreaker
You can customize this with orderBy():
protected function buildFlowPicker(FlowPickerBuilder $builder): void
{
parent::buildFlowPicker($builder);
$builder->orderBy(function ($query) {
$query->orderBy('priority', 'desc')
->orderBy('created_at', 'asc');
});
}
Fallback Cascade Exhausted
If all fallback steps are exhausted and no flow is found:
// No flow will be bound
// No error is thrown
// Model is created successfully without workflow
This is intentional - workflows are optional. If you need to enforce workflow binding, check after creation:
$order = Order::create([...]);
if (!$order->boundFlow()) {
throw new \Exception('Order must have a workflow');
}
Related Documentation
- HasFlow - Simple flow binding for single-flow scenarios
- FlowPickerBuilder - Customizing flow selection logic
- FlowPicker - Understanding flow picking mechanism
- Flow Service - Managing workflows
- FlowState Service - Managing states
- FlowTransition Service - Executing transitions
- TransitionResult - Understanding transition results
- Installation - Package installation guide