Lifecycle stages
Move contacts and companies through lifecycle stages with the state machine, record transitions, add guards, and measure funnel velocity.
Contacts and companies have a lifecycle_stage column cast to Odden\Core\Enums\LifecycleStage. Change it with TransitionLifecycleStageAction, which validates the change against LifecycleStateMachine, stamps a became_<stage>_at column, records a LifecycleStageTransition, and dispatches LifecycleStageChanged.
Stages
| Case | Value | label() |
|---|---|---|
Subscriber |
subscriber |
Subscriber |
Lead |
lead |
Lead |
MarketingQualifiedLead |
marketing_qualified_lead |
Marketing Qualified Lead (MQL) |
SalesQualifiedLead |
sales_qualified_lead |
Sales Qualified Lead (SQL) |
Opportunity |
opportunity |
Opportunity |
Customer |
customer |
Customer |
Evangelist |
evangelist |
Evangelist |
Other |
other |
Other |
New contacts and companies default to lead at the database level.
Transitioning a record
use Odden\Core\Actions\TransitionLifecycleStageAction;
use Odden\Core\Enums\LifecycleStage;
$transition = app(TransitionLifecycleStageAction::class)->execute(
$contact,
LifecycleStage::MarketingQualifiedLead,
source: 'lead_scoring',
userId: auth()->id(),
);
$transition->from_stage; // LifecycleStage::Lead
$contact->lifecycle_stage; // LifecycleStage::MarketingQualifiedLead
$contact->became_marketing_qualified_lead_at; // nowexecute(Model $record, LifecycleStage $toStage, string $source = 'manual', ?int $userId = null, bool $force = false): LifecycleStageTransition
- Validates the transition (see below), unless
$forceistrue. - Sets
lifecycle_stage, and setsbecame_<stage>_attonow()if it is stillnull, so it keeps the first time the record reached that stage. - Saves with
saveQuietly(). No model events fire and no property history is written. - Creates a
LifecycleStageTransitionwithduration_secondsset to the time since the previous transition, or since the record was created. - Dispatches
LifecycleStageChanged.
Setting lifecycle_stage directly with update() bypasses all of this: no validation, no transition row, no event.
$source is a free-form string. It is stored on the transition and used by the customer-regression rule below.
Validation rules
LifecycleStateMachine::validateTransition() throws Odden\Core\Exceptions\InvalidLifecycleStageTransitionException when a transition isn't allowed. It applies these rules in order:
- A record with no current stage, or a transition to its current stage, is always allowed.
- In strict mode, the transition must be in the allowed graph (below).
- A
Customercannot move back toSubscriber,Lead,MarketingQualifiedLead,SalesQualifiedLead, orOpportunityunless$sourceis one ofchurn,recycle,downgrade,disqualified, orrefund(case-insensitive). This rule applies even when strict mode is off. - Every registered guard must not return
false.
Passing force: true skips all of them.
use Odden\Core\Exceptions\InvalidLifecycleStageTransitionException;
try {
app(TransitionLifecycleStageAction::class)->execute($customer, LifecycleStage::Lead);
} catch (InvalidLifecycleStageTransitionException $e) {
app(TransitionLifecycleStageAction::class)->execute($customer, LifecycleStage::Lead, source: 'churn');
}Strict mode
Strict mode is off by default, so any stage can move to any other stage, subject to the customer rule. Turn it on in config:
// config/odden-core.php
'lifecycle' => [
'strict_transitions' => true,
],Or at runtime: app(LifecycleStateMachine::class)->setStrict(true). A value set with setStrict() takes precedence over the config value.
In strict mode these transitions are allowed:
| From | To |
|---|---|
Subscriber |
Lead, MarketingQualifiedLead, Other |
Lead |
MarketingQualifiedLead, SalesQualifiedLead, Other |
MarketingQualifiedLead |
SalesQualifiedLead, Opportunity, Lead, Other |
SalesQualifiedLead |
Opportunity, MarketingQualifiedLead, Lead, Other |
Opportunity |
Customer, SalesQualifiedLead, Lead, Other |
Customer |
Evangelist, Other |
Evangelist |
Customer, Other |
Other |
any stage |
Customer to Lead is not in the graph, so in strict mode a churn source alone isn't enough. Add the edge or use force.
Customizing the state machine
LifecycleStateMachine is a container singleton. Configure it in a service provider's boot() method:
use Odden\Core\Enums\LifecycleStage;
use Odden\Core\Support\LifecycleStateMachine;
use Illuminate\Database\Eloquent\Model;
$machine = app(LifecycleStateMachine::class);
$machine->allowTransition(LifecycleStage::Subscriber, LifecycleStage::Customer);
$machine->registerGuard('requires_owner', function (Model $record, ?LifecycleStage $from, LifecycleStage $to, string $source): bool {
return $to !== LifecycleStage::Opportunity || $record->getAttribute('owner_id') !== null;
});A guard receives the record, the current stage, the target stage, and the source. Returning false blocks the transition with the message "Lifecycle stage transition guard [requires_owner] failed for record [id]." Any other return value allows it. Registering a guard under an existing name replaces it.
Other methods: canTransition(LifecycleStage $from, LifecycleStage $to): bool, allowedTransitions(LifecycleStage $from): array, and isStrict(): bool.
Transition history
Odden\Core\Models\LifecycleStageTransition (table odden_lifecycle_stage_transitions) stores record_type, record_id, from_stage, to_stage, duration_seconds, source, user_id, team_id (copied from the record), and transitioned_at. It has record() and user() relations and these helpers:
durationInDays(): ?float, rounded to 2 decimals.durationInHours(): ?float, rounded to 1 decimal.formattedDuration(): string, such as3 days,1 hour,5 mins, or< 1 min.
The HasLifecycleStageTransitions trait on Contact and Company adds:
$contact->lifecycleTransitions; // newest first
$contact->latestLifecycleTransition(); // ?LifecycleStageTransition
$contact->timeInCurrentStageSeconds(); // int
$contact->timeInCurrentStageDays(); // float
$contact->formattedTimeInCurrentStage(); // "12 days"Time in the current stage is measured from the latest transition, or from created_at if there isn't one.
Funnel velocity
CalculateFunnelVelocityAction aggregates transition durations for one model class:
use Odden\Core\Actions\CalculateFunnelVelocityAction;
use Odden\Core\Enums\LifecycleStage;
use Odden\Core\Models\Contact;
$metrics = app(CalculateFunnelVelocityAction::class)->execute(
Contact::class,
fromStage: LifecycleStage::Lead,
toStage: LifecycleStage::MarketingQualifiedLead,
startDate: now()->subDays(90),
);execute(string $recordClass, ?LifecycleStage $fromStage = null, ?LifecycleStage $toStage = null, ?CarbonInterface $startDate = null, ?CarbonInterface $endDate = null, ?int $teamId = null): array returns:
| Key | Value |
|---|---|
total_transitions |
Number of matching transitions. |
average_duration_seconds |
Float. |
average_duration_days, median_duration_days, min_duration_days, max_duration_days |
Floats, rounded to 2 decimals. |
transitions_by_stage |
A list of from_stage, to_stage, count, avg_days, and formatted_avg_duration per pair of stages. |
The dates filter on transitioned_at. All values are 0 and transitions_by_stage is empty when nothing matches.