Skip to content

State History

Jean-Marc Strauven edited this page Aug 6, 2025 · 2 revisions

πŸ“Š State History

Laravel Statecraft provides comprehensive state history tracking, allowing you to audit all state changes, analyze workflows, and maintain a complete audit trail of your model's lifecycle.

πŸ—οΈ Setup

Enable History Tracking

First, publish and run the migrations:

php artisan vendor:publish --tag=statecraft-migrations
php artisan migrate

This creates the state_machine_histories table.

Configuration

Enable history tracking in your configuration:

// config/statecraft.php
'enable_history' => true,

πŸ“‹ History Table Structure

The state_machine_histories table stores:

Schema::create('state_machine_histories', function (Blueprint $table) {
    $table->id();
    $table->string('model_type');           // Model class name
    $table->unsignedBigInteger('model_id'); // Model ID
    $table->string('from_state');           // Previous state
    $table->string('to_state');             // New state
    $table->string('transition');           // Transition name
    $table->json('context')->nullable();    // Additional context
    $table->unsignedBigInteger('user_id')->nullable(); // User who triggered
    $table->timestamp('transitioned_at');   // When it happened
    $table->timestamps();
    
    $table->index(['model_type', 'model_id']);
    $table->index('transitioned_at');
});

πŸ” Accessing History

Get Model History

$order = Order::find(1);

// Get all state history
$history = $order->getStateHistory();

foreach ($history as $record) {
    echo "Changed from {$record->from_state} to {$record->to_state} at {$record->transitioned_at}";
    echo "Transition: {$record->transition}";
    echo "User: {$record->user_id}";
}

Get Latest State Change

$order = Order::find(1);

$lastChange = $order->getLastStateChange();

if ($lastChange) {
    echo "Last changed to: {$lastChange->to_state}";
    echo "At: {$lastChange->transitioned_at}";
}

History with Relationships

// Get history with user information
$history = $order->getStateHistory()->with('user');

foreach ($history as $record) {
    $userName = $record->user ? $record->user->name : 'System';
    echo "Changed by: {$userName}";
}

πŸ“ˆ History Analysis

Count Transitions

// Count total state changes
$totalChanges = $order->getStateHistory()->count();

// Count specific transitions
$paymentCount = $order->getStateHistory()
    ->where('to_state', 'paid')
    ->count();

// Count transitions by user
$userTransitions = $order->getStateHistory()
    ->where('user_id', auth()->id())
    ->count();

Time Analysis

// Get time spent in each state
$order = Order::find(1);
$history = $order->getStateHistory()->orderBy('transitioned_at');

$timeInStates = [];
$previousTime = $order->created_at;

foreach ($history as $record) {
    $state = $record->from_state;
    $duration = $previousTime->diffInMinutes($record->transitioned_at);
    
    $timeInStates[$state] = ($timeInStates[$state] ?? 0) + $duration;
    $previousTime = $record->transitioned_at;
}

// Time in current state
$currentStateDuration = $previousTime->diffInMinutes(now());
$timeInStates[$order->getCurrentState()] = $currentStateDuration;

Workflow Analysis

// Find common transition paths
$transitions = Order::join('state_machine_histories', function ($join) {
        $join->on('orders.id', '=', 'state_machine_histories.model_id')
             ->where('state_machine_histories.model_type', Order::class);
    })
    ->selectRaw('from_state, to_state, COUNT(*) as count')
    ->groupBy('from_state', 'to_state')
    ->orderBy('count', 'desc')
    ->get();

foreach ($transitions as $transition) {
    echo "{$transition->from_state} β†’ {$transition->to_state}: {$transition->count} times\n";
}

πŸ“Š Reports and Analytics

Daily State Changes

use App\Models\StateHistory;

// Get daily transition counts
$dailyStats = StateHistory::where('model_type', Order::class)
    ->where('transitioned_at', '>=', now()->subDays(30))
    ->selectRaw('DATE(transitioned_at) as date, to_state, COUNT(*) as count')
    ->groupBy('date', 'to_state')
    ->orderBy('date')
    ->get();

// Group by date for chart display
$chartData = $dailyStats->groupBy('date')->map(function ($dayData) {
    return $dayData->pluck('count', 'to_state');
});

Performance Metrics

// Average time from creation to completion
$completionTimes = Order::join('state_machine_histories', function ($join) {
        $join->on('orders.id', '=', 'state_machine_histories.model_id')
             ->where('state_machine_histories.to_state', 'delivered');
    })
    ->selectRaw('orders.created_at, state_machine_histories.transitioned_at')
    ->selectRaw('TIMESTAMPDIFF(HOUR, orders.created_at, state_machine_histories.transitioned_at) as hours')
    ->get();

