Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save mserge/25d2d5c092820187aff824136d79fd34 to your computer and use it in GitHub Desktop.
Save mserge/25d2d5c092820187aff824136d79fd34 to your computer and use it in GitHub Desktop.
FiniteAuditTrail Trait For Laravel 4 with Example. Support for keeping an audit trail for any state machine, borrowed from the Ruby StateMachine audit trail gem.

FiniteAuditTrail Trait

Support for keeping an audit trail for any state machine, borrowed from the Ruby StateMachine audit trail gem. Having an audit trail gives you a complete history of the state changes in your model. This history allows you to investigate incidents or perform analytics, like: “How long does it take on average to go from state a to state b?”, or “What percentage of cases goes from state a to b via state c?”

Requirements

  1. PHP >= 5.4
  2. Laravel 4 framework
  3. Install Finite package
  4. Use FiniteStateMachine in your Eloquent model

Usage

In your Stateful Class use this trait after FiniteStateMachine trait, like this use FiniteAuditTrail;. Then call initAuditTrail() method at the end of initialization (__contruct() method) after initStateMachine() and parent::__construct() methods call. Then create or complete the static boot() method in your model like this:

<?php
    public static function boot()
    {
        parent::boot();
        static::finiteAuditTrailBoot();
    }

Finally add the column_names() function in your app/start/global.php or app/helpers.php file.

Example

Check MyStatefulModel.php, MyStatefulModelStateTransition.php and Usage.php.

And the migration files: 2013_10_01_124809_create_my_stateful_models_table.php and 2013_10_01_125509_create_my_stateful_model_state_transitions_table.

You should migrate your database with this command:

php artisan migrate

License

MIT

Use At Your Own Risk

