├── AInvalidStateException.php ├── AState.php ├── AStateException.php ├── AStateMachine.php ├── AStateTransition.php ├── README.md └── tests └── AStateMachineTest.php /AInvalidStateException.php: -------------------------------------------------------------------------------- 1 | setName($name); 36 | $this->setMachine($owner); 37 | } 38 | 39 | /** 40 | * Invoked before the state is transitioned to 41 | * @return boolean true if the event is valid and the transition should be allowed to continue 42 | */ 43 | public function beforeEnter() 44 | { 45 | $transition = new AStateTransition($this); 46 | $transition->to = $this; 47 | $transition->from = $this->_machine->getState(); 48 | $this->onBeforeEnter($transition); 49 | 50 | return $transition->isValid; 51 | } 52 | /** 53 | * This event is raised before the state is transitioned to 54 | * @param AStateTransition $transition the state transition 55 | */ 56 | public function onBeforeEnter($transition) 57 | { 58 | $this->raiseEvent("onBeforeEnter",$transition); 59 | } 60 | 61 | /** 62 | * Invoked after the state is transitioned to 63 | * @param AState $from The state we're transitioning from 64 | */ 65 | public function afterEnter(AState $from) 66 | { 67 | $transition = new AStateTransition($this); 68 | $transition->to = $this; 69 | $transition->from = $from; 70 | $this->onAfterEnter($transition); 71 | } 72 | /** 73 | * This event is raised after the state is transitioned to 74 | * @param AStateTransition $transition the state transition 75 | */ 76 | public function onAfterEnter($transition) 77 | { 78 | $this->raiseEvent("onAfterEnter",$transition); 79 | } 80 | /** 81 | * Invoked before the state is transitioned from 82 | * @param AState $toState The state we're transitioning to 83 | * @return boolean true if the event is valid and the transition should be allowed to continue 84 | */ 85 | public function beforeExit(AState $toState) 86 | { 87 | $transition = new AStateTransition($this); 88 | $transition->to = $toState; 89 | $transition->from = $this; 90 | 91 | if ($this->_machine->checkTransitionMap && !in_array($toState->name, $this->transitsTo)) { 92 | $transition->isValid = false; 93 | } 94 | 95 | $this->onBeforeExit($transition); 96 | 97 | return $transition->isValid; 98 | } 99 | /** 100 | * This event is raised before the state is transitioned from 101 | * @param AStateTransition $transition the state transition 102 | */ 103 | public function onBeforeExit($transition) 104 | { 105 | $this->raiseEvent("onBeforeExit",$transition); 106 | } 107 | 108 | /** 109 | * Invoked after the state is transitioned from 110 | */ 111 | public function afterExit() 112 | { 113 | $transition = new AStateTransition($this); 114 | $transition->from = $this; 115 | $transition->to = $this->_machine->getState(); 116 | $this->onAfterExit($transition); 117 | } 118 | /** 119 | * This event is raised after the state is transitioned from 120 | * @param AStateTransition $transition the state transition 121 | */ 122 | public function onAfterExit($transition) 123 | { 124 | $this->raiseEvent("onAfterExit",$transition); 125 | } 126 | 127 | /** 128 | * Sets the name for this state 129 | * @param string $name 130 | */ 131 | public function setName($name) 132 | { 133 | return $this->_name = $name; 134 | } 135 | 136 | /** 137 | * Gets the name for this state 138 | * @return string 139 | */ 140 | public function getName() 141 | { 142 | return $this->_name; 143 | } 144 | 145 | /** 146 | * Sets the state machine that this state belongs to 147 | * @param AStateMachine $owner the state machine this state belongs to 148 | * @return AStateMachine the state machine 149 | */ 150 | public function setMachine($owner) 151 | { 152 | return $this->_machine = $owner; 153 | } 154 | 155 | /** 156 | * Gets the state machine the state belongs to 157 | * @return AStateMachine 158 | */ 159 | public function getMachine() 160 | { 161 | return $this->_machine; 162 | } 163 | 164 | /** 165 | * 166 | * @param mixed $states 167 | */ 168 | public function setTransitsTo($states) 169 | { 170 | $transitsTo = $states; 171 | 172 | if (!is_array($states)) { 173 | if (is_string($states)) { 174 | if (strstr($states, ',') !== FALSE) { 175 | $transitsTo = explode(',', preg_replace('/\s+/', '', $states)); 176 | }else 177 | $transitsTo = array(trim($states)); 178 | } else { 179 | throw new AStateException('Invalide transitsTo format: ' . print_r($states, true)); 180 | } 181 | } 182 | 183 | $this->_transitsTo = $transitsTo; 184 | } 185 | 186 | public function getTransitsTo() 187 | { 188 | return ($this->_transitsTo) ? $this->_transitsTo : array(); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /AStateException.php: -------------------------------------------------------------------------------- 1 | 9 | * $stateMachine = new AStateMachine; 10 | * $stateMachine->setStates(array( 11 | * new ExampleEnabledState("enabled",$stateMachine), 12 | * new ExampleDisabledState("disabled",$stateMachine), 13 | * )); 14 | * $stateMachine->defaultStateName = "enabled"; 15 | * $model = new User; 16 | * $model->attachBehavior("status", $stateMachine); 17 | * echo $model->status->is("enabled") ? "true" : "false"; // "true" 18 | * $model->transition("disabled"); 19 | * echo $model->status->getState(); // "disabled" 20 | * $model->status->enable(); // assuming enable is a method on ExampleDisabledState 21 | * echo $model->status->getState(); // "enabled" 22 | * 23 | * 24 | * 25 | * @author Charles Pick 26 | * @package packages.stateMachine 27 | */ 28 | class AStateMachine extends CBehavior implements IApplicationComponent 29 | { 30 | /** 31 | * Holds the name of the default state. 32 | * Defaults to "default" 33 | * @var string 34 | */ 35 | public $defaultStateName = "default"; 36 | 37 | /** 38 | * Whether to track state transition history or not. 39 | * The state history will be stored in a stack and will not be persisted between requests. 40 | * This is set to false by default. 41 | * @var boolean 42 | */ 43 | public $enableTransitionHistory = false; 44 | 45 | /** 46 | * The maximum size of the state history 47 | * This is useful when performing lots of transitions. 48 | * Defaults to null, meaning there is no maximum history size 49 | * @var integer|null 50 | */ 51 | public $maximumTransitionHistorySize; 52 | 53 | /** 54 | * Defines whather to use AState.transitsTo attribute to check transition validity. 55 | * If it was set to TRUE than you should specify which states can be reached from current. 56 | * For example: 57 | *
 58 |          *      $machine = new AStateMachine();
 59 |          *      $machine->setStates(array(
 60 |          *          array(
 61 |          *              'name'=>'not_saved',
 62 |          *              'transitsTo'=>'published'
 63 |          *          ),
 64 |          *          array(
 65 |          *              'name'=>'published',
 66 |          *              'transitsTo'=>'registration, canceled',
 67 |          *          ),
 68 |          *          array(
 69 |          *              'name'=>'registration',
 70 |          *              'transitsTo'=>'published, processing, canceled'
 71 |          *          ),
 72 |          *          array(
 73 |          *              'name'=>'processing',
 74 |          *              'transitsTo'=>'finished, canceled'
 75 |          *          ),
 76 |          *          array('name'=>'finished'),
 77 |          *          array('name'=>'canceled')
 78 |          *      ));
 79 |          *      $machine->checkTransitionMap = true;
 80 |          * 
81 | * 82 | * @var boolean 83 | */ 84 | public $checkTransitionMap = false; 85 | 86 | /** 87 | * Holds the transition history 88 | * @var CList 89 | */ 90 | protected $_transitionHistory; 91 | 92 | /** 93 | * The name of the current state 94 | * @var string 95 | */ 96 | protected $_stateName; 97 | /** 98 | * The supported states 99 | * @var AState[] 100 | */ 101 | protected $_states = array(); 102 | /** 103 | * Whether the state machine is initialized or not 104 | * @var boolean 105 | */ 106 | protected $_isInitialized = false; 107 | 108 | /** 109 | * The unique id for this state machine. 110 | * This is used when the machine is attached as a behavior 111 | * @var string 112 | */ 113 | protected $_uniqueID; 114 | 115 | /** 116 | * Constructor. 117 | * The default implementation calls the init() method 118 | */ 119 | public function __construct() 120 | { 121 | $this->init(); 122 | } 123 | 124 | /** 125 | * Initializes the state machine. 126 | * The default implementation merely sets the $this->_isInitialized property to true 127 | * Child classes that override this method should call the parent implementation 128 | * This method is required by IApplicationComponent 129 | */ 130 | public function init() 131 | { 132 | $this->_isInitialized = true; 133 | } 134 | /** 135 | * Attaches the state machine to a component 136 | * @param CComponent $owner the component to attach to 137 | */ 138 | public function attach($owner) 139 | { 140 | parent::attach($owner); 141 | if ($this->_uniqueID === null) { 142 | $this->_uniqueID = uniqid(); 143 | } 144 | if (($state = $this->getState()) !== null) { 145 | $owner->attachBehavior($this->_uniqueID."_".$state->name,$state); 146 | } 147 | } 148 | /** 149 | * Detaches the state machine from a component 150 | * @param CComponent $owner the component to detach from 151 | */ 152 | public function detach($owner) 153 | { 154 | parent::detach($owner); 155 | if ($this->_uniqueID !== null) { 156 | $owner->detachBehavior($this->_uniqueID."_".$this->getStateName()); 157 | } 158 | } 159 | 160 | /** 161 | * Determines whether the state machine has been initialized or not 162 | * @return boolean 163 | */ 164 | public function getIsInitialized() 165 | { 166 | return $this->_isInitialized; 167 | } 168 | /** 169 | * Sets the possible states for this machine 170 | * @param AState[] $states an array of possible states 171 | */ 172 | public function setStates($states) 173 | { 174 | $this->_states = array(); 175 | foreach ($states as $state) { 176 | $this->addState($state); 177 | } 178 | 179 | return $this->_states; 180 | } 181 | /** 182 | * Gets an array of possible states for this machine 183 | * @return AState[] the possible states for the machine 184 | */ 185 | public function getStates() 186 | { 187 | return $this->_states; 188 | } 189 | 190 | /** 191 | * Adds a state to the machine 192 | * @param AState|array $state The state to add, either an instance of AState or a configuration array for an AState 193 | * @return AState the added state 194 | */ 195 | public function addState($state) 196 | { 197 | if (is_array($state)) { 198 | if (!isset($state['class'])) { 199 | $state['class'] = "AState"; 200 | } 201 | $state = Yii::createComponent($state,$state['name'],$this); 202 | } 203 | 204 | return $this->_states[$state->getName()] = $state; 205 | } 206 | /** 207 | * Removes a state with the given name 208 | * @param string $stateName the name of the state to remove 209 | * @return AState|null the removed state, or null if there was no state by that name 210 | */ 211 | public function removeState($stateName) 212 | { 213 | if (!$this->hasState($stateName)) { 214 | return null; 215 | } 216 | $state = $this->_states[$stateName]; 217 | unset($this->_states[$stateName]); 218 | $this->_stateName = $this->defaultStateName; 219 | 220 | return $state; 221 | } 222 | /** 223 | * Sets the name of the current state but doesn't trigger the transition events 224 | * @param string $state the name of the state to change to 225 | */ 226 | public function setStateName($state) 227 | { 228 | $this->_stateName = $state; 229 | } 230 | 231 | /** 232 | * Gets the name of the current state 233 | * @return string 234 | */ 235 | public function getStateName() 236 | { 237 | if ($this->_stateName === null) { 238 | return $this->defaultStateName; 239 | } 240 | 241 | return $this->_stateName; 242 | } 243 | /** 244 | * Gets the default state 245 | * @return AState|null the default state, or null if no state is set 246 | */ 247 | public function getDefaultState() 248 | { 249 | if (is_null($this->defaultStateName) || !$this->hasState($this->defaultStateName)) { 250 | return null; 251 | } 252 | 253 | return $this->_states[$this->defaultStateName]; 254 | } 255 | 256 | /** 257 | * Gets the current state 258 | * @return AState|null the current state, or null if there is no state set 259 | */ 260 | public function getState() 261 | { 262 | $stateName = $this->getStateName(); 263 | if (!isset($this->_states[$stateName])) { 264 | return null; 265 | } 266 | 267 | return $this->_states[$stateName]; 268 | } 269 | /** 270 | * Transitions to a 271 | * @param string $state the name of the state 272 | * @return boolean true if the state exists, otherwise false 273 | */ 274 | public function hasState($state) 275 | { 276 | return isset($this->_states[$state]); 277 | } 278 | 279 | /** 280 | * Transitions the state machine to the specified state 281 | * @throws AInvalidStateException if the state doesn't exist 282 | * @param string $to The name of the state we're transitioning to 283 | * @param mixed $params additional parameters for the before/after Transition events 284 | * @return boolean true if the transition succeeded or false if it failed 285 | */ 286 | public function transition($to, $params=null) 287 | { 288 | if (!$this->hasState($to)) { 289 | throw new AInvalidStateException("No such state: ".$to); 290 | } 291 | $toState = $this->_states[$to]; 292 | $fromState = $this->getState(); 293 | 294 | if (!$this->canTransit($to, $params)) { 295 | return false; 296 | } 297 | 298 | if (($owner = $this->getOwner()) !== null) { 299 | 300 | // we need to attach the current state to the owner 301 | $owner->detachBehavior($this->_uniqueID."_".$this->getStateName()); 302 | $this->setStateName($to); 303 | $owner->attachBehavior($this->_uniqueID."_".$to,$toState); 304 | } else { 305 | $this->setStateName($to); 306 | } 307 | 308 | if ($this->enableTransitionHistory) { 309 | if ($this->maximumTransitionHistorySize !== null && ($c = $this->getTransitionHistory()->count() - $this->maximumTransitionHistorySize) >= 0) { 310 | for ($i = 0; $i <= $c; $i++) { 311 | $this->getTransitionHistory()->removeAt(0); 312 | } 313 | 314 | } 315 | $this->getTransitionHistory()->add($to); 316 | } 317 | $this->afterTransition($fromState, $params); 318 | 319 | return true; 320 | } 321 | 322 | /** 323 | * Checks can the state machine transite to the specified state 324 | * @throws AInvalidStateException if the state doesn't exist 325 | * @param string $to The name of the state we're transitioning to 326 | * @param mixed $params additional parameters for the before/after Transition events 327 | * @return boolean true if the transition succeeded or false if it failed 328 | */ 329 | public function canTransit($to, $params=null) 330 | { 331 | if (!$this->hasState($to)) { 332 | throw new AInvalidStateException("No such state: ".$to); 333 | } 334 | $toState = $this->_states[$to]; 335 | 336 | if (!$this->beforeTransition($toState, $params)) { 337 | return false; 338 | } 339 | 340 | return true; 341 | } 342 | 343 | /** 344 | * Invoked before a state transition 345 | * @param AState $toState The state we're transitioning to 346 | * @param mixed $params additional parameters for the event 347 | * @return boolean true if the event is valid and the transition should be allowed to continue 348 | */ 349 | public function beforeTransition(AState $toState, $params=null) 350 | { 351 | if (!$this->getState()->beforeExit($toState) || !$toState->beforeEnter()) { 352 | return false; 353 | } 354 | $transition = new AStateTransition($this, $params); 355 | $transition->to = $toState; 356 | $transition->from = $this->getState(); 357 | $this->onBeforeTransition($transition); 358 | 359 | return $transition->isValid; 360 | } 361 | /** 362 | * This event is raised before a state transition 363 | * @param AStateTransition $transition the state transition 364 | */ 365 | public function onBeforeTransition($transition) 366 | { 367 | $this->raiseEvent("onBeforeTransition",$transition); 368 | } 369 | 370 | /** 371 | * Invoked after a state transition 372 | * @param AState $from The state we're transitioning from 373 | * @param mixed $params additional parameters for the event 374 | */ 375 | public function afterTransition(AState $fromState, $params=null) 376 | { 377 | $fromState->afterExit(); 378 | $this->getState()->afterEnter($fromState); 379 | 380 | $transition = new AStateTransition($this, $params); 381 | $transition->to = $this->getState(); 382 | $transition->from = $fromState; 383 | $this->onAfterTransition($transition); 384 | } 385 | 386 | /** 387 | * This event is raised after a state transition 388 | * @param AStateTransition $transition the state transition 389 | */ 390 | public function onAfterTransition($transition) 391 | { 392 | $this->raiseEvent("onAfterTransition",$transition); 393 | } 394 | /** 395 | * Returns a property value based on its name. 396 | * @param string $name the property name or event name 397 | * @return mixed the property value, event handlers attached to the event, or the named behavior (since version 1.0.2) 398 | * @throws CException if the property or event is not defined 399 | * @see CComponent::__get() 400 | */ 401 | 402 | public function __get($name) 403 | { 404 | $state = $this->getState(); 405 | if ($state !== null && (property_exists($state,$name) || $state->canGetProperty($name))) { 406 | return $state->{$name}; 407 | } 408 | 409 | return parent::__get($name); 410 | } 411 | 412 | /** 413 | * Sets a property value based on its name. 414 | * @param string $name the property name or event name 415 | * @param mixed $value the property value 416 | * @return mixed the property value, event handlers attached to the event, or the named behavior (since version 1.0.2) 417 | * @throws CException if the property or event is not defined 418 | * @see CComponent::__get() 419 | */ 420 | public function __set($name,$value) 421 | { 422 | $state = $this->getState(); 423 | if ($state !== null && (property_exists($state,$name) || $state->canSetProperty($name))) { 424 | return $state->{$name} = $value; 425 | } 426 | 427 | return parent::__set($name,$value); 428 | } 429 | 430 | /** 431 | * Checks if a property value is null. 432 | * Do not call this method. This is a PHP magic method that we override 433 | * to allow using isset() to detect if a component property is set or not. 434 | * @param string $name the property name or the event name 435 | * @return boolean 436 | * @since 1.0.1 437 | */ 438 | public function __isset($name) 439 | { 440 | $state = $this->getState(); 441 | if ($state !== null && (property_exists($state,$name) || $state->canGetProperty($name))) { 442 | return true; 443 | } 444 | 445 | return parent::__isset($name); 446 | } 447 | 448 | /** 449 | * Sets a component property to be null. 450 | * @param string $name the property name or event name 451 | * @return mixed the property value, event handlers attached to the event, or the named behavior (since version 1.0.2) 452 | * @throws CException if the property or event is not defined 453 | * @see CComponent::__get() 454 | */ 455 | public function __unset($name) 456 | { 457 | $state = $this->getState(); 458 | if ($state !== null && (property_exists($state,$name) || $state->canSetProperty($name))) { 459 | return $state->{$name} = null; 460 | } 461 | 462 | return parent::__unset($name); 463 | } 464 | 465 | /** 466 | * Calls the named method which is not a class method. 467 | * Do not call this method. This is a PHP magic method that we override 468 | * to implement the states feature. 469 | * @param string $name the method name 470 | * @param array $parameters method parameters 471 | * @return mixed the method return value 472 | */ 473 | public function __call($name,$parameters) 474 | { 475 | $state = $this->getState(); 476 | if (is_object($state) && method_exists($state,$name)) { 477 | return call_user_func_array(array($state,$name),$parameters); 478 | } 479 | 480 | return parent::__call($name,$parameters); 481 | } 482 | 483 | /** 484 | * Determines whether the current state matches the given name 485 | * @param string $stateName the name of the state to check against 486 | * @return boolean true if the state names match 487 | */ 488 | public function is($stateName) 489 | { 490 | return $this->getStateName() == $stateName; 491 | } 492 | 493 | /** 494 | * Gets the transition history 495 | * @return CList the transition history 496 | */ 497 | public function getTransitionHistory() 498 | { 499 | if ($this->_transitionHistory === null) { 500 | $this->_transitionHistory = new CList(array($this->getStateName())); 501 | } 502 | 503 | return $this->_transitionHistory; 504 | } 505 | 506 | /** 507 | * Returns available states that can be reached from current. 508 | * It is usefull when you want allow user to chose next state somewhere in 509 | * an UI. 510 | * 511 | * @return array 512 | */ 513 | public function getAvailableStates() 514 | { 515 | $result = array(); 516 | 517 | foreach ($this->states as $state) 518 | if($this->canTransit($state->name)) 519 | $result[] = $state->name; 520 | 521 | return $result; 522 | } 523 | } 524 | -------------------------------------------------------------------------------- /AStateTransition.php: -------------------------------------------------------------------------------- 1 | Yii State Machine 2 | An object oriented state machine for Yii. It can be used independently or as a behavior to augment your existing components / models. 3 | 4 | 5 |

Why is this useful?

6 | 7 | As developers, we're often in the situation where we need to keep track of an object's state. 8 | For example, your user system might require users to activate their accounts by clicking a link on 9 | an email, and you might also offer users the ability to deactivate their accounts rather than merely deleting them. 10 | To do this, you would typically have an enum field called something like "status" with the fields: 11 | 16 | 17 | and that's great. But what if you need to *do something* when a user activates their account? 18 | You need to store the user's current status after find, then check if it has changed, then, finally do your magic, 19 | that could get complicated if you have other status fields. 20 | 21 | And also, what if you have business logic that only applies when the user is in a certain state? 22 | After all, an active user should not be able to activate their account a second time, just as a deactivated user should 23 | not be able to deactivate their account a second time. Again, when a user can have a lot of different states, 24 | this can become code spaghetti pretty quickly. 25 | 26 | This is where the state machine comes in. 27 | The state machine keeps track of the user's state, and manages the transitions from one state to another. 28 | A state can encapsulate logic that only applies when the state machine is in that state, and also provides 29 | events that are raised when the state is transitioned from and to. 30 | When the state machine is in a certain state, the methods and properties declared on that state become available to the state machine. 31 | This means that in the user scenario, we could implement: 32 | 37 | We can also do stuff, e.g. sending a welcome email when the user transitions to the *active* state by handling the 38 | afterEnter() event on the *active* state. If we want to send a different email when the account is reactivated, we can 39 | handle that by inspecting the $from method parameter which refers to the previous state, if the previous state is *inactive* 40 | send a welcome back email instead. 41 | 42 | This keeps our model code clean, separates the business logic into easily testable chunks 43 | and ensures that a user cannot accidentally be activated or deactivated more than once because the relevant methods 44 | are simply not available when the machine isn't in the right state. 45 | 46 | So when a user clicks their activation email, we can *transition* to the *active* state and send them an welcome email, 47 | safe in the knowledge that they won't get multiple welcome emails if they happen to click the activation link more than once. 48 | 49 |

Example Code

50 | First, declare our states 51 | 52 |
 53 | /**
 54 |  * A state that applies when the user's account is pending activation
 55 |  */
 56 | class UserPendingState extends AState {
 57 |     /**
 58 |      * Activates the user's account
 59 |      */
 60 |     public function activate() {
 61 |         $machine = $this->getMachine();
 62 |         $user = $machine->getOwner();
 63 |         $user->status = "active";
 64 |         $user->save();
 65 |         $machine->transition("active");
 66 |     }
 67 | }
 68 | 
 69 | 
 70 | /**
 71 |  * A state that applies when the user's account is active
 72 |  */
 73 | class UserActiveState extends AState {
 74 |     /**
 75 |      * Deactivates the user's account
 76 |      */
 77 |     public function deactivate() {
 78 |         $machine = $this->getMachine();
 79 |         $user = $machine->getOwner();
 80 |         $user->status = "inactive";
 81 |         $user->save();
 82 |         $machine->transition("inactive");
 83 |     }
 84 |     /**
 85 |      * Raised when the state is transitioned to
 86 |      * @param AState $from the previous state
 87 |      */
 88 |     protected function afterEnter(AState $from) {
 89 |         parent::afterEnter($from);
 90 |         if ($from->name == "pending") {
 91 |             // send welcome email
 92 |         }
 93 |         else {
 94 |             // send welcome back email
 95 |         }
 96 |     }
 97 | }
 98 | 
 99 | /**
100 |  * A state that applies when the user's account is deactivated
101 |  */
102 | class UserInactiveState extends AState {
103 |     /**
104 |      * Reactivates the user's account
105 |      */
106 |     public function reactivate() {
107 |         $machine = $this->getMachine();
108 |         $user = $machine->getOwner();
109 |         $user->status = "active";
110 |         $user->save();
111 |         $machine->transition("active");
112 |     }
113 |     /**
114 |      * Invoked before the state is transitioned to
115 |      */
116 |     protected function beforeEnter() {
117 |         if ($this->getMachine()->getState()->name == "pending") {
118 |             // invalid state transition, user cannot go pending -> deactivated
119 |             return false;
120 |         }
121 |         return parent::beforeEnter();
122 |     }
123 |     /**
124 |      * Raised when the state is transitioned to
125 |      * @param AState $from the previous state
126 |      */
127 |     protected function afterEnter(AState $from) {
128 |         parent::afterEnter($from);
129 |         Yii::log($this->getMachine()->getOwner()->name." deactivated their account :(");
130 |     }
131 | }
132 | 
133 | 
134 | 135 |

Adding the state machine to our user model

136 | 137 |
138 | /**
139 |  * Your user model
140 |  * @property string $status either pending, active or inactive
141 |  */
142 | class User extends CActiveRecord {
143 |     /**
144 |      * Declares the behaviors for the model
145 |      * @return array the behavior configuration
146 |      */
147 |     public function behaviors() {
148 |         return array(
149 |             "activationStatus" => array(
150 |                 "class" => "AStateMachine",
151 |                 "states" => array(
152 |                     array(
153 |                         "class" => "UserPendingState",
154 |                         "name" => "pending",
155 |                     ),
156 |                     array(
157 |                         "class" => "UserActiveState",
158 |                         "name" => "active",
159 |                     ),
160 |                     array(
161 |                         "class" => "UserInactiveState",
162 |                         "name" => "inactive",
163 |                     ),
164 |                 ),
165 |                 "defaultStateName" => "pending",
166 |                 "stateName" => $this->status,
167 |             )
168 |         );
169 |     }
170 |     ...
171 | }
172 | 
173 | 174 |

Using it

175 | 176 |
177 | $user = new User;
178 | $user->name = "Test User";
179 | $user->email = "test@example.com";
180 | $user->activate(); // activates the user, transitions to the "active" state
181 | $user->activate(); // throws exception, no such method
182 | 
183 | $user->deactivate(); // deactivates the user
184 | $user->reactivate(); // reactivates the user
185 | 
186 | $user->activationStatus->deactivate(); // call the state machine directly
187 | 
188 | 189 |

Specifying states map

190 | 191 | Often we need to check what state can become active after current. We can override 192 | beforeExit or beforeEnter methods of AState as described. 193 | 194 |
195 |     /**
196 |      * Invoked before the state is transitioned to
197 |      */
198 |     protected function beforeEnter() {
199 |         if ($this->getMachine()->getState()->name == "pending") {
200 |             // invalid state transition, user cannot go pending -> deactivated
201 |             return false;
202 |         }
203 |         return parent::beforeEnter();
204 |     }
205 | 
206 | 207 | This approach is very flexible but it may make you crazy if you should describe a big graph of states. 208 | In this case you can free your time by setting *AStateMachine.checkTransitionMap* to *TRUE* 209 | and specifying *AState.transitsTo* attribute for all states. This attribute describes which states can 210 | be reached from current. See example below. 211 | 212 |
213 |     /**
214 |      * Represents deputy election process. 
215 |      */
216 |     class Election extends CActiveRecord {
217 | 
218 |         public static $statuses = array(
219 |             Election::STATUS_PUBLISHED    => 'Published',
220 |             Election::STATUS_REGISTRATION => 'Registration',
221 |             Election::STATUS_ELECTION => 'Election',
222 |             Election::STATUS_FINISHED => 'Finished',
223 |             Election::STATUS_CANCELED => 'Canceled',
224 |         );
225 | 
226 |         public function getStatusName() {
227 |             return self::$statuses[$this->status];
228 |         }
229 | 
230 |         /**
231 |          *  ... other methods ...
232 |          */
233 |         public function behaviors() {
234 |             return array(
235 |                 "state" => array(
236 |                     "class" => "AStateMachine",
237 |                     "states" => array(
238 |                         array(
239 |                             'name'=>'not_saved',
240 |                             'transitsTo'=>'Published'
241 |                         ),
242 |                         array(
243 |                             'name'=>'Published',
244 |                             'transitsTo'=>'Registration, Canceled'
245 |                         ),
246 |                         array(
247 |                             'name'=>'Registration',
248 |                             'transitsTo'=>'Published, Election, Canceled'
249 |                         ),
250 |                         array(
251 |                             'name'=>'Election',
252 |                             'transitsTo'=>'Finished, Canceled'
253 |                         ),
254 |                         array(
255 |                             'name'=>'Finished',
256 |                             'class'=>'ElectionFinishedState'
257 |                         ),
258 |                         array('name'=>'Canceled')
259 |                     ),
260 |                     "defaultStateName" => "not_saved",
261 |                     "checkTransitionMap" => true,
262 |                     "stateName" => $this->statusName,
263 |                 )
264 |             );
265 |         }
266 |         /**
267 |          *  ... other methods ...
268 |          */
269 |     }
270 | 
271 |     class ElectionFinishedState extends AState {
272 |     
273 |         public function finish() {
274 |             // ...
275 |         }
276 | 
277 |         // ...
278 | 
279 |         public function afterEnter(AState $from) {
280 |             parent::afterEnter($from);
281 |             $this->finish();
282 |         }
283 |     }
284 | 
285 | 286 | So lets see which states can be reached. 287 | 288 |
289 |     $election = new Election;
290 |     echo $election->stateName;      // "not_saved"
291 |     echo $election->canTransit('Published');    // true
292 |     
293 |     echo $election->canTransit('Registration'); // false
294 |     echo $election->canTransit('Finished');     // false
295 |     // ... Election and Canceled will return false too
296 | 
297 |     $election->transition('Published');
298 |     echo $election->canTransit('Published');    // false because we already here
299 |     
300 |     echo $election->canTransit('Registration'); // true
301 |     echo $election->canTransit('Election');     // false
302 |     echo $election->canTransit('Finished');     // false
303 |     echo $election->canTransit('Canceled');     // true
304 | 
305 |     $election->transition('Registration');
306 |     $election->availableStates;                 // return array('Published', 'Election', 'Canceled')
307 | 
308 |     $election->transition('Election');
309 |     $election->availableStates;                 // return array('Finished', 'Canceled')
310 | 
311 |     $election->transition('Finished');
312 |     $election->availableStates;                 // return array()
313 | 
314 | 315 | Here we saw *availableStates* attribute ( or *getAvailableStates()* ). This is useful 316 | method when we want provide ability to switch state by user in an UI. -------------------------------------------------------------------------------- /tests/AStateMachineTest.php: -------------------------------------------------------------------------------- 1 | setStates(array( 17 | new ExampleEnabledState("enabled",$machine), 18 | new ExampleDisabledState("disabled",$machine), 19 | )); 20 | $machine->defaultStateName = "enabled"; 21 | $this->assertTrue($machine->is("enabled")); 22 | $this->assertFalse($machine->is("disabled")); 23 | $this->assertTrue($machine->isEnabled); 24 | $this->assertTrue(isset($machine->testProperty)); 25 | $machine->disable(); 26 | $this->assertTrue($machine->is("disabled")); 27 | $this->assertFalse($machine->is("enabled")); 28 | $this->assertFalse($machine->isEnabled); 29 | $this->assertFalse(isset($machine->testProperty)); 30 | } 31 | 32 | /** 33 | * Tests adding and removing states from a state machine 34 | */ 35 | public function testAddRemoveStates() 36 | { 37 | $machine = new AStateMachine(); 38 | 39 | $machine->addState(new ExampleEnabledState("enabled",$machine)); 40 | $this->assertFalse(isset($machine->testProperty)); 41 | $machine->defaultStateName = "enabled"; 42 | $this->assertTrue(isset($machine->testProperty)); 43 | $machine->removeState("enabled"); 44 | $this->assertFalse(isset($machine->testProperty)); 45 | $this->assertNull($machine->getState()); 46 | } 47 | 48 | /** 49 | * Tests the transition events 50 | */ 51 | public function testTransitions() 52 | { 53 | $machine = new AStateMachine(); 54 | $machine->setStates(array( 55 | new ExampleEnabledState("enabled",$machine), 56 | new ExampleDisabledState("disabled",$machine), 57 | new ExampleIntermediateState("intermediate", $machine), 58 | )); 59 | $machine->defaultStateName = "enabled"; 60 | $machine->enableTransitionHistory = true; 61 | $machine->maximumTransitionHistorySize = 2; 62 | $this->assertFalse($machine->transition("intermediate")); // intermediate state blocks transition from enabled -> intermediate 63 | $this->assertTrue($machine->transition("disabled")); 64 | $this->assertEquals(2, $machine->getTransitionHistory()->count()); 65 | $this->assertTrue($machine->transition("intermediate")); // should work 66 | $this->assertEquals(2, $machine->getTransitionHistory()->count()); 67 | } 68 | 69 | public function testCanTransit() 70 | { 71 | $machine = new AStateMachine(); 72 | $machine->setStates(array( 73 | new ExampleEnabledState("enabled",$machine), 74 | new ExampleDisabledState("disabled",$machine), 75 | new ExampleIntermediateState("intermediate", $machine), 76 | )); 77 | $machine->defaultStateName = "enabled"; 78 | 79 | $this->assertFalse($machine->canTransit("intermediate")); // intermediate state blocks transition from enabled -> intermediate 80 | 81 | $this->assertTrue($machine->canTransit("disabled")); 82 | $this->assertTrue($machine->transition("disabled")); 83 | 84 | $this->assertTrue($machine->canTransit("intermediate")); 85 | $this->assertTrue($machine->transition("intermediate")); // should work 86 | } 87 | 88 | public function testCanTransitWithTransitionsMapSpecified() 89 | { 90 | $machine = new AStateMachine(); 91 | $machine->setStates(array( 92 | array( 93 | 'name'=>'published', 94 | 'transitsTo'=>'registration, canceled' 95 | ), 96 | array( 97 | 'name'=>'registration', 98 | 'transitsTo'=>'published, processing, canceled' 99 | ), 100 | array( 101 | 'name'=>'processing', 102 | 'transitsTo'=>'finished, canceled' 103 | ), 104 | array('name'=>'finished'), 105 | array('name'=>'canceled') 106 | )); 107 | $machine->defaultStateName = "published"; 108 | $machine->checkTransitionMap = true; 109 | 110 | $this->assertFalse($machine->canTransit("processing")); 111 | $this->assertFalse($machine->canTransit("finished")); 112 | $this->assertFalse($machine->canTransit("published")); 113 | 114 | $this->assertTrue($machine->canTransit("registration")); 115 | $this->assertTrue($machine->canTransit("canceled")); 116 | 117 | $this->assertTrue($machine->transition("registration")); 118 | 119 | $this->assertFalse($machine->canTransit("finished")); 120 | $this->assertFalse($machine->canTransit("registration")); 121 | 122 | $this->assertTrue($machine->canTransit("published")); 123 | $this->assertTrue($machine->canTransit("processing")); 124 | $this->assertTrue($machine->canTransit("canceled")); 125 | 126 | $this->assertTrue($machine->transition("processing")); 127 | 128 | $this->assertFalse($machine->canTransit("processing")); 129 | $this->assertFalse($machine->canTransit("registration")); 130 | $this->assertFalse($machine->canTransit("published")); 131 | 132 | $this->assertTrue($machine->canTransit("finished")); 133 | $this->assertTrue($machine->canTransit("canceled")); 134 | 135 | $this->assertTrue($machine->transition("finished")); 136 | 137 | $this->assertFalse($machine->canTransit("finished")); 138 | $this->assertFalse($machine->canTransit("processing")); 139 | $this->assertFalse($machine->canTransit("registration")); 140 | $this->assertFalse($machine->canTransit("published")); 141 | $this->assertFalse($machine->canTransit("canceled")); 142 | } 143 | 144 | public function testGetAvailableStates() 145 | { 146 | $machine = new AStateMachine(); 147 | $machine->setStates(array( 148 | array( 149 | 'name'=>'not_saved', 150 | 'transitsTo'=>'published' 151 | ), 152 | array( 153 | 'name'=>'published', 154 | 'transitsTo'=>'registration, canceled', 155 | ), 156 | array( 157 | 'name'=>'registration', 158 | 'transitsTo'=>'published, processing, canceled' 159 | ), 160 | array( 161 | 'name'=>'processing', 162 | 'transitsTo'=>'finished, canceled' 163 | ), 164 | array('name'=>'finished'), 165 | array('name'=>'canceled') 166 | )); 167 | $machine->checkTransitionMap = true; 168 | $machine->defaultStateName = 'not_saved'; 169 | 170 | $this->checkStates(array('published'), $machine->availableStates); 171 | 172 | $machine->transition('published'); 173 | $this->checkStates(array('registration', 'canceled'), $machine->availableStates); 174 | 175 | $machine->transition('registration'); 176 | $this->checkStates(array('published', 'processing', 'canceled'), $machine->availableStates); 177 | 178 | $machine->transition('processing'); 179 | $this->checkStates(array('finished', 'canceled'), $machine->availableStates); 180 | 181 | $machine->transition('finished'); 182 | $this->checkStates(array(), $machine->availableStates); 183 | } 184 | 185 | protected function checkStates($shouldBeAvailable, $states) 186 | { 187 | $this->assertCount(count($shouldBeAvailable), $states); 188 | foreach ($shouldBeAvailable as $state) 189 | $this->assertContains($state, $states); 190 | } 191 | 192 | /** 193 | * Tests for the behavior functionality 194 | */ 195 | public function testBehavior() 196 | { 197 | $machine = new AStateMachine(); 198 | $machine->setStates(array( 199 | new ExampleEnabledState("enabled",$machine), 200 | new ExampleDisabledState("disabled",$machine), 201 | )); 202 | $machine->defaultStateName = "enabled"; 203 | 204 | $component = new CComponent(); 205 | $component->attachBehavior("status",$machine); 206 | $this->assertTrue($component->is("enabled")); 207 | $this->assertTrue($component->demoMethod()); 208 | $this->assertTrue($component->transition("disabled")); 209 | $this->assertTrue($component->status->is("disabled")); 210 | } 211 | 212 | public function testEvents() 213 | { 214 | $machine = $this->getMock("AStateMachine", array("onBeforeTransition", "onAfterTransition")); 215 | 216 | $enabled = $this->getMock("AState", array("onBeforeEnter", "onBeforeExit", "onAfterEnter", "onAfterExit"), array("enabled", $machine)); 217 | $disabled = $this->getMock("AState", array("onBeforeEnter", "onBeforeExit", "onAfterEnter", "onAfterExit"), array("disabled", $machine)); 218 | 219 | $machine->setStates(array($enabled,$disabled)); 220 | $machine->defaultStateName = "enabled"; 221 | 222 | $params = array("param"=>1, "param"=>2); 223 | $transition = new AStateTransition($machine, $params); 224 | $transition->to = $disabled; 225 | $transition->from = $enabled; 226 | 227 | $machine->expects($this->once()) 228 | ->method("onBeforeTransition") 229 | ->with($transition); 230 | 231 | $machine->expects($this->once()) 232 | ->method("onAfterTransition") 233 | ->with($transition); 234 | 235 | $enabledTransition = new AStateTransition($enabled); 236 | $enabledTransition->to = $disabled; 237 | $enabledTransition->from = $enabled; 238 | 239 | $enabled->expects($this->never()) 240 | ->method("onBeforeEnter"); 241 | $enabled->expects($this->never()) 242 | ->method("onAfterEnter"); 243 | $enabled->expects($this->once()) 244 | ->method("onBeforeExit") 245 | ->with($enabledTransition); 246 | $enabled->expects($this->once()) 247 | ->method("onAfterExit") 248 | ->with($enabledTransition); 249 | 250 | $disabledTransition = new AStateTransition($disabled); 251 | $disabledTransition->to = $disabled; 252 | $disabledTransition->from = $enabled; 253 | 254 | $disabled->expects($this->never()) 255 | ->method("onBeforeExit"); 256 | $disabled->expects($this->never()) 257 | ->method("onAfterExit"); 258 | $disabled->expects($this->once()) 259 | ->method("onBeforeEnter") 260 | ->with($disabledTransition); 261 | $disabled->expects($this->once()) 262 | ->method("onAfterEnter") 263 | ->with($disabledTransition); 264 | 265 | $this->assertTrue($machine->transition("disabled", $params)); 266 | } 267 | } 268 | 269 | /** 270 | * An example of an enabled state 271 | * @author Charles Pick 272 | * @package packages.stateMachine.tests 273 | */ 274 | class ExampleEnabledState extends AState 275 | { 276 | /** 277 | * An example of a state property 278 | * @var boolean 279 | */ 280 | public $isEnabled = true; 281 | 282 | /** 283 | * An example of a state property 284 | * @var boolean 285 | */ 286 | public $testProperty = true; 287 | 288 | /** 289 | * Sets the state to disabled 290 | */ 291 | public function disable() 292 | { 293 | $this->_machine->transition("disabled"); 294 | } 295 | 296 | public function demoMethod() 297 | { 298 | return true; 299 | } 300 | } 301 | /** 302 | * An example of a disabled state 303 | * @author Charles Pick 304 | * @package packages.stateMachine.tests 305 | */ 306 | class ExampleDisabledState extends AState 307 | { 308 | /** 309 | * An example of a state property 310 | * @var boolean 311 | */ 312 | public $isEnabled = false; 313 | /** 314 | * Sets the state to enabled 315 | */ 316 | public function enable() 317 | { 318 | $this->_machine->transition("enabled"); 319 | } 320 | } 321 | 322 | /** 323 | * An example of an intermediate state 324 | * @author Charles Pick 325 | * @package packages.stateMachine.tests 326 | */ 327 | class ExampleIntermediateState extends AState 328 | { 329 | /** 330 | * An example of a state property 331 | * @var boolean 332 | */ 333 | public $isEnabled = null; 334 | 335 | /** 336 | * Blocks the transition from enabled to intermediate 337 | * @param AState $fromState the state we're transitioning from 338 | * @return boolean whether the transition should continue 339 | */ 340 | public function beforeEnter() 341 | { 342 | $fromState = $this->_machine->getState(); 343 | if ($fromState->getName() == "enabled") { 344 | return false; 345 | } 346 | 347 | return parent::beforeEnter(); 348 | } 349 | } 350 | --------------------------------------------------------------------------------