$averageCompletionTime = $completionTimes->avg('hours');
echo "Average completion time: {$averageCompletionTime} hours";

State Distribution

// Current state distribution
$stateDistribution = Order::selectRaw('status, COUNT(*) as count')
    ->groupBy('status')
    ->pluck('count', 'status');

// Historical state distribution
$historicalDistribution = StateHistory::where('model_type', Order::class)
    ->where('transitioned_at', '>=', now()->subMonth())
    ->selectRaw('to_state, COUNT(*) as count')
    ->groupBy('to_state')
    ->pluck('count', 'to_state');

πŸ” Advanced History Queries

Complex Filtering

// Find orders that were cancelled after being paid
$cancelledAfterPayment = StateHistory::where('model_type', Order::class)
    ->where('to_state', 'cancelled')
    ->whereIn('model_id', function ($query) {
        $query->select('model_id')
              ->from('state_machine_histories')
              ->where('model_type', Order::class)
              ->where('to_state', 'paid');
    })
    ->with('model')
    ->get();

User Activity Tracking

// Track which users are most active in state changes
$userActivity = StateHistory::where('model_type', Order::class)
    ->where('transitioned_at', '>=', now()->subWeek())
    ->selectRaw('user_id, COUNT(*) as transitions, COUNT(DISTINCT model_id) as orders_affected')
    ->groupBy('user_id')
    ->with('user')
    ->orderBy('transitions', 'desc')
    ->get();

foreach ($userActivity as $activity) {
    $user = $activity->user ? $activity->user->name : 'System';
    echo "{$user}: {$activity->transitions} transitions on {$activity->orders_affected} orders\n";
}

Anomaly Detection

// Find orders with unusual state patterns
$unusualPatterns = StateHistory::where('model_type', Order::class)
    ->selectRaw('model_id, COUNT(*) as transition_count')
    ->groupBy('model_id')
    ->having('transition_count', '>', 10) // More than 10 transitions
    ->with('model')
    ->get();

// Find rapid state changes (multiple changes in short time)
$rapidChanges = StateHistory::where('model_type', Order::class)
    ->where('transitioned_at', '>=', now()->subHour())
    ->selectRaw('model_id, COUNT(*) as changes_last_hour')
    ->groupBy('model_id')
    ->having('changes_last_hour', '>', 3)
    ->get();

πŸ“± UI Integration

History Timeline Component

// In your controller
public function showOrderHistory(Order $order)
{
    $history = $order->getStateHistory()
        ->with('user')
        ->orderBy('transitioned_at', 'desc')
        ->get();
    
    return view('orders.history', compact('order', 'history'));
}
{{-- resources/views/orders/history.blade.php --}}
<div class="timeline">
    @foreach($history as $record)
        <div class="timeline-item">
            <div class="timeline-marker state-{{ $record->to_state }}"></div>
            <div class="timeline-content">
                <h4>{{ ucfirst($record->to_state) }}</h4>
                <p>
                    Transitioned from <strong>{{ $record->from_state }}</strong> 
                    to <strong>{{ $record->to_state }}</strong>
                </p>
                <small>
                    {{ $record->transitioned_at->format('M j, Y g:i A') }}
                    @if($record->user)
                        by {{ $record->user->name }}
                    @endif
                </small>
            </div>
        </div>
    @endforeach
</div>

State Duration Display

// Helper to calculate state durations
class StateDurationCalculator
{
    public static function calculate(Model $model): array
    {
        $history = $model->getStateHistory()->orderBy('transitioned_at')->get();
        $durations = [];
        $previousTime = $model->created_at;
        
        foreach ($history as $record) {
            $duration = $previousTime->diffInMinutes($record->transitioned_at);
            $durations[$record->from_state] = $duration;
            $previousTime = $record->transitioned_at;
        }
        
        // Current state duration
        $currentDuration = $previousTime->diffInMinutes(now());
        $durations[$model->getCurrentState()] = $currentDuration;
        
        return $durations;
    }
}

πŸ“ˆ Dashboard Metrics

State Machine Dashboard

// Dashboard controller
class StateMachineDashboardController extends Controller
{
    public function index()
    {
        $metrics = [
            'total_orders' => Order::count(),
            'state_distribution' => $this->getStateDistribution(),
            'daily_transitions' => $this->getDailyTransitions(),
            'average_completion_time' => $this->getAverageCompletionTime(),
            'most_active_users' => $this->getMostActiveUsers(),
        ];
        
        return view('dashboard.state-machine', compact('metrics'));
    }
    
    private function getStateDistribution()
    {
        return Order::selectRaw('status, COUNT(*) as count')
            ->groupBy('status')
            ->pluck('count', 'status');
    }
    
    private function getDailyTransitions()
    {
        return StateHistory::where('model_type', Order::class)
            ->where('transitioned_at', '>=', now()->subDays(7))
            ->selectRaw('DATE(transitioned_at) as date, COUNT(*) as count')
            ->groupBy('date')
            ->orderBy('date')
            ->pluck('count', 'date');
    }
    
    private function getAverageCompletionTime()
    {
        // Implementation for average completion time calculation
    }
    
    private function getMostActiveUsers()
    {
        return StateHistory::where('model_type', Order::class)
            ->where('transitioned_at', '>=', now()->subWeek())
            ->selectRaw('user_id, COUNT(*) as transitions')
            ->groupBy('user_id')
            ->with('user')
            ->orderBy('transitions', 'desc')
            ->limit(10)
            ->get();
    }
}

πŸ§ͺ Testing History

Testing History Recording

<?php

namespace Tests\Feature;

use App\Models\Order;
use App\Models\StateHistory;
use Tests\TestCase;

class StateHistoryTest extends TestCase
{
    public function test_state_change_is_recorded_in_history()
    {
        $order = Order::factory()->create(['status' => 'pending']);
        
        $this->assertEquals(0, $order->getStateHistory()->count());
        
        $order->transitionTo('pay');
        
        $this->assertEquals(1, $order->getStateHistory()->count());
        
        $history = $order->getLastStateChange();
        $this->assertEquals('pending', $history->from_state);
        $this->assertEquals('paid', $history->to_state);
    }
    
    public function test_user_is_recorded_in_history()
    {
        $user = User::factory()->create();
        $this->actingAs($user);
        
        $order = Order::factory()->create(['status' => 'pending']);
        $order->transitionTo('pay');
        
        $history = $order->getLastStateChange();
        $this->assertEquals($user->id, $history->user_id);
    }
    
    public function test_context_is_stored_in_history()
    {
        $order = Order::factory()->create(['status' => 'pending']);
        
        $context = ['payment_method' => 'credit_card', 'amount' => 100];
        $order->transitionTo('pay', $context);
        
        $history = $order->getLastStateChange();
        $this->assertEquals($context, $history->context);
    }
}

πŸ”§ Custom History Models

Extending History Functionality

<?php

namespace App\Models;

use Grazulex\LaravelStatecraft\Models\StateHistory as BaseStateHistory;

class StateHistory extends BaseStateHistory
{
    protected $casts = [
        'context' => 'array',
        'transitioned_at' => 'datetime',
    ];
    
    // Add custom relationships
    public function user()
    {
        return $this->belongsTo(User::class);
    }
    
    // Add custom methods
    public function getDurationInPreviousStateAttribute()
    {
        $previousRecord = static::where('model_type', $this->model_type)
            ->where('model_id', $this->model_id)
            ->where('transitioned_at', '<', $this->transitioned_at)
            ->orderBy('transitioned_at', 'desc')
            ->first();
            
        if (!$previousRecord) {
            // Calculate from model creation
            $model = $this->model;
            return $model->created_at->diffInMinutes($this->transitioned_at);
        }
        
        return $previousRecord->transitioned_at->diffInMinutes($this->transitioned_at);
    }
}

πŸ“Š Export and Reporting

CSV Export

public function exportHistory(Order $order)
{
    $history = $order->getStateHistory()->with('user')->get();
    
    $csv = "Date,From State,To State,Transition,User,Duration (minutes)\n";
    
    $previousTime = $order->created_at;
    
    foreach ($history as $record) {
        $duration = $previousTime->diffInMinutes($record->transitioned_at);
        $userName = $record->user ? $record->user->name : 'System';
        
        $csv .= sprintf(
            "%s,%s,%s,%s,%s,%d\n",
            $record->transitioned_at->format('Y-m-d H:i:s'),
            $record->from_state,
            $record->to_state,
            $record->transition,
            $userName,
            $duration
        );
        
        $previousTime = $record->transitioned_at;
    }
    
    return response($csv)
        ->header('Content-Type', 'text/csv')
        ->header('Content-Disposition', 'attachment; filename="order-' . $order->id . '-history.csv"');
}

πŸš€ What's Next?

Now that you understand State History:

  1. Learn Console Commands - Use CLI tools for management
  2. Explore Testing Guide - Test your state machines effectively
  3. See Real Examples - Complete workflow implementations

Ready to track your state changes? State history provides powerful insights into your workflow performance and user behavior patterns.

Clone this wiki locally