<?php
// app/database/migrations/2013_10_01_124809_create_my_stateful_models_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
class CreateMyStatefulModelsTable extends Migration {
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('my_stateful_models', function(Blueprint $table) {
$table->increments('id');
$table->string('title');
$table->text('body');
$table->string('state')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::drop('my_stateful_models');
}
}
<?php
// app/database/migrations/2013_10_01_125509_create_my_stateful_model_state_transitions_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
class CreateMyStatefulModelStateTransitionsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('my_stateful_model_state_transitions', function(Blueprint $table) {
$table->increments('id');
$table->string('event')->nullable();
$table->string('from')->nullable();
$table->string('to');
// $table->string('custom_field')->nullable();
$table->integer('my_stateful_model_id');
$table->string('my_stateful_model_type')->nullable();
// $table->foreign('my_stateful_model_id')->references('id')->on('my_stateful_models');//Optional foreign key
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::drop('my_stateful_model_state_transitions');
}
}
<?php
/**
* The FiniteAuditTrail Trait.
* This plugin, for the Finite package (see https://github.com/yohang/Finite), adds support for keeping an audit trail for any state machine.
* Having an audit trail gives you a complete history of the state changes in your stateful model.
* Prerequisites:
* 1. Install Finite package (https://github.com/yohang/Finite#readme)
* 2. Use FiniteStateMachine in your model (https://gist.github.com/tortuetorche/6365575)
* Usage: in your Stateful Class use this trait after FiniteStateMachine trait, like this "use FiniteAuditTrail;".
* Then call initAuditTrail() method at the end of initialization (__contruct() method) after initStateMachine() and parent::__construct() call.
* Finally create or complete the static boot() method in your model like this:
*
* class MyStatefulModel extends Eloquent implements Finite\StatefulInterface
* {
*
* use FiniteStateMachine;
* use FiniteAuditTrail;
*
* public static function boot()
* {
* parent::boot();
* static::finiteAuditTrailBoot();
* }
*
* public function __construct($attributes = [])
* {
* $this->initStateMachine();
* parent::__construct($attributes);
* $this->initAuditTrail();
* }
* }
*
* Optionally in your AuditTrail model, you can create a $statefulModel property to access the StateMachine model who's audited:
*
* class MyStatefulModelStateTransition extends Eloquent
* {
* public $statefulModel;
* }
*
* @author Tortue Torche <tortuetorche@spam.me>
*/
trait FiniteAuditTrail
{
public static function finiteAuditTrailBoot()
{
static::saveInitialState();
}
protected static function saveInitialState()
{
static::created(function ($model) {
$transition = new \Finite\Transition\Transition(null, null, $model->findInitialState());
$model->storeAuditTrail($model, $transition, false);
});
}
protected $auditTrailModel;
protected $auditTrailName;
protected $auditTrailAttributes;// We can't set an empty array as default value here, maybe a PHP Trait bug ?
/**
* @param mixed|array|args $args
* if $args is an array:
* initAuditTrail(['to' => 'ModelAuditTrail', 'attributes' => ['name', 'email'], 'prepend' => true]);
* else: initAuditTrail('ModelAuditTrail', ['name', 'email']);
* first param: string $to Model name who stores the history
* second param: string|array $attributes Attribute(s) or method(s) from stateful model to save
*/
protected function initAuditTrail($args = null)
{
// Default options
$options = ['attributes' => (array) $this->auditTrailAttributes, 'to' => "\\".get_called_class()."StateTransition"];
if (func_num_args() === 2) {
$args = func_get_args();
list($options['to'], $attributes) = $args;
$newOptions = array_extract_options($attributes);
$options['attributes'] = $attributes;
$options = array_merge($options, $newOptions);
} elseif (func_num_args() === 1) {
if (is_array($args)) {
$newOptions = array_extract_options($args);
if (empty($newOptions)) {
$options['attributes'] = $args;
} else {
$options = array_merge($options, $newOptions);
}
} elseif (is_string($args)) {
$options['to'] = $args;
}
}
$this->auditTrailAttributes = (array) $options['attributes'];
$this->auditTrailName = $options['to'];
if (array_get($options, 'prepend') === true) {
// Audit trail State Machine changes at the first 'after' transition
$this->prependAfter([$this, 'storeAuditTrail']);
} else {
// Audit trail State Machine changes at the last 'after' transition
$this->addAfter([$this, 'storeAuditTrail']);
}
}
/**
* Create a new model instance that is existing.
*
* @param array $attributes
* @return \Illuminate\Database\Eloquent\Model|static
*/
public function newFromBuilder($attributes = [])
{
$instance = parent::newFromBuilder($attributes);
$this->restoreAuditTrail($instance);
return $instance;
}
/**
* @param \Illuminate\Database\Eloquent\Model|static $instance
*/
protected function restoreAuditTrail($instance)
{
// Initialize the StateMachine when the $instance is loaded from the database and not created via __construct() method
$instance->getStateMachine()->initialize();
}
/**
* @param object $self
* @param \Finite\Event\TransitionEvent|Finite\Transition\Transition $transitionEvent
* @param boolean $save Optional, default: true
*/
public function storeAuditTrail($self, $transitionEvent, $save = true)
{
// Save State Machine model to log initial state
if ($save === true || $this->exists === false) {
$this->save();
}
if (is_a($transitionEvent, "\Finite\Event\TransitionEvent")) {
$transition = $transitionEvent->getTransition();
} else {
$transition = $transitionEvent;
}
$this->auditTrailModel = \App::make($this->auditTrailName);
if (property_exists($this->auditTrailModel, 'statefulModel')) {
$this->auditTrailModel->statefulModel = $this;
}
$values = [];
$values['event'] = $transition->getName();
$initialStates = $transition->getInitialStates();
if (! empty($initialStates)) {
$values['from'] = $transitionEvent->getInitialState()->getName();
}
$values['to'] = $transition->getState();
$statefulName = $this->auditTrailModel->statefulName ?: snake_case(str_singular($this->getTable()));
$values[$statefulName.'_id'] = $this->getKey();//Foreign key
$statefulType = $statefulName.'_type';
$columnNames = column_names($this->auditTrailModel->getTable());
if (in_array($statefulType, $columnNames)) {
$values[$statefulType] = get_class($this);//For morph relation
}
// TODO: Fill and save additional attributes in a created()/afterCreate() model event
foreach ((array) $this->auditTrailAttributes as $attribute) {
if ($this->getAttribute($attribute)) {
$values[$attribute] = $this->getAttribute($attribute);
}
}
$this->auditTrailModel->fill($values);
$validated = $this->auditTrailModel->save();
if (! $validated) {
// TODO: Use this $validationErrors var in the Exception class
// $validationErrors = '<ul>'.implode('', array_values($this->auditTrailModel->errors()->all('<li>:message</li>'))).'</ul>';
throw new \Illuminate\Database\Eloquent\ModelNotFoundException("Unable to save auditTrail model '".$this->auditTrailName."'");
}
}
public function getAuditTrailModel()
{
return $this->auditTrailModel;
}
}
<?php
// Add this function in your 'app/start/global.php' or 'app/helpers.php' file
if (! function_exists('column_names')) {
/**
* @param string $table
* @param string $connectionName Database connection name
*
* @return array
*/
function column_names($table, $connectionName = null)
{
$schema = \DB::connection($connectionName)->getDoctrineSchemaManager();
return array_map(function ($var) {
return str_replace('"', '', $var); // PostgreSQL need this replacement
}, array_keys($schema->listTableColumns($table)));
}
}
<?php
// app/models/MyStatefulModel.php
class MyStatefulModel extends Eloquent implements Finite\StatefulInterface
{
use FiniteStateMachine;
use FiniteAuditTrail;
public static function boot()
{
parent::boot();
static::finiteAuditTrailBoot();
}
public function __construct($attributes = [])
{
$this->initStateMachine();
parent::__construct($attributes);
$this->initAuditTrail();
}
protected function stateMachineConfig()
{
return [
'states' => [
's1' => [
'type' => 'initial',
'properties' => ['deletable' => true, 'editable' => true],
],
's2' => [
'type' => 'normal',
'properties' => [],
],
's3' => [
'type' => 'final',
'properties' => [],
]
],
'transitions' => [
't12' => ['from' => ['s1'], 'to' => 's2'],
't23' => ['from' => ['s2'], 'to' => 's3'],
't21' => ['from' => ['s2'], 'to' => 's1'],
],
'callbacks' => [
'before' => [
['on' => 't12', 'do' => [$this, 'beforeTransitionT12']],
['from' => 's2', 'to' => 's3', 'do' => function ($myStatefulInstance, $transitionEvent) {
echo "Before callback from 's2' to 's3'";// debug
}],
['from' => '-s3', 'to' => ['s3' ,'s1'], 'do' => [$this, 'fromStatesS1S2ToS1S3']],
],
'after' => [
['from' => 'all', 'to' => 'all', 'do' => [$this, 'afterAllTransitions']],
],
],
];
}
public function beforeTransitionT12($myStatefulInstance, $transitionEvent)
{
echo "Function called before transition: '".$transitionEvent->getTransition()->getName()."' !";// debug
}
public function fromStatesS1S2ToS1S3()
{
echo "Before callback from states 's1' or 's2' to 's1' or 's3'";// debug
}
public function afterAllTransitions($myStatefulInstance, $transitionEvent)
{
echo "After All Transitions !";// debug
}
}
<?php
// app/models/MyStatefulModelStateTransition.php
class MyStatefulModelStateTransition extends Eloquent
{
public $statefulModel;
// Set the stateful attribute prefix.
// Useful for polymorphic association, when it's different than the table name of the stateful Model.
// E.g. 'my_stateful_model_id' and 'my_stateful_model_type' attributes become 'imageable_id' and 'imageable_type'.
// public $statefulName = 'imageable';
protected $guarded = ['id', 'created_at', 'updated_at'];
public static function boot()
{
parent::boot();
// Pro tips:
static::creating(function($model) {
// You can save fields/additional attributes in your database here:
// $model->custom_field = "Hello world!";
});
}
}
<?php
$myStatefulObject = new MyStatefulModel;
$myStatefulObject->getState(); // → "s1"
$myStatefulObject->can('t23'); // → false
$myStatefulObject->can('t12'); // → true
$myStatefulObject->apply('t12'); // $myStatefulObject is saved in the database
$myStatefulObject->is('s2'); // → true
MyStatefulModel::orderBy('id', 'desc')->first()->getState();// → "s2"
MyStatefulModel::orderBy('id', 'desc')->first()->is('s2'); // → true
MyStatefulModelStateTransition::orderBy('id', 'desc')->first()->event;// → "t12"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment