Skip to content

Instantly share code, notes, and snippets.

@mass6
Last active August 29, 2017 10:19
Show Gist options
  • Save mass6/b85d4bdf168157387f49cb988e6b12d3 to your computer and use it in GitHub Desktop.
Save mass6/b85d4bdf168157387f49cb988e6b12d3 to your computer and use it in GitHub Desktop.
Laravel 5.3 Finite State Machine Trait

Finite State Machine for Laravel 5.3

Requires https://github.com/yohang/Finite

<?php

class StatefulModel extends Model
{

    use FiniteStateMachineTrait, FiniteAuditTrailTrait;
  
  
    public function __construct($attributes = [])
    {
        $this->initStateMachine();
        parent::__construct($attributes);
        $this->initAuditTrail([
            'auditTrailClass' => TransitionEvent::class,
            'storeAuditTrailOnFirstAfterCallback' => false,
            'attributes' => []
        ]);
    }
    
    /**
     * @return array
     */
    protected function stateMachineConfig()
    {
        return [
            'class' => get_class(),
            'states' => [
                's1' => [
                    'type' => 'initial',
                    'properties' => ['text' => 'draft'],
                ],
                's2' => [
                    'type' => 'normal',
                    'properties' => ['text' => 'pending approval'],
                ],
                's3' => [
                    'type' => 'final',
                    'properties' => ['text' => 'approved'],
                ]
            ],
            'transitions' => [
                't12' => ['from' => ['s1'], 'to' => 's2', 'properties' => []],
                't23' => ['from' => ['s2'], 'to' => 's3', 'properties' => []],
            ],
            'callbacks' => [
                'before' => [
                    ['on' => 't12', 'do' => [$this, 'beforeT12']],
                    ['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']],
                ],
            ],
        ];
    }
    
