-
-
Notifications
You must be signed in to change notification settings - Fork 0
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.
First, publish and run the migrations:
php artisan vendor:publish --tag=statecraft-migrations
php artisan migrate
This creates the state_machine_histories
table.
Enable history tracking in your configuration:
// config/statecraft.php
'enable_history' => true,
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');
});
$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}";
}
$order = Order::find(1);
$lastChange = $order->getLastStateChange();
if ($lastChange) {
echo "Last changed to: {$lastChange->to_state}";
echo "At: {$lastChange->transitioned_at}";
}
// 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}";
}
// 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();
// 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;
// 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";
}
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');
});
// 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";
// 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');
// 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();
// 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";
}
// 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();
// 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>
// 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 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();
}
}
<?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);
}
}
<?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);
}
}
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"');
}
Now that you understand State History:
- Learn Console Commands - Use CLI tools for management
- Explore Testing Guide - Test your state machines effectively
- 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.
π― Laravel Statecraft - Advanced State Machine Implementation for Laravel
Navigate: π Home | π¦ Installation | π Basic Guide | π YAML Config | π‘ Examples
Resources: π‘οΈ Guards & Actions | π― Events | π State History | π§ͺ Testing | π¨ Commands
π Community Resources:
Made with β€οΈ for the Laravel community β’ Contribute β’ License