├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .styleci.yml ├── README.md ├── composer.json ├── migrations └── 2021_00_00_000000_create_states_table.php ├── phpunit.xml ├── src ├── Contracts │ └── Stateful.php ├── Exceptions │ ├── FinalStateException.php │ └── TransitionException.php ├── HasStates.php ├── Models │ └── State.php ├── State.php ├── States.php ├── StatesServiceProvider.php └── Transition.php └── tests ├── ModelIntegrationTest.php ├── StateTest.php └── TestCase.php /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | tests: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | fail-fast: true 15 | matrix: 16 | php: [7.4, 8.0] 17 | laravel: [8.*] 18 | stability: [prefer-stable] 19 | include: 20 | - laravel: 8.* 21 | testbench: 6.* 22 | 23 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} 24 | 25 | steps: 26 | - name: checkout 27 | uses: actions/checkout@v2 28 | 29 | - name: Cache dependencies 30 | uses: actions/cache@v1 31 | with: 32 | path: ~/.composer/cache/files 33 | key: dependencies-laravel-${{ matrix.laravel }}-php-${{ 34 | matrix.php }}-composer-${{ hashFiles('composer.json') }} 35 | 36 | - name: Setup PHP 37 | uses: shivammathur/setup-php@v2 38 | with: 39 | php-version: ${{ matrix.php }} 40 | tools: composer:v2 41 | coverage: none 42 | 43 | - name: Install dependencies 44 | run: | 45 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update 46 | composer update --${{ matrix.stability }} --prefer-dist --no-suggest 47 | 48 | - name: Execute tests 49 | run: vendor/bin/phpunit 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /.vscode/ 3 | .phpunit.result.cache 4 | composer.lock 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: laravel 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel States 2 | 3 | A package to make use of the **finite state pattern** in eloquent Models. 4 | 5 | The package stores all states in a database table, so all states changes and the corresponding times can be traced. Since states are mapped via a relation, no additional migrations need to be created when a new state is needed for a model. 6 | 7 | ## A Recommendation 8 | 9 | Use states wherever possible! A state can be used instead of booleans like `active` or timestamps like `declined_at` or `deleted_at`: 10 | 11 | ```php 12 | $product->state->is('active'); 13 | ``` 14 | 15 | This way you also know when the change to active has taken place. Also your app becomes more scalable, you can simply add an additional state if needed. 16 | 17 | ## Table Of Contents 18 | 19 | - [Setup](#setup) 20 | - [Basics](#basics) 21 | - [Usage](#usage) 22 | - [Receive The Current State](#receive-state) 23 | - [Execute Transitions](#execute-transitions) 24 | - [Eager Loading](#eager-loading) 25 | - [Query Methods](#query) 26 | - [Observer Events](#events) 27 | 28 | 29 | 30 | 31 | ## Setup 32 | 33 | 1. Install the package via composer: 34 | 35 | ```shell 36 | composer require aw-studio/laravel-states 37 | ``` 38 | 39 | 2. Publish the required assets: 40 | 41 | ```shell 42 | php artisan vendor:publish --tag="states:migrations" 43 | ``` 44 | 45 | 3. Run The Migrations 46 | 47 | ```php 48 | php artisan migrate 49 | ``` 50 | 51 | ## Basics 52 | 53 | 1. Create A State: 54 | 55 | ```php 56 | class BookingState extends State 57 | { 58 | const PENDING = 'pending'; 59 | const FAILED = 'failed'; 60 | const SUCCESSFULL = 'successfull'; 61 | 62 | const INITIAL_STATE = self::PENDING; 63 | const FINAL_STATES = [self::FAILED, self::SUCCESSFULL]; 64 | } 65 | ``` 66 | 67 | 2. Create the transitions class: 68 | 69 | ```php 70 | class BookingStateTransitions extends State 71 | { 72 | const PAYMENT_PAID = 'payment_paid'; 73 | const PAYMENT_FAILED = 'payment_failed'; 74 | } 75 | ``` 76 | 77 | 3. Define the allowed transitions: 78 | 79 | ```php 80 | class BookingState extends State 81 | { 82 | // ... 83 | 84 | public static function config() 85 | { 86 | self::set(BookingStateTransition::PAYMENT_PAID) 87 | ->from(self::PENDING) 88 | ->to(self::SUCCESSFULL); 89 | self::set(BookingStateTransition::PAYMENT_FAILED) 90 | ->from(self::PENDING) 91 | ->to(self::FAILED); 92 | } 93 | } 94 | ``` 95 | 96 | 4. Setup your Model: 97 | 98 | ```php 99 | use AwStudio\States\Contracts\Stateful; 100 | use AwStudio\States\HasStates; 101 | 102 | class Booking extends Model implements Stateful 103 | { 104 | use HasStates; 105 | 106 | protected $states = [ 107 | 'state' => BookingState::class, 108 | 'payment_state' => ..., 109 | ]; 110 | } 111 | ``` 112 | 113 | 114 | 115 | ## Usage 116 | 117 | 118 | 119 | ### Receive The Current State 120 | 121 | ```php 122 | $booking->state->current(); // "pending" 123 | (string) $booking->state; // "pending" 124 | ``` 125 | 126 | Determine if the current state is a given state: 127 | 128 | ```php 129 | if($booking->state->is(BookingState::PENDING)) { 130 | // 131 | } 132 | ``` 133 | 134 | Determine if the current state is any of a the given states: 135 | 136 | ```php 137 | $states = [ 138 | BookingState::PENDING, 139 | BookingState::SUCCESSFULL 140 | ]; 141 | if($booking->state->isAnyOf($states)) { 142 | // 143 | } 144 | ``` 145 | 146 | Determine if the state has been the given state at any time: 147 | 148 | ```php 149 | if($booking->state->was(BookingState::PENDING)) { 150 | // 151 | } 152 | ``` 153 | 154 | 155 | 156 | ### Execute Transitions 157 | 158 | Execute a state transition: 159 | 160 | ```php 161 | $booking->state->transition(BookingStateTransition::PAYMENT_PAID); 162 | ``` 163 | 164 | Prevent throwing an exception when the given transition is not allowed for the current state by setting fail to `false`: 165 | 166 | ```php 167 | $booking->state->transition(BookingStateTransition::PAYMENT_PAID, fail: false); 168 | ``` 169 | 170 | Store additional information about the reason of a transition. 171 | 172 | ```php 173 | $booking->state->transition(BookingStateTransition::PAYMENT_PAID, reason: "Mollie API call failed."); 174 | ``` 175 | 176 | Determine wether the transition is allowed for the current state: 177 | 178 | ```php 179 | $booking->state->can(BookingStateTransition::PAYMENT_PAID); 180 | ``` 181 | 182 | Lock the current state for update at the start of a transaction so the state can not be modified by simultansiously requests until the transaction is finished: 183 | 184 | ```php 185 | DB::transaction(function() { 186 | // Lock the current state for update: 187 | $booking->state->lockForUpdate(); 188 | 189 | // ... 190 | }); 191 | 192 | ``` 193 | 194 | 195 | 196 | ### Eager Loading 197 | 198 | Reload the current state: 199 | 200 | ```php 201 | $booking->state->reload(); 202 | ``` 203 | 204 | Eager load the current state: 205 | 206 | ```php 207 | Booking::withCurrentState(); 208 | Booking::withCurrentState('payment_state'); 209 | 210 | $booking->loadCurrentState(); 211 | $booking->loadCurrentState('payment_state'); 212 | ``` 213 | 214 | 215 | 216 | ### Query Methods 217 | 218 | Filter models that have or dont have a current state: 219 | 220 | ```php 221 | Booking::whereStateIs('payment_state', PaymentState::PAID); 222 | Booking::orWhereStateIs('payment_state', PaymentState::PAID); 223 | Booking::whereStateIsNot('payment_state', PaymentState::PAID); 224 | Booking::orWhereStateIsNot('payment_state', PaymentState::PAID); 225 | Booking::whereStateWas('payment_state', PaymentState::PAID); 226 | Booking::whereStateWasNot('payment_state', PaymentState::PAID); 227 | ``` 228 | 229 | Receive state changes: 230 | 231 | ```php 232 | $booking->states()->get() // Get all states. 233 | $booking->states('payment_state')->get() // Get all payment states. 234 | ``` 235 | 236 | 237 | 238 | ## Observer Events 239 | 240 | Listen to state changes or transitions in your model observer: 241 | 242 | ```php 243 | class BookingObserver 244 | { 245 | public function stateSuccessfull(Booking $booking) 246 | { 247 | // Gets fired when booking state changed to successfull. 248 | } 249 | 250 | public function paymentStatePaid(Booking $booking) 251 | { 252 | // Gets fired when booking payment_state changed to paid. 253 | } 254 | 255 | public function stateTransitionPaymentPaid(Booking $booking) 256 | { 257 | // Gets fired when state transition payment_paid gets fired. 258 | } 259 | } 260 | ``` 261 | 262 | ## Static Methods: 263 | 264 | ```php 265 | BookingState::whereCan(BookingStateTransition::PAYMENT_PAID); // Gets states where from where the given transition can be executed. 266 | BookingState::canTransitionFrom('pending', 'cancel'); // Determines if the transition can be executed for the given state. 267 | ``` 268 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aw-studio/laravel-states", 3 | "license": "MIT", 4 | "authors": [ 5 | { 6 | "name": "cbl", 7 | "email": "lennart.carbe@gmail.com" 8 | } 9 | ], 10 | "scripts": { 11 | "test": "vendor/bin/phpunit" 12 | }, 13 | "autoload": { 14 | "psr-4": { 15 | "AwStudio\\States\\": "src" 16 | } 17 | }, 18 | "autoload-dev": { 19 | "psr-4": { 20 | "Tests\\": "tests/" 21 | } 22 | }, 23 | "require": { 24 | "illuminate/database": "^8.0 | ^9.0 | ^10.0 | ^11.0", 25 | "illuminate/support": "^8.0 | ^9.0 | ^10.0 | ^11.0" 26 | }, 27 | "require-dev": { 28 | "phpunit/phpunit": "^9.4", 29 | "mockery/mockery": "^1.4", 30 | "orchestra/testbench": "^6.13" 31 | }, 32 | "extra": { 33 | "laravel": { 34 | "providers": [ 35 | "AwStudio\\States\\StatesServiceProvider" 36 | ] 37 | }, 38 | "branch-alias": { 39 | "dev-master": "1.x-dev" 40 | } 41 | }, 42 | "minimum-stability": "dev", 43 | "prefer-stable": true 44 | } 45 | -------------------------------------------------------------------------------- /migrations/2021_00_00_000000_create_states_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->string('type'); 19 | $table->string('state'); 20 | $table->string('from'); 21 | $table->text('reason')->nullable(); 22 | $table->string('transition')->nullable(); 23 | $table->morphs('stateful'); 24 | $table->timestamps(); 25 | }); 26 | } 27 | 28 | /** 29 | * Reverse the migrations. 30 | * 31 | * @return void 32 | */ 33 | public function down() 34 | { 35 | Schema::dropIfExists('states'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | ./tests 17 | 18 | 19 | 20 | 21 | ./src 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/Contracts/Stateful.php: -------------------------------------------------------------------------------- 1 | 'changed', 26 | ]; 27 | 28 | /** 29 | * Initialize HasStates trait. 30 | * 31 | * @return void 32 | */ 33 | public function initializeHasStates() 34 | { 35 | if (! isset(static::$initialized[static::class])) { 36 | static::$initialized[static::class] = true; 37 | 38 | foreach ($this->states as $type => $state) { 39 | static::resolveRelationUsing( 40 | $this->getCurrentStateRelationName($type), 41 | function (Model $stateful) use ($type) { 42 | return $this->belongsTo(State::class, "current_{$type}_id"); 43 | } 44 | ); 45 | } 46 | } 47 | } 48 | 49 | /** 50 | * Get current state relation name. 51 | * 52 | * @param string $type 53 | * @return string 54 | */ 55 | public function getCurrentStateRelationName($type = 'state') 56 | { 57 | return "current_{$type}"; 58 | } 59 | 60 | /** 61 | * Get states. 62 | * 63 | * @return array 64 | */ 65 | public function getStateTypes() 66 | { 67 | return $this->states; 68 | } 69 | 70 | /** 71 | * Get state type. 72 | * 73 | * @param string $type 74 | * @return string|null 75 | */ 76 | public function getStateType($type) 77 | { 78 | return $this->getStateTypes()[$type] ?? null; 79 | } 80 | 81 | /** 82 | * Determine wether a state exists. 83 | * 84 | * @param string $type 85 | * @return bool 86 | */ 87 | public function hasState($type) 88 | { 89 | return array_key_exists($type, $this->states); 90 | } 91 | 92 | /** 93 | * States relationship. 94 | * 95 | * @return States 96 | */ 97 | public function states($type = null): States 98 | { 99 | $morphMany = $this->morphMany(State::class, 'stateful'); 100 | 101 | $states = new States( 102 | $morphMany->getQuery(), 103 | $morphMany->getParent(), 104 | $morphMany->getQualifiedMorphType(), 105 | $morphMany->getQualifiedForeignKeyName(), 106 | $morphMany->getLocalKeyName() 107 | ); 108 | 109 | if ($type) { 110 | $states->where('type', $type); 111 | } 112 | 113 | return $states; 114 | } 115 | 116 | /** 117 | * Update the current state. 118 | * 119 | * @param string $value 120 | * @param string $type 121 | * @return void 122 | */ 123 | public function setState($value, $type = 'state') 124 | { 125 | $this->states()->make([ 126 | 'from' => $this->getState($type), 127 | 'state' => $value, 128 | 'type' => $type, 129 | ])->save(); 130 | } 131 | 132 | /** 133 | * Get the current state. 134 | * 135 | * @param string $type 136 | * @return State 137 | */ 138 | public function getState($type = 'state') 139 | { 140 | $relation = $this->getCurrentStateRelationName($type); 141 | 142 | if (! $this->relationLoaded($relation)) { 143 | $this->loadCurrentState($type); 144 | } 145 | 146 | $latest = $this->getRelation($relation); 147 | 148 | if (! $latest) { 149 | return $this->getStateTypes()[$type]::INITIAL_STATE; 150 | } 151 | 152 | return $latest->state; 153 | } 154 | 155 | /** 156 | * Determine if a get mutator exists for an attribute. 157 | * 158 | * @param string $key 159 | * @return bool 160 | */ 161 | public function hasGetMutator($key) 162 | { 163 | if ($this->hasState($key)) { 164 | return true; 165 | } 166 | 167 | return parent::hasGetMutator($key); 168 | } 169 | 170 | /** 171 | * Get the value of an attribute using its mutator. 172 | * 173 | * @param string $key 174 | * @param mixed $value 175 | * @return mixed 176 | */ 177 | public function mutateAttribute($key, $value) 178 | { 179 | if ($this->hasState($key)) { 180 | return $this->mutatStateAttribute($key); 181 | } 182 | 183 | return parent::mutateAttribute($key, $value); 184 | } 185 | 186 | /** 187 | * Mutate state attribute. 188 | * 189 | * @param string $key 190 | * @return State 191 | */ 192 | public function mutatStateAttribute($key) 193 | { 194 | $type = $this->getStateType($key); 195 | 196 | $state = new $type($this, $key); 197 | 198 | if (parent::hasGetMutator($key)) { 199 | $state = parent::mutateAttribute($key, $state); 200 | } 201 | 202 | return $state; 203 | } 204 | 205 | /** 206 | * Register a single observer with the model. 207 | * 208 | * @param object|string $class 209 | * @return void 210 | * 211 | * @throws \RuntimeException 212 | */ 213 | protected function registerObserver($class) 214 | { 215 | parent::registerObserver($class); 216 | $className = $this->resolveObserverClassName($class); 217 | $reflector = new ReflectionClass($className); 218 | 219 | foreach ($reflector->getMethods() as $method) { 220 | foreach ($this->getStateTypes() as $type => $stateClass) { 221 | foreach ($this->observableStateEvents as $event => $methodPrefix) { 222 | $methodName = $methodPrefix.ucfirst(Str::camel($type)); 223 | if ($method->getName() != $methodName) { 224 | continue; 225 | } 226 | 227 | static::registerModelEvent( 228 | "{$event}.{$type}", 229 | $className.'@'.$method->getName() 230 | ); 231 | } 232 | foreach ($stateClass::all() as $state) { 233 | if (! $this->watchesObserverMethodState($method->getName(), $type, $state)) { 234 | continue; 235 | } 236 | 237 | static::registerModelEvent( 238 | $this->getStateEventName($type, $state), 239 | $className.'@'.$method->getName() 240 | ); 241 | } 242 | foreach ($stateClass::uniqueTransitions() as $transition) { 243 | if (! $this->watchesObserverMethodStateTransition($method->getName(), $type, $transition)) { 244 | continue; 245 | } 246 | 247 | static::registerModelEvent( 248 | $this->getTransitionEventName($type, $transition), 249 | $className.'@'.$method->getName() 250 | ); 251 | } 252 | } 253 | } 254 | } 255 | 256 | /** 257 | * Resolve the observer's class name from an object or string. 258 | * 259 | * @param object|string $class 260 | * @return string 261 | * 262 | * @throws \InvalidArgumentException 263 | */ 264 | protected function resolveObserverClassName($class) 265 | { 266 | if (is_object($class)) { 267 | return get_class($class); 268 | } 269 | 270 | if (class_exists($class)) { 271 | return $class; 272 | } 273 | 274 | throw new InvalidArgumentException('Unable to find observer: '.$class); 275 | } 276 | 277 | /** 278 | * Determines wether an observer method watches the given state transition. 279 | * 280 | * @param string $method 281 | * @param string $type 282 | * @param string $transition 283 | * @return bool 284 | */ 285 | protected function watchesObserverMethodStateTransition($method, $type, $transition) 286 | { 287 | $start = Str::camel($type).'Transition'; 288 | 289 | if (! Str::startsWith($method, $start)) { 290 | return false; 291 | } 292 | 293 | $following = str_replace($start, '', $method); 294 | 295 | return $following == ucfirst(Str::camel($transition)); 296 | } 297 | 298 | /** 299 | * Determines wether an observer method watches the given state. 300 | * 301 | * @param string $method 302 | * @param string $type 303 | * @param string $state 304 | * @return bool 305 | */ 306 | protected function watchesObserverMethodState($method, $type, $state) 307 | { 308 | if (! Str::startsWith($method, Str::camel($type))) { 309 | return false; 310 | } 311 | 312 | $following = str_replace(Str::camel($type), '', $method); 313 | 314 | if ($following == ucfirst(Str::camel($state))) { 315 | return true; 316 | } 317 | 318 | if (! str_contains($method, 'Or')) { 319 | return false; 320 | } 321 | 322 | foreach (explode('Or', $following) as $key) { 323 | if ($key == $state) { 324 | return true; 325 | } 326 | } 327 | 328 | return false; 329 | } 330 | 331 | /** 332 | * Get state event name. 333 | * 334 | * @param string $type 335 | * @param string $state 336 | * @return string 337 | */ 338 | public function getStateEventName($type, $state) 339 | { 340 | return "{$type}.{$state}"; 341 | } 342 | 343 | /** 344 | * Get state event name. 345 | * 346 | * @param string $type 347 | * @param string $state 348 | * @return string 349 | */ 350 | public function getStateEventMethod($type, $state) 351 | { 352 | return Str::camel("{$type}_{$state}"); 353 | } 354 | 355 | /** 356 | * Get transition event name. 357 | * 358 | * @param string $type 359 | * @param string $transition 360 | * @return string 361 | */ 362 | public function getTransitionEventName($type, $transition) 363 | { 364 | return "{$type}.transition.{$transition}"; 365 | } 366 | 367 | /** 368 | * Get transition event name. 369 | * 370 | * @param string $type 371 | * @param string $transition 372 | * @return string 373 | */ 374 | public function getTransitionEventMethod($type, $transition) 375 | { 376 | return Str::camel("{$type}_transition_{$transition}"); 377 | } 378 | 379 | /** 380 | * Fire state events for the given transition. 381 | * 382 | * @param string $type 383 | * @param Transition $transition 384 | * @return void 385 | */ 386 | public function fireStateEventsFor($type, Transition $transition) 387 | { 388 | $this->fireModelEvent($this->getTransitionEventName($type, $transition->name)); 389 | $this->fireModelEvent($this->getStateEventName($type, $transition->to)); 390 | $this->fireModelEvent("changed.{$type}"); 391 | } 392 | 393 | /** 394 | * `whereDoesntHaveStates` query scope. 395 | * 396 | * @param string $type 397 | * @return void 398 | */ 399 | public function scopeWhereDoesntHaveStates($query, $type = 'state') 400 | { 401 | $query->whereDoesntHave('states', function ($statesQuery) use ($type) { 402 | $statesQuery->where('type', $type); 403 | }); 404 | } 405 | 406 | /** 407 | * `whereDoesntHaveStates` query scope. 408 | * 409 | * @param string $type 410 | * @return void 411 | */ 412 | public function scopeOrWhereDoesntHaveStates($query, $type = 'state') 413 | { 414 | $query->orWhereDoesntHave('states', function ($statesQuery) use ($type) { 415 | $statesQuery->where('type', $type); 416 | }); 417 | } 418 | 419 | /** 420 | * `whereStateWas` query scope. 421 | * 422 | * @param Builder $query 423 | * @param string $type 424 | * @param string $value 425 | * @return void 426 | */ 427 | public function scopeWhereStateWas($query, $type, $value) 428 | { 429 | if ($this->getStateType($type)::INITIAL_STATE == $value) { 430 | return; 431 | } 432 | 433 | $query->whereHas('states', function ($statesQuery) use ($type, $value) { 434 | $statesQuery 435 | ->where('type', $type) 436 | ->where('state', $value); 437 | }); 438 | } 439 | 440 | /** 441 | * `whereNotHaveWasNot` query scope. 442 | * 443 | * @param Builder $query 444 | * @param string $type 445 | * @param string $value 446 | * @return void 447 | */ 448 | public function scopeWhereNotHaveWasNot($query, $type, $value) 449 | { 450 | $query->whereDoesntHave('states', function ($statesQuery) use ($type, $value) { 451 | $statesQuery 452 | ->where('type', $type) 453 | ->where('state', $value); 454 | }); 455 | } 456 | 457 | /** 458 | * `whereStateIs` query scope. 459 | * 460 | * @param Builder $query 461 | * @param string $type 462 | * @param string $value 463 | * @return void 464 | */ 465 | public function scopeWhereStateIs($query, $type, $value) 466 | { 467 | if ($this->getStateType($type)::INITIAL_STATE == $value) { 468 | return $query->whereDoesntHaveStates($type); 469 | } 470 | 471 | $query->whereExists(function ($existsQuery) use ($type, $value) { 472 | $existsQuery 473 | ->from(DB::raw((new State())->getTable().' as _s')) 474 | ->where('type', $type) 475 | ->where('stateful_type', $this->getMorphClass()) 476 | ->whereColumn('stateful_id', $this->getTable().'.id') 477 | ->where('state', $value) 478 | ->whereNotExists(function ($notExistsQuery) use ($type) { 479 | $notExistsQuery->from('states') 480 | ->where('type', $type) 481 | ->where('stateful_type', $this->getMorphClass()) 482 | ->whereColumn('stateful_id', $this->getTable().'.id') 483 | ->whereColumn('id', '>', '_s.id'); 484 | }); 485 | }); 486 | } 487 | 488 | /** 489 | * `orWhereStateIs` query scope. 490 | * 491 | * @param Builder $query 492 | * @param string $type 493 | * @param string $value 494 | * @return void 495 | */ 496 | public function scopeOrWhereStateIs($query, $type, $value) 497 | { 498 | if ($this->getStateType($type)::INITIAL_STATE == $value) { 499 | return $query->orWhereDoesntHaveStates($type); 500 | } 501 | 502 | $query->orWhereExists(function ($existsQuery) use ($type, $value) { 503 | $existsQuery 504 | ->from(DB::raw((new State())->getTable().' as _s')) 505 | ->where('type', $type) 506 | ->where('stateful_type', $this->getMorphClass()) 507 | ->whereColumn('stateful_id', $this->getTable().'.id') 508 | ->where('state', $value) 509 | ->whereNotExists(function ($notExistsQuery) use ($type) { 510 | $notExistsQuery->from('states') 511 | ->where('type', $type) 512 | ->where('stateful_type', $this->getMorphClass()) 513 | ->whereColumn('stateful_id', $this->getTable().'.id') 514 | ->whereColumn('id', '>', '_s.id'); 515 | }); 516 | }); 517 | } 518 | 519 | /** 520 | * `whereStateIn` query scope. 521 | * 522 | * @param Builder $query 523 | * @param string $type 524 | * @param array $value 525 | * @return void 526 | */ 527 | public function scopeWhereStateIsIn($query, $type, array $value) 528 | { 529 | if ($this->getStateType($type)::INITIAL_STATE == $value) { 530 | return $query->whereDoesntHaveStates($type); 531 | } 532 | 533 | $query->whereExists(function ($existsQuery) use ($type, $value) { 534 | $existsQuery 535 | ->from(DB::raw((new State())->getTable().' as _s')) 536 | ->where('type', $type) 537 | ->where('stateful_type', $this->getMorphClass()) 538 | ->whereColumn('stateful_id', $this->getTable().'.id') 539 | ->whereIn('state', $value) 540 | ->whereNotExists(function ($notExistsQuery) use ($type) { 541 | $notExistsQuery->from('states') 542 | ->where('type', $type) 543 | ->where('stateful_type', $this->getMorphClass()) 544 | ->whereColumn('stateful_id', $this->getTable().'.id') 545 | ->whereColumn('id', '>', '_s.id'); 546 | }); 547 | }); 548 | } 549 | 550 | /** 551 | * `whereStateIsNot` query scope. 552 | * 553 | * @param Builder $query 554 | * @param string $type 555 | * @param string $value 556 | * @return void 557 | */ 558 | public function scopeWhereStateIsNot($query, $type, $value) 559 | { 560 | $query->where(function ($query) use ($type, $value) { 561 | $query->whereExists(function ($existsQuery) use ($type, $value) { 562 | $existsQuery 563 | ->from(DB::raw((new State())->getTable().' as _s')) 564 | ->where('type', $type) 565 | ->where('stateful_type', $this->getMorphClass()) 566 | ->whereColumn('stateful_id', $this->getTable().'.id') 567 | ->where('state', '!=', $value) 568 | ->whereNotExists(function ($notExistsQuery) use ($type) { 569 | $notExistsQuery->from('states') 570 | ->where('type', $type) 571 | ->where('stateful_type', $this->getMorphClass()) 572 | ->whereColumn('stateful_id', $this->getTable().'.id') 573 | ->whereColumn('id', '>', '_s.id'); 574 | }); 575 | }); 576 | 577 | if (! in_array($this->getStateType($type)::INITIAL_STATE, Arr::wrap($value))) { 578 | return $query->orWhereDoesntHaveStates($type); 579 | } 580 | }); 581 | } 582 | 583 | /** 584 | * `whereStateIsNot` query scope. 585 | * 586 | * @param Builder $query 587 | * @param string $type 588 | * @param array $value 589 | * @return void 590 | */ 591 | public function scopeWhereStateIsNotIn($query, $type, array $value) 592 | { 593 | $query->where(function ($query) use ($type, $value) { 594 | $query->whereExists(function ($existsQuery) use ($type, $value) { 595 | $existsQuery 596 | ->from(DB::raw((new State())->getTable().' as _s')) 597 | ->where('type', $type) 598 | ->where('stateful_type', $this->getMorphClass()) 599 | ->whereColumn('stateful_id', $this->getTable().'.id') 600 | ->whereNotIn('state', $value) 601 | ->whereNotExists(function ($notExistsQuery) use ($type) { 602 | $notExistsQuery->from('states') 603 | ->where('type', $type) 604 | ->where('stateful_type', $this->getMorphClass()) 605 | ->whereColumn('stateful_id', $this->getTable().'.id') 606 | ->whereColumn('id', '>', '_s.id'); 607 | }); 608 | }); 609 | 610 | if (! in_array($this->getStateType($type)::INITIAL_STATE, Arr::wrap($value))) { 611 | return $query->orWhereDoesntHaveStates($type); 612 | } 613 | }); 614 | } 615 | 616 | /** 617 | * `orWhereStateIsNot` query scope. 618 | * 619 | * @param Builder $query 620 | * @param string $type 621 | * @param string|array $value 622 | * @return void 623 | */ 624 | public function scopeOrWhereStateIsNot($query, $type, $value) 625 | { 626 | $query->orWhere(function ($query) use ($type, $value) { 627 | $query->whereExists(function ($existsQuery) use ($type, $value) { 628 | $existsQuery 629 | ->from(DB::raw((new State())->getTable().' as _s')) 630 | ->where('type', $type) 631 | ->where('stateful_type', $this->getMorphClass()) 632 | ->whereColumn('stateful_id', $this->getTable().'.id') 633 | ->where('state', '!=', $value) 634 | ->whereNotExists(function ($notExistsQuery) use ($type) { 635 | $notExistsQuery->from('states') 636 | ->where('type', $type) 637 | ->where('stateful_type', $this->getMorphClass()) 638 | ->whereColumn('stateful_id', $this->getTable().'.id') 639 | ->whereColumn('id', '>', '_s.id'); 640 | }); 641 | }); 642 | 643 | if (! in_array($this->getStateType($type)::INITIAL_STATE, Arr::wrap($value))) { 644 | return $query->orWhereDoesntHaveStates($type); 645 | } 646 | }); 647 | } 648 | 649 | /** 650 | * `addCurrentStateSelect` query scope. 651 | * 652 | * @param Builder $query 653 | * @param string $type 654 | * @param string $select 655 | * @return void 656 | */ 657 | public function scopeAddCurrentStateSelect($query, $type = 'state', $select = 'state', Closure $closure = null) 658 | { 659 | $query->addSelect(["current_{$type}_{$select}" => State::select($select) 660 | ->where('type', $type) 661 | ->where('stateful_type', $this->getMorphClass()) 662 | ->whereColumn('stateful_id', $this->getTable().'.id') 663 | ->orderByDesc('id') 664 | ->take(1), 665 | ]); 666 | } 667 | 668 | /** 669 | * `withCurrentState` query scope. 670 | * 671 | * @param Builder $query 672 | * @param string $type 673 | * @return void 674 | */ 675 | public function scopeWithCurrentState($query, $type = 'state') 676 | { 677 | $query->addCurrentStateSelect($type, 'id') 678 | ->with($this->getCurrentStateRelationName($type)); 679 | } 680 | 681 | /** 682 | * Load state relation of the given type. 683 | * 684 | * @param string $type 685 | * @return $this 686 | */ 687 | public function loadCurrentState($type = 'state') 688 | { 689 | $currentState = $this->states() 690 | ->where('type', $type) 691 | ->orderByDesc('id') 692 | ->first(); 693 | 694 | $this->setRelation( 695 | $this->getCurrentStateRelationName($type), 696 | $currentState 697 | ); 698 | 699 | return $this; 700 | } 701 | 702 | /** 703 | * Reload current state relation of the given type. 704 | * 705 | * @param string $type 706 | * @return $this 707 | */ 708 | public function reloadState($type = 'state') 709 | { 710 | $this->loadCurrentState($type); 711 | 712 | return $this; 713 | } 714 | } 715 | -------------------------------------------------------------------------------- /src/Models/State.php: -------------------------------------------------------------------------------- 1 | ticket_state->current());. 27 | * 28 | * @var Stateful|Model 29 | */ 30 | protected $stateful; 31 | 32 | /** 33 | * State type. 34 | * 35 | * @var string 36 | */ 37 | protected $type; 38 | 39 | /** 40 | * Configure the state. 41 | * 42 | * @return void 43 | */ 44 | abstract public static function config(); 45 | 46 | /** 47 | * Returns an array of all types. 48 | * 49 | * @return array 50 | */ 51 | public static function all() 52 | { 53 | $reflector = new ReflectionClass(static::class); 54 | 55 | return collect($reflector->getConstants()) 56 | ->filter(fn ($value, $key) => ! in_array($key, [ 57 | 'INITIAL_STATE', 'FINAL_STATES', 58 | ])) 59 | ->values() 60 | ->toArray(); 61 | } 62 | 63 | /** 64 | * Get states from where the given transition can be executed. 65 | * 66 | * @param string $transition 67 | * @return array 68 | */ 69 | public static function whereCan($transition) 70 | { 71 | return collect(static::all()) 72 | ->filter(function ($state) use ($transition) { 73 | return static::canTransitionFrom($state, $transition); 74 | }) 75 | ->values() 76 | ->toArray(); 77 | } 78 | 79 | /** 80 | * Determines wether the given transition can be executed for the given 81 | * state. 82 | * 83 | * @param string $state 84 | * @param string $transition 85 | * @return bool 86 | */ 87 | public static function canTransitionFrom($state, $transition) 88 | { 89 | return (bool) collect(static::getTransitions()) 90 | ->first(function (Transition $t) use ($state, $transition) { 91 | return $t->from == $state 92 | && $t->name == $transition; 93 | }); 94 | } 95 | 96 | /** 97 | * Returns an array fo all transitions. 98 | * 99 | * @return array 100 | */ 101 | public static function uniqueTransitions() 102 | { 103 | $transitions = []; 104 | 105 | foreach (static::getTransitions() as $transition) { 106 | if (! in_array($transition->name, $transitions)) { 107 | $transitions[] = $transition->name; 108 | } 109 | } 110 | 111 | return $transitions; 112 | } 113 | 114 | /** 115 | * Allow transition. 116 | * 117 | * @param string $transition 118 | * @return Transition 119 | */ 120 | public static function set($transition) 121 | { 122 | if (! array_key_exists(static::class, static::$transitions)) { 123 | static::$transitions[static::class] = []; 124 | } 125 | 126 | $transition = new Transition($transition); 127 | 128 | static::$transitions[static::class][] = $transition; 129 | 130 | return $transition; 131 | } 132 | 133 | /** 134 | * Get transitions. 135 | * 136 | * @return array 137 | */ 138 | public static function getTransitions() 139 | { 140 | if (! array_key_exists(static::class, static::$transitions)) { 141 | static::config(); 142 | } 143 | 144 | return static::$transitions[static::class]; 145 | } 146 | 147 | /** 148 | * Create new State instance. 149 | * 150 | * @param Stateful $stateful 151 | * @param string $type 152 | * @return void 153 | */ 154 | public function __construct(Stateful $stateful, $type) 155 | { 156 | $this->stateful = $stateful; 157 | $this->type = $type; 158 | } 159 | 160 | /** 161 | * Get the state type. 162 | * 163 | * @return string 164 | */ 165 | public function getType() 166 | { 167 | if (property_exists($this, 'type')) { 168 | return $this->type; 169 | } 170 | 171 | return $this->getTypeFromNamespace(); 172 | } 173 | 174 | /** 175 | * Get type from namespace. 176 | * 177 | * @return string 178 | */ 179 | protected function getTypeFromNamespace() 180 | { 181 | return Str::snake(class_basename(static::class)); 182 | } 183 | 184 | /** 185 | * Determines if a transition can be executed. 186 | * 187 | * @param string $transition 188 | * @return bool 189 | */ 190 | public function can($transition) 191 | { 192 | return (bool) $this->getCurrentTransition($transition); 193 | } 194 | 195 | /** 196 | * Get current transition. 197 | * 198 | * @param string $transition 199 | * @return Transition|void 200 | */ 201 | public function getCurrentTransition($transition) 202 | { 203 | foreach ($this->getTransitions() as $t) { 204 | if ($t->name == $transition && $this->current() == $t->from) { 205 | return $t; 206 | } 207 | } 208 | } 209 | 210 | /** 211 | * Determines wether a transition exists. 212 | * 213 | * @param string $transition 214 | * @return bool 215 | */ 216 | public function transitionExists($transition) 217 | { 218 | foreach ($this->getTransitions() as $t) { 219 | if ($t->name == $transition) { 220 | return true; 221 | } 222 | } 223 | 224 | return false; 225 | } 226 | 227 | /** 228 | * Lock the current state for update. 229 | * 230 | * @return $this 231 | */ 232 | public function lockForUpdate() 233 | { 234 | $dbConnection = $this->stateful->states()->getQuery()->getQuery()->getConnection(); 235 | 236 | if ($dbConnection instanceof \Illuminate\Database\PostgresConnection) { 237 | return $this; 238 | } 239 | 240 | $this->stateful 241 | ->states() 242 | ->where('type', $this->type) 243 | ->lockForUpdate() 244 | ->count(); 245 | 246 | return $this; 247 | } 248 | 249 | /** 250 | * Execute transition. 251 | * 252 | * @param string $transition 253 | * @param string $fail 254 | * @param string $reason 255 | * @return void 256 | * 257 | * @throws TransitionException 258 | */ 259 | public function transition($name, $fail = true, $reason = null) 260 | { 261 | [$state, $transition] = DB::transaction(function () use ($name, $fail, $reason) { 262 | $this->reload()->lockForUpdate(); 263 | if (! $this->can($name)) { 264 | if ($this->transitionExists($name)) { 265 | Log::warning('Unallowed transition.', [ 266 | 'transition' => $name, 267 | 'type' => $this->type, 268 | 'current' => $this->current(), 269 | ]); 270 | } 271 | 272 | if (! $fail) { 273 | return; 274 | } 275 | 276 | throw new TransitionException( 277 | "Transition [{$name}] to change [{$this->type}] not allowed for [".$this->current().']' 278 | ); 279 | } 280 | 281 | $transition = $this->getCurrentTransition($name); 282 | 283 | $state = $this->stateful->states()->makeFromTransition( 284 | $this->getType(), 285 | $transition, 286 | $reason 287 | ); 288 | $state->save(); 289 | 290 | return [$state, $transition]; 291 | }, 5); 292 | 293 | if (! $transition) { 294 | return; 295 | } 296 | 297 | $this->stateful->setRelation( 298 | $this->stateful->getCurrentStateRelationName($this->getType()), 299 | $state 300 | ); 301 | 302 | $this->stateful->fireStateEventsFor($this->getType(), $transition); 303 | 304 | return $state; 305 | } 306 | 307 | /** 308 | * Get current state. 309 | * 310 | * @return string 311 | */ 312 | public function current() 313 | { 314 | return $this->stateful->getState( 315 | $this->getType() 316 | ); 317 | } 318 | 319 | /** 320 | * Determine wether state was the given state. 321 | * 322 | * @return bool 323 | */ 324 | public function was($state) 325 | { 326 | if ($state == static::INITIAL_STATE) { 327 | return true; 328 | } 329 | 330 | return $this->stateful 331 | ->states($this->getType()) 332 | ->where('state', $state) 333 | ->exists(); 334 | } 335 | 336 | /** 337 | * Determine if the current state is the given state. 338 | * 339 | * @param string|array $state 340 | * @return bool 341 | */ 342 | public function is($state) 343 | { 344 | return in_array($this->current(), Arr::wrap($state)); 345 | } 346 | 347 | /** 348 | * Determine if the current state is any of the given states. 349 | * 350 | * @param array $states 351 | * @return bool 352 | */ 353 | public function isAnyOf($states) 354 | { 355 | return in_array($this->current(), $states); 356 | } 357 | 358 | /** 359 | * Reload the state. 360 | * 361 | * @return $this 362 | */ 363 | public function reload() 364 | { 365 | $this->stateful->reloadState($this->getType()); 366 | 367 | return $this; 368 | } 369 | 370 | /** 371 | * Format to string. 372 | * 373 | * @return string 374 | */ 375 | public function __toString() 376 | { 377 | return $this->current(); 378 | } 379 | 380 | /** 381 | * Convert the object to its JSON representation. 382 | * 383 | * @param int $options 384 | * @return string 385 | */ 386 | public function toJson($options = 0) 387 | { 388 | return json_encode($this->current(), $options); 389 | } 390 | } 391 | -------------------------------------------------------------------------------- /src/States.php: -------------------------------------------------------------------------------- 1 | parent->getStateType($type)) { 20 | return; 21 | } 22 | 23 | if (! $this->parent->{$type} instanceof State) { 24 | return; 25 | } 26 | 27 | return $this->parent->{$type}->executeTransition($transition); 28 | } 29 | 30 | /** 31 | * Make state from transition. 32 | * 33 | * @param string $type 34 | * @param Transition $transition 35 | * @param string|null $reason 36 | * @return StateModel 37 | */ 38 | public function makeFromTransition($type, Transition $transition, $reason = null) 39 | { 40 | return $this->make([ 41 | 'transition' => $transition->name, 42 | 'from' => $transition->from, 43 | 'state' => $transition->to, 44 | 'type' => $type, 45 | 'reason' => $reason, 46 | ]); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/StatesServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 17 | __DIR__.'/../migrations' => database_path('migrations'), 18 | ], 'states:migrations'); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Transition.php: -------------------------------------------------------------------------------- 1 | name = $transition; 37 | } 38 | 39 | /** 40 | * Set from state. 41 | * 42 | * @param string $state 43 | * @return $this 44 | */ 45 | public function from($state) 46 | { 47 | $this->from = $state; 48 | 49 | return $this; 50 | } 51 | 52 | /** 53 | * Set to state. 54 | * 55 | * @param string $state 56 | * @return $this 57 | */ 58 | public function to($state) 59 | { 60 | $this->to = $state; 61 | 62 | return $this; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tests/ModelIntegrationTest.php: -------------------------------------------------------------------------------- 1 | loadMigrationsFrom(__DIR__.'/../migrations'); 23 | 24 | Schema::create('bookings', function (Blueprint $table) { 25 | $table->id(); 26 | }); 27 | } 28 | 29 | public function tearDown(): void 30 | { 31 | Schema::drop('bookings'); 32 | parent::tearDown(); 33 | } 34 | 35 | /** @test */ 36 | public function it_has_initial_state() 37 | { 38 | $booking = new Booking(); 39 | $this->assertNotNull($booking->state); 40 | $this->assertInstanceOf(BookingState::class, $booking->state); 41 | $this->assertSame(BookingState::INITIAL_STATE, $booking->state->current()); 42 | } 43 | 44 | /** @test */ 45 | public function it_executes_transition() 46 | { 47 | $booking = Booking::create(); 48 | $booking->state->transition(BookingStateTransition::PAYMENT_PAID); 49 | $this->assertSame(BookingState::SUCCESSFULL, $booking->state->current()); 50 | 51 | $booking = Booking::create(); 52 | $booking->state->transition(BookingStateTransition::PAYMENT_FAILED); 53 | $this->assertSame(BookingState::FAILED, $booking->state->current()); 54 | } 55 | 56 | /** @test */ 57 | public function it_fails_for_invalid_transition() 58 | { 59 | $this->expectException(TransitionException::class); 60 | $booking = Booking::create(); 61 | $booking->state->transition('foo'); 62 | } 63 | 64 | /** @test */ 65 | public function test_state_getter() 66 | { 67 | $booking = Booking::create(); 68 | $booking->state->transition(BookingStateTransition::PAYMENT_PAID); 69 | $this->assertInstanceOf(State::class, $booking->state); 70 | $this->assertSame(BookingState::SUCCESSFULL, (string) $booking->state); 71 | } 72 | 73 | /** @test */ 74 | public function test_state_is_method() 75 | { 76 | $booking = Booking::create(); 77 | $this->assertTrue($booking->state->is(BookingState::PENDING)); 78 | $booking->state->transition(BookingStateTransition::PAYMENT_PAID); 79 | $this->assertTrue($booking->state->is(BookingState::SUCCESSFULL)); 80 | $this->assertFalse($booking->state->is(BookingState::FAILED)); 81 | } 82 | 83 | /** @test */ 84 | public function test_state_is_any_of_method() 85 | { 86 | $booking = Booking::create(); 87 | $this->assertTrue($booking->state->isAnyOf([ 88 | BookingState::PENDING, 89 | BookingState::SUCCESSFULL, 90 | ])); 91 | $this->assertFalse($booking->state->isAnyOf([ 92 | BookingState::FAILED, 93 | BookingState::SUCCESSFULL, 94 | ])); 95 | } 96 | 97 | /** @test */ 98 | public function test_state_was_method() 99 | { 100 | $booking = Booking::create(); 101 | $booking->state->transition(BookingStateTransition::PAYMENT_PAID); 102 | $this->assertTrue($booking->state->was(BookingState::SUCCESSFULL)); 103 | $this->assertFalse($booking->state->was(BookingState::FAILED)); 104 | } 105 | 106 | /** @test */ 107 | public function test_state_was_method_for_initial_state() 108 | { 109 | $booking = Booking::create(); 110 | $this->assertTrue($booking->state->was(BookingState::INITIAL_STATE)); 111 | $this->assertFalse($booking->state->was(BookingState::SUCCESSFULL)); 112 | $this->assertFalse($booking->state->was(BookingState::FAILED)); 113 | } 114 | 115 | /** @test */ 116 | public function test_withCurrentState_method() 117 | { 118 | $booking = Booking::create(); 119 | $state = $booking->states()->create([ 120 | 'type' => 'state', 121 | 'state' => 'foo', 122 | 'from' => 'bar', 123 | 'transition' => 'bar_foo', 124 | ]); 125 | 126 | $booking = Booking::withCurrentState()->first(); 127 | $this->assertTrue($booking->relationLoaded('current_state')); 128 | $this->assertSame($state->id, $booking->getRelation('current_state')->id); 129 | $this->assertSame('foo', $booking->getRelation('current_state')->state); 130 | } 131 | 132 | /** @test */ 133 | public function test_loadCurrentState_method() 134 | { 135 | $booking = Booking::create(); 136 | $state = $booking->states()->create([ 137 | 'type' => 'state', 138 | 'state' => 'foo', 139 | 'from' => 'bar', 140 | 'transition' => 'bar_foo', 141 | ]); 142 | 143 | $this->assertFalse($booking->relationLoaded('current_state')); 144 | $booking->loadCurrentState(); 145 | $this->assertTrue($booking->relationLoaded('current_state')); 146 | $this->assertSame($state->id, $booking->getRelation('current_state')->id); 147 | $this->assertSame('foo', $booking->getRelation('current_state')->state); 148 | } 149 | 150 | /** @test */ 151 | public function test_getCurrentStateRelationName_method() 152 | { 153 | $booking = new Booking(); 154 | $this->assertSame('current_state', $booking->getCurrentStateRelationName()); 155 | $this->assertSame('current_state', $booking->getCurrentStateRelationName('state')); 156 | $this->assertSame('current_payment_state', $booking->getCurrentStateRelationName('payment_state')); 157 | } 158 | 159 | /** @test */ 160 | public function test_whereStateIs_query_scope() 161 | { 162 | $booking1 = Booking::create(); 163 | $booking2 = Booking::create(); 164 | $booking3 = Booking::create(); 165 | $booking1->state->transition(BookingStateTransition::PAYMENT_PAID); 166 | 167 | $this->assertSame(2, Booking::whereStateIs('state', BookingState::INITIAL_STATE)->count()); 168 | $this->assertSame(0, Booking::whereStateIs('state', BookingState::FAILED)->count()); 169 | $this->assertSame(1, Booking::whereStateIs('state', BookingState::SUCCESSFULL)->count()); 170 | $this->assertSame($booking1->id, Booking::whereStateIs('state', BookingState::SUCCESSFULL)->first()->id); 171 | } 172 | 173 | /** @test */ 174 | public function test_OrWhereStateIs_query_scope() 175 | { 176 | $booking1 = Booking::create(); 177 | $booking2 = Booking::create(); 178 | $booking3 = Booking::create(); 179 | $booking1->state->transition(BookingStateTransition::PAYMENT_PAID); 180 | 181 | $this->assertSame(2, Booking::where('id', $booking2->id)->OrWhereStateIs('state', BookingState::SUCCESSFULL)->count()); 182 | } 183 | 184 | /** @test */ 185 | public function test_whereStateIsNot_query_scope() 186 | { 187 | $booking1 = Booking::create(); 188 | $booking2 = Booking::create(); 189 | $booking3 = Booking::create(); 190 | $booking1->state->transition(BookingStateTransition::PAYMENT_PAID); 191 | 192 | // dd( 193 | // $booking1->state->current(), 194 | // $booking2->state->current(), 195 | // $booking3->state->current(), 196 | // Booking::whereStateIsNot('state', BookingState::INITIAL_STATE)->count() 197 | // ); 198 | 199 | $this->assertSame(3, Booking::whereStateIsNot('state', BookingState::FAILED)->count()); 200 | $this->assertSame(2, Booking::whereStateIsNot('state', BookingState::SUCCESSFULL)->count()); 201 | $this->assertSame(0, Booking::whereStateIsNot('state', [BookingState::SUCCESSFULL, BookingState::INITIAL_STATE])->count()); 202 | $this->assertSame(2, Booking::whereStateIsNot('state', [BookingState::SUCCESSFULL, BookingState::FAILED])->count()); 203 | $this->assertSame(1, Booking::whereStateIsNot('state', BookingState::INITIAL_STATE)->count()); 204 | $this->assertSame($booking1->id, Booking::whereStateIsNot('state', BookingState::INITIAL_STATE)->first()->id); 205 | } 206 | 207 | /** @test */ 208 | public function test_orWhereStateIsNot_query_scope() 209 | { 210 | $booking1 = Booking::create(); 211 | $booking2 = Booking::create(); 212 | $booking3 = Booking::create(); 213 | $booking1->state->transition(BookingStateTransition::PAYMENT_PAID); 214 | 215 | $this->assertSame(1, Booking::where('id', $booking1->id)->OrWhereStateIsNot('state', BookingState::INITIAL_STATE)->count()); 216 | $this->assertSame(2, Booking::where('id', $booking2->id)->OrWhereStateIsNot('state', BookingState::INITIAL_STATE)->count()); 217 | } 218 | } 219 | 220 | class BookingStateTransition 221 | { 222 | const PAYMENT_PAID = 'payment_paid'; 223 | const PAYMENT_FAILED = 'payment_failed'; 224 | } 225 | 226 | class BookingState extends State 227 | { 228 | const PENDING = 'pending'; 229 | const FAILED = 'failed'; 230 | const SUCCESSFULL = 'successfull'; 231 | 232 | const INITIAL_STATE = self::PENDING; 233 | 234 | public static function config() 235 | { 236 | self::set(BookingStateTransition::PAYMENT_PAID) 237 | ->from(self::PENDING) 238 | ->to(self::SUCCESSFULL); 239 | self::set(BookingStateTransition::PAYMENT_FAILED) 240 | ->from(self::PENDING) 241 | ->to(self::FAILED); 242 | } 243 | } 244 | 245 | class Booking extends Model implements Stateful 246 | { 247 | use HasStates; 248 | 249 | public $timestamps = false; 250 | 251 | protected $states = [ 252 | 'state' => BookingState::class, 253 | ]; 254 | } 255 | -------------------------------------------------------------------------------- /tests/StateTest.php: -------------------------------------------------------------------------------- 1 | assertEquals([ 13 | 'foo', 'bar', 14 | ], DummyState::all()); 15 | } 16 | 17 | public function test_uniqueTransitions_method() 18 | { 19 | $this->assertEquals([ 20 | 'hello_world', 21 | ], DummyState::uniqueTransitions()); 22 | } 23 | 24 | public function test_whereCan_method() 25 | { 26 | $this->assertEquals([ 27 | DummyState::FOO, 28 | ], DummyState::whereCan('hello_world')); 29 | } 30 | 31 | public function test_canTransitionFrom_method() 32 | { 33 | $this->assertTrue(DummyState::canTransitionFrom('foo', 'hello_world')); 34 | $this->assertFalse(DummyState::canTransitionFrom('bar', 'hello_world')); 35 | $this->assertFalse(DummyState::canTransitionFrom('foo', 'bar')); 36 | } 37 | } 38 | 39 | class DummyState extends State 40 | { 41 | const FOO = 'foo'; 42 | const BAR = 'bar'; 43 | 44 | const INITIAL_STATE = self::FOO; 45 | const FINAL_STATES = [self::BAR]; 46 | 47 | public static function config() 48 | { 49 | self::set('hello_world')->from(self::FOO)->to(self::BAR); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | set('database.default', 'testbench'); 20 | $app['config']->set('database.connections.testbench', [ 21 | 'driver' => 'sqlite', 22 | 'database' => ':memory:', 23 | 'prefix' => '', 24 | ]); 25 | } 26 | 27 | protected function getPackageProviders($app) 28 | { 29 | return [StatesServiceProvider::class]; 30 | } 31 | } 32 | --------------------------------------------------------------------------------