<?php
namespace App;
/**
* Transition event logging trait. Persists a transition event record
* to the database each time a state machine transition occurs.
*/
trait FiniteAuditTrailTrait
{
/**
* Boots the audit trail trait
*/
public static function bootFiniteAuditTrailTrait()
{
static::saveInitialState();
}
/**
* Save the model's initial state to the database when it is first created.
*/
protected static function saveInitialState()
{
static::created(function ($model) {
$transition = new \Finite\Transition\Transition(null, null, $model->findInitialState());
$model->storeAuditTrail($transition, false);
});
}
/**
* Model instance
*
* @var
*/
protected $auditTrailModel;
/**
* Class name of the model
*
* @var
*/
protected $auditTrailClass;
/**
* Attributes to be saved along with the transition event.
*
* @var
*/
protected $auditTrailAttributes;
/**
* Initializes the audit trail
*
* @param array $options
*/
protected function initAuditTrail(array $options = [])
{
$this->auditTrailAttributes = array_get($options, 'attributes', []);
$this->auditTrailClass = array_get($options, 'auditTrailClass', "\\".get_called_class()."StateTransition");
if (array_get($options, 'storeAuditTrailOnFirstAfterCallback') === 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'], [$this]);
}
}
/**
* Create a new model instance that is existing.
*
* @param array $attributes
* @return \Illuminate\Database\Eloquent\Model|static
*/
public function newFromBuilder($attributes = [], $connection = null)
{
$instance = parent::newFromBuilder($attributes, $connection);
$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();
}
/**
* Persists the audit trail record to the database
*
* @param \Finite\Event\TransitionEvent|Finite\Transition\Transition $transitionEvent
* @param bool $save
*/
public function storeAuditTrail($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($this->auditTrailClass);
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 = $this->column_names($this->auditTrailModel->getTable());
if (in_array($statefulType, $columnNames)) {
$values[$statefulType] = get_class($this);//For morph relation
}
foreach ($this->auditTrailAttributes as $attribute) {
if (is_array($attribute)) {
if (is_callable(current($attribute))) {
$values[key($attribute)] = call_user_func(current($attribute));
}
} else {
if ($this->getAttribute($attribute)) {
$values[$attribute] = $this->getAttribute($attribute);
}
}
}
$this->auditTrailModel->fill($values);
$validated = $this->auditTrailModel->save();
if (! $validated) {
throw new \Illuminate\Database\Eloquent\ModelNotFoundException("Unable to save auditTrail model '".$this->auditTrailClass."'");
}
}
/**
* @return mixed
*/
public function getAuditTrailModel()
{
return $this->auditTrailModel;
}
/**
* Get the model's transition history
*
* @return mixed
*/
public function transitionHistory()
{
return $this->morphMany($this->auditTrailClass, 'stateful');
}
/**
* @param string $table
* @param string $connectionName Database connection name
*
* @return array
*/
protected 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
namespace App;
use Illuminate\Support\Facades\Log;
use Finite\StateMachine\StateMachine;
/**
* The FiniteStateMachine Trait.
* It provides easy ways to deal with Stateful objects and StateMachine
* Prerequisite: install Finite package (https://github.com/yohang/Finite#readme)
* Usage: in your Stateful Class, add the stateMachineConfig() protected method
* and call initStateMachine() method at initialization (__contruct() method)
*
* @author Tortue Torche <tortuetorche@spam.me>
*/
trait FiniteStateMachineTrait
{
/**
* @var \Finite\StateMachine\StateMachine
*/
protected $stateMachine;
/**
* @var array
*/
protected $finiteLoader;
/**
* @return array
*/
protected abstract function stateMachineConfig();
protected function initStateMachine(array $config = null)
{
$this->finiteLoader = $config ?: $this->stateMachineConfig();
$loader = new \Finite\Loader\ArrayLoader($this->finiteLoader);
$sm = new StateMachine($this);
$loader->load($sm);
$sm->initialize();
$this->stateMachine = $sm;
}
/**
* Sets the object state
*
* @param string $state
*/
public function setFiniteState($state)
{
$this->status = $state;
}
/**
* Get the object state
*
* @return string
*/
public function getFiniteState()
{
return $this->status;
}
/**
* @return \Finite\StateMachine\StateMachine
*/
public function getStateMachine()
{
return $this->stateMachine;
}
/**
* @return \Finite\State\State
*/
public function getCurrentState()
{
return $this->getStateMachine()->getCurrentState();
}
/**
* @return string
*/
public function getState()
{
return $this->getCurrentState()->getName();
}
/**
* @return string
*/
public function getHumanState()
{
return $this->humanize($this->getState());
}
/**
* @param string $transitionName
*
* @return string|null
*/
public function getHumanStateTransition($transitionName)
{
$transitionIndex = array_search($transitionName, $this->getTransitions());
if ($transitionIndex !== null && $transitionIndex !== false) {
return $this->humanize(array_get($this->getTransitions(), $transitionIndex));
}
}
/**
* Returns if this state is the initial state
*
* @return boolean
*/
public function isInitial()
{
return $this->getCurrentState()->isInitial();
}
/**
* Returns if this state is the final state
*
* @return mixed
*/
public function isFinal()
{
return $this->getCurrentState()->isFinal();
}
/**
* Returns if this state is a normal state (!($this->isInitial() || $this->isFinal())
*
* @return mixed
*/
public function isNormal()
{
return $this->getCurrentState()->isNormal();
}
/**
* Returns the state type
*
* @return string
*/
public function getType()
{
return $this->getCurrentState()->getType();
}
/**
* @return array<string>
*/
public function getTransitions()
{
return $this->getCurrentState()->getTransitions();
}
/**
* @return array<string>
*/
public function getProperties()
{
return $this->getCurrentState()->getProperties();
}
/**
* @param array $properties
*/
public function setProperties(array $properties)
{
$this->getCurrentState()->setProperties($properties);
}
/**
* @param string $property
*
* @return bool
*/
public function hasProperty($property)
{
return $this->getCurrentState()->has($property);
}
/**
* @param string $targetState
*
* @return bool
*/
public function isState($targetState)
{
return $this->getState() === $targetState;
}
/**
* @param string $transitionName
*
* @return bool
*/
public function can($transitionName)
{
return $this->getStateMachine()->can($transitionName);
}
/**
* @param string $transitionName
*
* @param array $properties
*
* @return mixed
*/
public function apply($transitionName, array $properties = [])
{
$returnValue = $this->getStateMachine()->apply($transitionName, $properties);
$this->save();
return $returnValue;
}
/**
* @param callable $callback
* @param array $spec
*/
public function addBefore($callback, array $spec = [])
{
$this->getStateMachine()->getDispatcher()->addListener('finite.pre_transition', function(\Finite\Event\TransitionEvent $e) use ($callback, $spec) {
call_user_func_array($callback, [$e]);
});
}
/**
* @param callable $callback
* @param array $spec
*/
public function addAfter($callback, array $spec = [])
{
$this->getStateMachine()->getDispatcher()->addListener('finite.post_transition', function(\Finite\Event\TransitionEvent $e) use ($callback, $spec) {
call_user_func_array($callback, [$e]);
});
}
/**
* @param callable $callback
* @param array $spec
*/
public function prependBefore($callback, array $spec = [])
{
$config = $this->finiteLoader;
array_set($config, 'callbacks.before', array_merge(
[array_merge($spec, ['do' => $callback])],
array_get($config, 'callbacks.before', [])
));
$this->initStateMachine($config);
}
/**
* @param callable $callback
* @param array $spec
*/
public function prependAfter($callback, array $spec = [])
{
$config = $this->finiteLoader;
array_set($config, 'callbacks.after', array_merge(
[array_merge($spec, ['do' => $callback])],
array_get($config, 'callbacks.after', [])
));
$this->initStateMachine($config);
}
/**
* Find and return the Initial state if exists
* @return string
* @throws \Finite\Exception\StateException
*/
public function findInitialState()
{
foreach ($this->getStateMachine()->getStates() as $stateName) {
if (\Finite\State\State::TYPE_INITIAL === $this->getStateMachine()->getState($stateName)->getType()) {
return $stateName;
}
}
throw new \Finite\Exception\StateException('No initial state found.');
}
/**
*
* @param string $attribute Attribute name who contains transition name
* @param string $errorMessage
*
* @return mixed Returns false if there are errors
* @throws \Exception
*/
public function applyStateTransition($attribute = null, $errorMessage = null)
{
$attribute = $attribute ?: 'state_transition';
$attributes = $this->getAttributes();
if (($stateTransition = array_get($attributes, $attribute))) {
if ($this->can($stateTransition)) {
unset($this->$attribute);
$this->apply($stateTransition);
} else {
$defaultErrorMessage = sprintf(
'The "%s" transition can not be applied to the "%s" state.',
$stateTransition,
$this->getState()
);
Log::error($defaultErrorMessage);
$errorMessage = $errorMessage ?: $defaultErrorMessage;
if (method_exists($this, 'errors')) {
$this->errors()->add($attribute, $errorMessage);
} else {
throw new \Exception($errorMessage, 1);
}
return false;
}
}
}
/**
* $this->humanize("my beautiful hat");//-> 'My beautiful hat'
*
* @param string $value
* @return string
*/
protected function humanize($value)
{
return ucfirst(snake_case(camel_case($value), ' '));
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment