├── LICENSE.md ├── README.md ├── composer.json ├── config └── model-states.php └── src ├── Attributes ├── AllowTransition.php ├── AttributeLoader.php ├── DefaultState.php ├── IgnoreSameState.php └── RegisterState.php ├── DefaultTransition.php ├── Events └── StateChanged.php ├── Exceptions ├── ClassDoesNotExtendBaseClass.php ├── CouldNotPerformTransition.php ├── CouldNotResolveTransitionField.php ├── FieldDoesNotExtendState.php ├── InvalidConfig.php ├── MissingTraitOnModel.php ├── TransitionNotAllowed.php ├── TransitionNotFound.php └── UnknownState.php ├── HasStates.php ├── HasStatesContract.php ├── ModelStatesServiceProvider.php ├── State.php ├── StateCaster.php ├── StateConfig.php ├── Transition.php └── Validation └── ValidStateRule.php /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Spatie bvba 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | Logo for laravel-model-states 6 | 7 | 8 | 9 |

Adding state behaviour to Eloquent models

10 | 11 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/spatie/laravel-model-states.svg?style=flat-square)](https://packagist.org/packages/spatie/laravel-model-states) 12 | [![Run tests](https://github.com/spatie/laravel-model-states/actions/workflows/run-tests.yml/badge.svg)](https://github.com/spatie/laravel-model-states/actions/workflows/run-tests.yml) 13 | [![Check & fix styling](https://github.com/spatie/laravel-model-states/actions/workflows/php-cs-fixer.yml/badge.svg)](https://github.com/spatie/laravel-model-states/actions/workflows/php-cs-fixer.yml) 14 | [![Total Downloads](https://img.shields.io/packagist/dt/spatie/laravel-model-states.svg?style=flat-square)](https://packagist.org/packages/spatie/laravel-model-states) 15 | 16 |
17 | 18 | This package adds state support to models. It combines concepts from the [state pattern](https://en.wikipedia.org/wiki/State_pattern) and [state machines](https://www.youtube.com/watch?v=N12L5D78MAA). 19 | 20 | It is recommended that you're familiar with both patterns if you're going to use this package. 21 | 22 | To give you a feel about how this package can be used, let's look at a quick example. 23 | 24 | Imagine a model `Payment`, which has three possible states: `Pending`, `Paid` and `Failed`. This package allows you to represent each state as a separate class, handles serialization of states to the database behind the scenes, and allows for easy state transitions. 25 | 26 | For the sake of our example, let's say that, depending on the state, the color of a payment should differ. 27 | 28 | Here's what the `Payment` model would look like: 29 | 30 | ```php 31 | use Spatie\ModelStates\HasStates; 32 | 33 | class Payment extends Model 34 | { 35 | use HasStates; 36 | 37 | protected $casts = [ 38 | 'state' => PaymentState::class, 39 | ]; 40 | } 41 | ``` 42 | 43 | This is what the abstract `PaymentState` class would look like: 44 | 45 | ```php 46 | use Spatie\ModelStates\State; 47 | use Spatie\ModelStates\StateConfig; 48 | 49 | abstract class PaymentState extends State 50 | { 51 | abstract public function color(): string; 52 | 53 | public static function config(): StateConfig 54 | { 55 | return parent::config() 56 | ->default(Pending::class) 57 | ->allowTransition(Pending::class, Paid::class) 58 | ->allowTransition(Pending::class, Failed::class) 59 | ; 60 | } 61 | } 62 | ``` 63 | 64 | Here's a concrete implementation of one state, the `Paid` state: 65 | 66 | ```php 67 | class Paid extends PaymentState 68 | { 69 | public function color(): string 70 | { 71 | return 'green'; 72 | } 73 | } 74 | ``` 75 | 76 | And here's how it is used: 77 | 78 | ```php 79 | $payment = Payment::find(1); 80 | 81 | $payment->state->transitionTo(Paid::class); 82 | 83 | echo $payment->state->color(); 84 | ``` 85 | 86 | ## Support us 87 | 88 | [](https://spatie.be/github-ad-click/laravel-model-states) 89 | 90 | We invest a lot of resources into creating [best in class open source packages](https://spatie.be/open-source). You can support us by [buying one of our paid products](https://spatie.be/open-source/support-us). 91 | 92 | We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. You'll find our address on [our contact page](https://spatie.be/about-us). We publish all received postcards on [our virtual postcard wall](https://spatie.be/open-source/postcards). 93 | 94 | ## Installation 95 | 96 | You can install the package via composer: 97 | 98 | ```bash 99 | composer require spatie/laravel-model-states 100 | ``` 101 | 102 | You can publish the config file with: 103 | ```bash 104 | php artisan vendor:publish --provider="Spatie\ModelStates\ModelStatesServiceProvider" --tag="model-states-config" 105 | ``` 106 | 107 | This is the content of the published config file: 108 | 109 | ```php 110 | return [ 111 | 112 | /* 113 | * The fully qualified class name of the default transition. 114 | */ 115 | 'default_transition' => Spatie\ModelStates\DefaultTransition::class, 116 | 117 | ]; 118 | ``` 119 | 120 | ## Usage 121 | 122 | Please refer to the [docs](https://spatie.be/docs/laravel-model-states/v2/01-introduction/) to learn how to use this package. 123 | 124 | ## Testing 125 | 126 | ``` bash 127 | composer test 128 | ``` 129 | 130 | ## Changelog 131 | 132 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 133 | 134 | ## Contributing 135 | 136 | Please see [CONTRIBUTING](https://github.com/spatie/.github/blob/main/CONTRIBUTING.md) for details. 137 | 138 | ## Security 139 | 140 | If you've found a bug regarding security please mail [security@spatie.be](mailto:security@spatie.be) instead of using the issue tracker. 141 | 142 | ## Credits 143 | 144 | - [Brent Roose](https://github.com/brendt) 145 | - [All Contributors](../../contributors) 146 | 147 | ## License 148 | 149 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 150 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spatie/laravel-model-states", 3 | "description": "State support for Eloquent models", 4 | "keywords": [ 5 | "spatie", 6 | "state" 7 | ], 8 | "homepage": "https://github.com/spatie/laravel-model-states", 9 | "license": "MIT", 10 | "authors": [ 11 | { 12 | "name": "Brent Roose", 13 | "email": "brent@spatie.be", 14 | "homepage": "https://spatie.be", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "^7.4|^8.0", 20 | "ext-json": "*", 21 | "facade/ignition-contracts": "^1.0", 22 | "illuminate/contracts": "^10.0 | ^11.0 | ^12.0", 23 | "illuminate/database": "^10.0 | ^11.0 | ^12.0", 24 | "illuminate/support": "^10.0 | ^11.0 | ^12.0", 25 | "spatie/laravel-package-tools": "^1.9", 26 | "spatie/php-structure-discoverer": "^2.2" 27 | }, 28 | "require-dev": { 29 | "orchestra/testbench": "^8.0 | ^9.0 | ^10.0", 30 | "pestphp/pest": "^2.0|^3.0", 31 | "phpunit/phpunit": "^10.0|^11.0|^12.0", 32 | "symfony/var-dumper": "^6.0 | ^7.0" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "Spatie\\ModelStates\\": "src" 37 | } 38 | }, 39 | "autoload-dev": { 40 | "psr-4": { 41 | "Spatie\\ModelStates\\Tests\\": "tests" 42 | } 43 | }, 44 | "scripts": { 45 | "test": "vendor/bin/pest", 46 | "test-coverage": "vendor/bin/pest --coverage" 47 | }, 48 | "config": { 49 | "sort-packages": true, 50 | "allow-plugins": { 51 | "pestphp/pest-plugin": true 52 | } 53 | }, 54 | "extra": { 55 | "laravel": { 56 | "providers": [ 57 | "Spatie\\ModelStates\\ModelStatesServiceProvider" 58 | ] 59 | } 60 | }, 61 | "minimum-stability": "dev", 62 | "prefer-stable": true 63 | } 64 | -------------------------------------------------------------------------------- /config/model-states.php: -------------------------------------------------------------------------------- 1 | Spatie\ModelStates\DefaultTransition::class, 9 | 10 | ]; 11 | -------------------------------------------------------------------------------- /src/Attributes/AllowTransition.php: -------------------------------------------------------------------------------- 1 | reflectionClass = new ReflectionClass($stateClass); 15 | } 16 | 17 | public function load(StateConfig $stateConfig): StateConfig 18 | { 19 | $transitionAttributes = $this->reflectionClass->getAttributes(AllowTransition::class); 20 | 21 | foreach ($transitionAttributes as $attribute) { 22 | /** @var \Spatie\ModelStates\Attributes\AllowTransition $transitionAttribute */ 23 | $transitionAttribute = $attribute->newInstance(); 24 | 25 | $stateConfig->allowTransition( 26 | $transitionAttribute->from, 27 | $transitionAttribute->to, 28 | $transitionAttribute->transition, 29 | ); 30 | } 31 | 32 | if ($attribute = $this->reflectionClass->getAttributes(DefaultState::class)[0] ?? null) { 33 | /** @var \Spatie\ModelStates\Attributes\DefaultState $transitionAttribute */ 34 | $defaultStateAttribute = $attribute->newInstance(); 35 | 36 | $stateConfig->default($defaultStateAttribute->defaultStateClass); 37 | } 38 | 39 | if ($this->reflectionClass->getAttributes(IgnoreSameState::class)[0] ?? null) { 40 | /** @var \Spatie\ModelStates\Attributes\IgnoreSameState $transitionAttribute */ 41 | 42 | $stateConfig->ignoreSameState(); 43 | } 44 | 45 | $registerStateAttributes = $this->reflectionClass->getAttributes(RegisterState::class); 46 | 47 | foreach($registerStateAttributes as $attribute) { 48 | /** @var \Spatie\ModelStates\Attributes\RegisterState $registerStateAttribute */ 49 | $registerStateAttribute = $attribute->newInstance(); 50 | 51 | $stateConfig->registerState($registerStateAttribute->stateClass); 52 | } 53 | 54 | 55 | return $stateConfig; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Attributes/DefaultState.php: -------------------------------------------------------------------------------- 1 | model = $model; 24 | $this->field = $field; 25 | $this->newState = $newState; 26 | } 27 | 28 | /** 29 | * @return \Illuminate\Database\Eloquent\Model 30 | */ 31 | public function handle() 32 | { 33 | $originalState = $this->model->{$this->field} ? clone $this->model->{$this->field} : null; 34 | 35 | $this->model->{$this->field} = $this->newState; 36 | 37 | $this->model->save(); 38 | 39 | return $this->model; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Events/StateChanged.php: -------------------------------------------------------------------------------- 1 | initialState = $initialState; 34 | $this->finalState = $finalState; 35 | $this->transition = $transition; 36 | $this->model = $model; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Exceptions/ClassDoesNotExtendBaseClass.php: -------------------------------------------------------------------------------- 1 | setClass($class) 20 | ->setBaseClass($baseClass); 21 | } 22 | 23 | public function setClass(string $class): self 24 | { 25 | $this->class = $class; 26 | 27 | return $this; 28 | } 29 | 30 | public function setBaseClass(string $baseClass): self 31 | { 32 | $this->baseClass = $baseClass; 33 | 34 | return $this; 35 | } 36 | 37 | public function getSolution(): Solution 38 | { 39 | $documentationLinks = Str::endsWith($this->baseClass, 'State') 40 | ? ['Configuring states' => 'https://docs.spatie.be/laravel-model-states/v1/working-with-states/01-configuring-states/'] 41 | : ['Custom transition classes' => 'https://docs.spatie.be/laravel-model-states/v1/working-with-transitions/02-custom-transition-classes/']; 42 | 43 | return BaseSolution::create('') 44 | ->setSolutionDescription("Make sure that `{$this->class}` extends `{$this->baseClass}`") 45 | ->setDocumentationLinks($documentationLinks); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Exceptions/CouldNotPerformTransition.php: -------------------------------------------------------------------------------- 1 | setModelClass($modelClass); 17 | } 18 | 19 | public function setModelClass(string $modelClass): self 20 | { 21 | $this->modelClass = $modelClass; 22 | 23 | return $this; 24 | } 25 | 26 | public function getSolution(): Solution 27 | { 28 | return BaseSolution::create('Could not resolve transition field') 29 | ->setSolutionDescription("Use {$this->modelClass}->stateField->transitionTo()") 30 | ->setDocumentationLinks([ 31 | 'Using transitions' => 'https://docs.spatie.be/laravel-model-states/v1/working-with-transitions/01-configuring-transitions/#using-transitions', 32 | ]); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Exceptions/FieldDoesNotExtendState.php: -------------------------------------------------------------------------------- 1 | setField($field) 21 | ->setExpectedStateClass($expectedStateClass) 22 | ->setActualClass($actualClass); 23 | } 24 | 25 | public function setField(string $field): self 26 | { 27 | $this->field = $field; 28 | 29 | return $this; 30 | } 31 | 32 | public function setExpectedStateClass(string $expectedStateClass): self 33 | { 34 | $this->expectedStateClass = $expectedStateClass; 35 | 36 | return $this; 37 | } 38 | 39 | public function setActualClass(string $actualClass): self 40 | { 41 | $this->actualClass = $actualClass; 42 | 43 | return $this; 44 | } 45 | 46 | public function getSolution(): Solution 47 | { 48 | return BaseSolution::create("State field `{$this->field}` is the wrong type") 49 | ->setSolutionDescription("Make sure that states for state field `{$this->field}` extend `{$this->expectedStateClass}`, not `{$this->actualClass}`") 50 | ->setDocumentationLinks([ 51 | 'Configuring states' => 'https://docs.spatie.be/laravel-model-states/v1/working-with-states/01-configuring-states/', 52 | ]); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidConfig.php: -------------------------------------------------------------------------------- 1 | setModelClass($modelClass) 19 | ->setTrait($trait); 20 | } 21 | 22 | public function setModelClass(string $modelClass): self 23 | { 24 | $this->modelClass = $modelClass; 25 | 26 | return $this; 27 | } 28 | 29 | public function setTrait(string $trait): self 30 | { 31 | $this->trait = $trait; 32 | 33 | return $this; 34 | } 35 | 36 | public function getSolution(): Solution 37 | { 38 | return BaseSolution::create('Missing trait on model') 39 | ->setSolutionDescription("Use the `{$this->trait}` trait on `{$this->modelClass}`") 40 | ->setDocumentationLinks([ 41 | 'Configuring states' => 'https://docs.spatie.be/laravel-model-states/v1/working-with-states/01-configuring-states/', 42 | ]); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Exceptions/TransitionNotAllowed.php: -------------------------------------------------------------------------------- 1 | setTransitionClass($transitionClass); 17 | } 18 | 19 | public function setTransitionClass(string $transitionClass): self 20 | { 21 | $this->transitionClass = $transitionClass; 22 | 23 | return $this; 24 | } 25 | 26 | public function getSolution(): Solution 27 | { 28 | return BaseSolution::create('Transition not allowed') 29 | ->setSolutionDescription("Review your implementation of `canTransition` in {$this->transitionClass} if this is unexpected") 30 | ->setDocumentationLinks([ 31 | 'Custom transition classes' => 'https://docs.spatie.be/laravel-model-states/v1/working-with-transitions/02-custom-transition-classes/', 32 | ]); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Exceptions/TransitionNotFound.php: -------------------------------------------------------------------------------- 1 | setFrom($from) 21 | ->setTo($to) 22 | ->setModelClass($modelClass); 23 | } 24 | 25 | public function setFrom(string $from): self 26 | { 27 | $this->from = $from; 28 | 29 | return $this; 30 | } 31 | 32 | public function getFrom(): string 33 | { 34 | return $this->from; 35 | } 36 | 37 | public function setTo(string $to): self 38 | { 39 | $this->to = $to; 40 | 41 | return $this; 42 | } 43 | 44 | public function getTo(): string 45 | { 46 | return $this->to; 47 | } 48 | 49 | public function setModelClass(string $modelClass): self 50 | { 51 | $this->modelClass = $modelClass; 52 | 53 | return $this; 54 | } 55 | 56 | public function getModelClass(): string 57 | { 58 | return $this->modelClass; 59 | } 60 | 61 | public function getSolution(): Solution 62 | { 63 | return BaseSolution::create('Transition not found') 64 | ->setSolutionDescription("Register the transition in `{$this->modelClass}::registerStates()`") 65 | ->setDocumentationLinks([ 66 | 'Configuring transitions' => 'https://docs.spatie.be/laravel-model-states/v1/working-with-transitions/01-configuring-transitions/', 67 | ]); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Exceptions/UnknownState.php: -------------------------------------------------------------------------------- 1 | setField($field) 23 | ->setExpectedBaseStateClass($expectedBaseStateClass); 24 | } 25 | 26 | public function setField(string $field): self 27 | { 28 | $this->field = $field; 29 | 30 | return $this; 31 | } 32 | 33 | public function setExpectedBaseStateClass(string $expectedBaseStateClass): self 34 | { 35 | $this->expectedBaseStateClass = $expectedBaseStateClass; 36 | 37 | return $this; 38 | } 39 | 40 | public function getSolution(): Solution 41 | { 42 | return BaseSolution::create('The state field is unknown') 43 | ->setSolutionDescription("Add the `{$this->field}` field to the `config` method inside `{$this->expectedBaseStateClass}`") 44 | ->setDocumentationLinks([ 45 | 'Configuring states' => 'https://docs.spatie.be/laravel-model-states/v1/working-with-states/01-configuring-states/', 46 | ]); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/HasStates.php: -------------------------------------------------------------------------------- 1 | setStateDefaults(); 21 | }); 22 | } 23 | 24 | public function initializeHasStates(): void 25 | { 26 | $this->setStateDefaults(); 27 | } 28 | 29 | public static function getStates(): Collection 30 | { 31 | /** @var \Illuminate\Database\Eloquent\Model|\Spatie\ModelStates\HasStates $model */ 32 | $model = new static(); 33 | 34 | return collect($model->getStateConfigs()) 35 | ->map(function (StateConfig $stateConfig) { 36 | return $stateConfig->baseStateClass::getStateMapping()->keys(); 37 | }); 38 | } 39 | 40 | public static function getDefaultStates(): Collection 41 | { 42 | /** @var \Illuminate\Database\Eloquent\Model|\Spatie\ModelStates\HasStates $model */ 43 | $model = new static(); 44 | 45 | return collect($model->getStateConfigs()) 46 | ->map(function (StateConfig $stateConfig) { 47 | $defaultStateClass = $stateConfig->defaultStateClass; 48 | 49 | if ($defaultStateClass === null) { 50 | return null; 51 | } 52 | 53 | return $defaultStateClass::getMorphClass(); 54 | }); 55 | } 56 | 57 | public static function getDefaultStateFor(string $fieldName): ?string 58 | { 59 | return static::getDefaultStates()[$fieldName] ?? null; 60 | } 61 | 62 | public static function getStatesFor(string $fieldName): Collection 63 | { 64 | return collect(static::getStates()[$fieldName] ?? []); 65 | } 66 | 67 | public function scopeWhereState(Builder $builder, string $column, $states): Builder 68 | { 69 | $states = Arr::wrap($states); 70 | 71 | $field = Str::afterLast($column, '.'); 72 | 73 | return $builder->whereIn($column, $this->getStateNamesForQuery($field, $states)); 74 | } 75 | 76 | public function scopeWhereNotState(Builder $builder, string $column, $states): Builder 77 | { 78 | $states = Arr::wrap($states); 79 | 80 | $field = Str::afterLast($column, '.'); 81 | 82 | return $builder->whereNotIn($column, $this->getStateNamesForQuery($field, $states)); 83 | } 84 | 85 | public function scopeOrWhereState(Builder $builder, string $column, $states): Builder 86 | { 87 | $states = Arr::wrap($states); 88 | 89 | $field = Str::afterLast($column, '.'); 90 | 91 | return $builder->orWhereIn($column, $this->getStateNamesForQuery($field, $states)); 92 | } 93 | 94 | public function scopeOrWhereNotState(Builder $builder, string $column, $states): Builder 95 | { 96 | $states = Arr::wrap($states); 97 | 98 | $field = Str::afterLast($column, '.'); 99 | 100 | return $builder->orWhereNotIn($column, $this->getStateNamesForQuery($field, $states)); 101 | } 102 | 103 | /** 104 | * @return array|\Spatie\ModelStates\StateConfig[] 105 | */ 106 | private function getStateConfigs(): array 107 | { 108 | $casts = $this->getCasts(); 109 | 110 | $states = []; 111 | 112 | foreach ($casts as $field => $state) { 113 | if (! is_subclass_of($state, State::class)) { 114 | continue; 115 | } 116 | 117 | /** 118 | * @var \Spatie\ModelStates\State $state 119 | * @var \Illuminate\Database\Eloquent\Model $this 120 | */ 121 | $states[$field] = $state::config(); 122 | } 123 | 124 | return $states; 125 | } 126 | 127 | private function getStateNamesForQuery(string $field, array $states): Collection 128 | { 129 | /** @var \Spatie\ModelStates\StateConfig|null $stateConfig */ 130 | $stateConfig = $this->getStateConfigs()[$field]; 131 | 132 | return $stateConfig->baseStateClass::getStateMapping() 133 | ->filter(function (string $className, string $morphName) use ($states) { 134 | return in_array($className, $states) 135 | || in_array($morphName, $states); 136 | }) 137 | ->keys(); 138 | } 139 | 140 | private function setStateDefaults(): void 141 | { 142 | foreach ($this->getStateConfigs() as $field => $stateConfig) { 143 | if ($this->{$field} !== null) { 144 | continue; 145 | } 146 | 147 | if ($stateConfig->defaultStateClass === null) { 148 | continue; 149 | } 150 | 151 | $this->{$field} = $stateConfig->defaultStateClass; 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/HasStatesContract.php: -------------------------------------------------------------------------------- 1 | name('laravel-model-states') 14 | ->hasConfigFile(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/State.php: -------------------------------------------------------------------------------- 1 | model = $model; 34 | $this->stateConfig = static::config(); 35 | } 36 | 37 | public static function config(): StateConfig 38 | { 39 | $reflection = new ReflectionClass(static::class); 40 | 41 | $baseClass = $reflection->name; 42 | 43 | while ($reflection && ! $reflection->isAbstract()) { 44 | $reflection = $reflection->getParentClass(); 45 | 46 | $baseClass = $reflection->name; 47 | } 48 | 49 | $stateConfig = new StateConfig($baseClass); 50 | 51 | if (version_compare(PHP_VERSION, '8.0', '>=')) { 52 | $stateConfig = (new AttributeLoader($baseClass))->load($stateConfig); 53 | } 54 | 55 | return $stateConfig; 56 | } 57 | 58 | public static function castUsing(array $arguments) 59 | { 60 | return new StateCaster(static::class); 61 | } 62 | 63 | public static function getMorphClass(): string 64 | { 65 | return static::$name ?? static::class; 66 | } 67 | 68 | public static function getStateMapping(): Collection 69 | { 70 | if (! isset(self::$stateMapping[static::class])) { 71 | self::$stateMapping[static::class] = static::resolveStateMapping(); 72 | } 73 | 74 | return collect(self::$stateMapping[static::class]); 75 | } 76 | 77 | public static function resolveStateClass($state): ?string 78 | { 79 | if ($state === null) { 80 | return null; 81 | } 82 | 83 | if ($state instanceof State) { 84 | return get_class($state); 85 | } 86 | 87 | foreach (static::getStateMapping() as $stateClass) { 88 | if (! class_exists($stateClass)) { 89 | continue; 90 | } 91 | 92 | // Loose comparison is needed here in order to support non-string values, 93 | // Laravel casts their database value automatically to strings if we didn't specify the fields in `$casts`. 94 | $name = $stateClass::getMorphClass(); 95 | 96 | if ($name == $state) { 97 | return $stateClass; 98 | } 99 | } 100 | 101 | return $state; 102 | } 103 | 104 | /** 105 | * @param string $name 106 | * @param TModel $model 107 | * @return State 108 | */ 109 | public static function make(string $name, $model): State 110 | { 111 | $stateClass = static::resolveStateClass($name); 112 | 113 | if (! is_subclass_of($stateClass, static::class)) { 114 | throw InvalidConfig::doesNotExtendBaseClass($name, static::class); 115 | } 116 | 117 | return new $stateClass($model); 118 | } 119 | 120 | /** 121 | * @return TModel 122 | */ 123 | public function getModel() 124 | { 125 | return $this->model; 126 | } 127 | 128 | public function getField(): string 129 | { 130 | return $this->field; 131 | } 132 | 133 | /** 134 | * @return \Illuminate\Support\Collection|string[]|static[] A list of class names. 135 | */ 136 | public static function all(): Collection 137 | { 138 | return collect(self::resolveStateMapping()); 139 | } 140 | 141 | public function setField(string $field): self 142 | { 143 | $this->field = $field; 144 | 145 | return $this; 146 | } 147 | 148 | /** 149 | * @param string|State $newState 150 | * @param mixed ...$transitionArgs 151 | * @return TModel 152 | */ 153 | public function transitionTo($newState, ...$transitionArgs) 154 | { 155 | $newState = $this->resolveStateObject($newState); 156 | 157 | $from = static::getMorphClass(); 158 | 159 | $to = $newState::getMorphClass(); 160 | 161 | if (! $this->stateConfig->isTransitionAllowed($from, $to)) { 162 | throw CouldNotPerformTransition::notFound($from, $to, $this->model); 163 | } 164 | 165 | $transition = $this->resolveTransitionClass( 166 | $from, 167 | $to, 168 | $newState, 169 | ...$transitionArgs 170 | ); 171 | 172 | return $this->transition($transition); 173 | } 174 | 175 | /** 176 | * @param Transition $transition 177 | * @return TModel 178 | * @throws ClassDoesNotExtendBaseClass 179 | */ 180 | public function transition(Transition $transition) 181 | { 182 | if (method_exists($transition, 'canTransition')) { 183 | if (! $transition->canTransition()) { 184 | throw CouldNotPerformTransition::notAllowed($this->model, $transition); 185 | } 186 | } 187 | 188 | $model = app()->call([$transition, 'handle']); 189 | $model->{$this->field}->setField($this->field); 190 | 191 | $stateChangedEvent = $this->stateConfig->stateChangedEvent; 192 | 193 | if ($stateChangedEvent !== StateChanged::class && get_parent_class($stateChangedEvent) !== StateChanged::class) { 194 | throw ClassDoesNotExtendBaseClass::make($stateChangedEvent, StateChanged::class); 195 | } 196 | 197 | event(new $stateChangedEvent( 198 | $this, 199 | $model->{$this->field}, 200 | $transition, 201 | $this->model, 202 | )); 203 | 204 | return $model; 205 | } 206 | 207 | public function transitionableStates(...$transitionArgs): array 208 | { 209 | return collect($this->stateConfig->transitionableStates(static::getMorphClass()))->reject(function ($state) use ($transitionArgs) { 210 | return ! $this->canTransitionTo($state, ...$transitionArgs); 211 | })->toArray(); 212 | } 213 | 214 | public function canTransitionTo($newState, ...$transitionArgs): bool 215 | { 216 | $newState = $this->resolveStateObject($newState); 217 | 218 | $from = static::getMorphClass(); 219 | 220 | $to = $newState::getMorphClass(); 221 | 222 | if (! $this->stateConfig->isTransitionAllowed($from, $to)) { 223 | return false; 224 | } 225 | 226 | $transition = $this->resolveTransitionClass( 227 | $from, 228 | $to, 229 | $newState, 230 | ...$transitionArgs 231 | ); 232 | 233 | if (method_exists($transition, 'canTransition')) { 234 | return $transition->canTransition(); 235 | } 236 | 237 | return true; 238 | } 239 | 240 | public function getValue(): string 241 | { 242 | return static::getMorphClass(); 243 | } 244 | 245 | public function equals(...$otherStates): bool 246 | { 247 | foreach ($otherStates as $otherState) { 248 | $otherState = $this->resolveStateObject($otherState); 249 | 250 | if ($this->stateConfig->baseStateClass === $otherState->stateConfig->baseStateClass 251 | && $this->getValue() === $otherState->getValue()) { 252 | return true; 253 | } 254 | } 255 | 256 | return false; 257 | } 258 | 259 | #[\ReturnTypeWillChange] 260 | public function jsonSerialize() 261 | { 262 | return $this->getValue(); 263 | } 264 | 265 | public function __toString(): string 266 | { 267 | return $this->getValue(); 268 | } 269 | 270 | private function resolveStateObject($state): self 271 | { 272 | if (is_object($state) && is_subclass_of($state, $this->stateConfig->baseStateClass)) { 273 | return $state; 274 | } 275 | 276 | $stateClassName = $this->stateConfig->baseStateClass::resolveStateClass($state); 277 | 278 | return new $stateClassName($this->model, $this->stateConfig); 279 | } 280 | 281 | private function resolveTransitionClass( 282 | string $from, 283 | string $to, 284 | State $newState, 285 | ...$transitionArgs 286 | ): Transition { 287 | $transitionClass = $this->stateConfig->resolveTransitionClass($from, $to); 288 | 289 | if ($transitionClass === null) { 290 | $defaultTransition = config('model-states.default_transition', DefaultTransition::class); 291 | 292 | $transition = new $defaultTransition( 293 | $this->model, 294 | $this->field, 295 | $newState, 296 | ...$transitionArgs 297 | ); 298 | } else { 299 | $transition = new $transitionClass($this->model, ...$transitionArgs); 300 | } 301 | 302 | return $transition; 303 | } 304 | 305 | private static function resolveStateMapping(): array 306 | { 307 | $reflection = new ReflectionClass(static::class); 308 | 309 | ['dirname' => $directory] = pathinfo($reflection->getFileName()); 310 | 311 | $files = scandir($directory); 312 | 313 | $namespace = $reflection->getNamespaceName(); 314 | 315 | $resolvedStates = []; 316 | 317 | $stateConfig = static::config(); 318 | 319 | foreach ($files as $file) { 320 | if ($file === '.' || $file === '..') { 321 | continue; 322 | } 323 | 324 | ['filename' => $className] = pathinfo($file); 325 | 326 | /** @var \Spatie\ModelStates\State|mixed $stateClass */ 327 | $stateClass = $namespace . '\\' . $className; 328 | 329 | if (! is_subclass_of($stateClass, $stateConfig->baseStateClass)) { 330 | continue; 331 | } 332 | 333 | $resolvedStates[$stateClass::getMorphClass()] = $stateClass; 334 | } 335 | 336 | foreach ($stateConfig->registeredStates as $stateClass) { 337 | $resolvedStates[$stateClass::getMorphClass()] = $stateClass; 338 | } 339 | 340 | return $resolvedStates; 341 | } 342 | } 343 | -------------------------------------------------------------------------------- /src/StateCaster.php: -------------------------------------------------------------------------------- 1 | baseStateClass = $baseStateClass; 18 | } 19 | 20 | public function get($model, string $key, $value, array $attributes) 21 | { 22 | if ($value === null) { 23 | return null; 24 | } 25 | 26 | $mapping = $this->getStateMapping(); 27 | 28 | $stateClassName = $mapping[$value]; 29 | 30 | /** @var \Spatie\ModelStates\State $state */ 31 | $state = new $stateClassName($model); 32 | 33 | $state->setField($key); 34 | 35 | return $state; 36 | } 37 | 38 | /** 39 | * @param \Illuminate\Database\Eloquent\Model $model 40 | * @param string $key 41 | * @param \Spatie\ModelStates\State|string $value 42 | * @param array $attributes 43 | * 44 | * @return string 45 | */ 46 | public function set($model, string $key, $value, array $attributes): ?string 47 | { 48 | if ($value === null) { 49 | return null; 50 | } 51 | 52 | if (! is_subclass_of($value, $this->baseStateClass)) { 53 | $mapping = $this->getStateMapping(); 54 | 55 | if (! isset($mapping[$value])) { 56 | throw UnknownState::make( 57 | $value, 58 | $this->baseStateClass, 59 | get_class($model), 60 | $key 61 | ); 62 | } 63 | 64 | $value = $mapping[$value]; 65 | } 66 | 67 | if ($value instanceof $this->baseStateClass) { 68 | $value->setField($key); 69 | } 70 | 71 | return $value::getMorphClass(); 72 | } 73 | 74 | /** 75 | * @param \Illuminate\Database\Eloquent\Model $model 76 | * @param string $key 77 | * @param mixed $value 78 | * @param array $attributes 79 | * 80 | * @return mixed 81 | */ 82 | public function serialize($model, string $key, $value, array $attributes) 83 | { 84 | return $value instanceof State ? $value->jsonSerialize() : $value; 85 | } 86 | 87 | private function getStateMapping(): Collection 88 | { 89 | return $this->baseStateClass::getStateMapping(); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/StateConfig.php: -------------------------------------------------------------------------------- 1 | > */ 13 | public string $baseStateClass; 14 | 15 | /** @var class-string<\Spatie\ModelStates\State<\Illuminate\Database\Eloquent\Model>>|null */ 16 | public ?string $defaultStateClass = null; 17 | 18 | /** @var array> */ 19 | public array $allowedTransitions = []; 20 | 21 | /** @var string[] */ 22 | public array $registeredStates = []; 23 | 24 | /** @var bool */ 25 | public bool $shouldIgnoreSameState = false; 26 | 27 | public string $stateChangedEvent = StateChanged::class; 28 | 29 | public function __construct( 30 | string $baseStateClass 31 | ) { 32 | $this->baseStateClass = $baseStateClass; 33 | } 34 | 35 | public function default(string $defaultStateClass): StateConfig 36 | { 37 | $this->defaultStateClass = $defaultStateClass; 38 | 39 | return $this; 40 | } 41 | 42 | public function ignoreSameState(): StateConfig 43 | { 44 | $this->shouldIgnoreSameState = true; 45 | 46 | return $this; 47 | } 48 | 49 | public function allowTransition($from, string $to, ?string $transition = null): StateConfig 50 | { 51 | if (is_array($from)) { 52 | foreach ($from as $fromState) { 53 | $this->allowTransition($fromState, $to, $transition); 54 | } 55 | 56 | return $this; 57 | } 58 | 59 | if (! is_subclass_of($from, $this->baseStateClass)) { 60 | throw InvalidConfig::doesNotExtendBaseClass($from, $this->baseStateClass); 61 | } 62 | 63 | if (! is_subclass_of($to, $this->baseStateClass)) { 64 | throw InvalidConfig::doesNotExtendBaseClass($to, $this->baseStateClass); 65 | } 66 | 67 | if ($transition && ! is_subclass_of($transition, Transition::class)) { 68 | throw InvalidConfig::doesNotExtendTransition($transition); 69 | } 70 | 71 | $this->allowedTransitions[$this->createTransitionKey($from, $to)] = $transition; 72 | 73 | return $this; 74 | } 75 | 76 | public function allowTransitions(array $transitions): StateConfig 77 | { 78 | foreach ($transitions as $transition) { 79 | $this->allowTransition($transition[0], $transition[1], $transition[2] ?? null); 80 | } 81 | 82 | return $this; 83 | } 84 | 85 | public function isTransitionAllowed(string $fromMorphClass, string $toMorphClass): bool 86 | { 87 | if($this->shouldIgnoreSameState && $fromMorphClass === $toMorphClass){ 88 | return true; 89 | } 90 | 91 | $transitionKey = $this->createTransitionKey($fromMorphClass, $toMorphClass); 92 | 93 | return array_key_exists($transitionKey, $this->allowedTransitions); 94 | } 95 | 96 | public function resolveTransitionClass(string $fromMorphClass, string $toMorphClass): ?string 97 | { 98 | $transitionKey = $this->createTransitionKey($fromMorphClass, $toMorphClass); 99 | 100 | if(array_key_exists($transitionKey, $this->allowedTransitions)) { 101 | return $this->allowedTransitions[$transitionKey]; 102 | } 103 | 104 | return null; 105 | } 106 | 107 | public function transitionableStates(string $fromMorphClass): array 108 | { 109 | $transitionableStates = []; 110 | 111 | foreach ($this->allowedTransitions as $allowedTransition => $value) { 112 | [$transitionFromMorphClass, $transitionToMorphClass] = explode('-', $allowedTransition); 113 | 114 | if ($transitionFromMorphClass !== $fromMorphClass) { 115 | continue; 116 | } 117 | 118 | $transitionableStates[] = $transitionToMorphClass; 119 | } 120 | 121 | return $transitionableStates; 122 | } 123 | 124 | public function registerState($stateClass): StateConfig 125 | { 126 | if (is_array($stateClass)) { 127 | foreach ($stateClass as $state) { 128 | $this->registerState($state); 129 | } 130 | 131 | return $this; 132 | } 133 | 134 | if (!is_subclass_of($stateClass, $this->baseStateClass)) { 135 | throw InvalidConfig::doesNotExtendBaseClass($stateClass, $this->baseStateClass); 136 | } 137 | 138 | $this->registeredStates[] = $stateClass; 139 | 140 | return $this; 141 | } 142 | 143 | public function stateChangedEvent(string $event): StateConfig 144 | { 145 | $this->stateChangedEvent = $event; 146 | 147 | return $this; 148 | } 149 | 150 | /** 151 | * @throws InvalidConfig 152 | */ 153 | public function allowAllTransitions(): StateConfig 154 | { 155 | $this->registerBaseStateClassDirectoryStates(); 156 | 157 | if (empty($this->registeredStates)) { 158 | throw new InvalidConfig('No states registered for ' . $this->baseStateClass); 159 | } 160 | 161 | $this->allowTransitions(collect($this->registeredStates)->crossJoin($this->registeredStates)->toArray()); 162 | 163 | return $this; 164 | } 165 | 166 | private function registerBaseStateClassDirectoryStates(): void 167 | { 168 | $reflector = new ReflectionClass($this->baseStateClass); 169 | $filename = $reflector->getFileName(); 170 | $baseStateClassDirectory = dirname($filename); 171 | 172 | $stateClasses = Discover::in($baseStateClassDirectory) 173 | ->classes() 174 | ->extending($this->baseStateClass) 175 | ->get(); 176 | 177 | $this->registerState($stateClasses); 178 | } 179 | 180 | /** 181 | * Register all state classes from one or more custom directories. 182 | * 183 | * @param string ...$directories 184 | * @return $this 185 | */ 186 | public function registerStatesFromDirectory(string ...$directories): StateConfig 187 | { 188 | $stateClasses = Discover::in(...$directories) 189 | ->classes() 190 | ->extending($this->baseStateClass) 191 | ->get(); 192 | 193 | $this->registerState($stateClasses); 194 | 195 | return $this; 196 | } 197 | 198 | /** 199 | * @param string|\Spatie\ModelStates\State $from 200 | * @param string|\Spatie\ModelStates\State $to 201 | * 202 | * @return string 203 | */ 204 | private function createTransitionKey(string $from, string $to): string 205 | { 206 | if (is_subclass_of($from, $this->baseStateClass)) { 207 | $from = $from::getMorphClass(); 208 | } 209 | 210 | if (is_subclass_of($to, $this->baseStateClass)) { 211 | $to = $to::getMorphClass(); 212 | } 213 | 214 | return "{$from}-{$to}"; 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /src/Transition.php: -------------------------------------------------------------------------------- 1 | baseStateClass = $abstractStateClass; 22 | } 23 | 24 | public function nullable(): ValidStateRule 25 | { 26 | $this->nullable = true; 27 | 28 | return $this; 29 | } 30 | 31 | public function required(): ValidStateRule 32 | { 33 | $this->nullable = false; 34 | 35 | return $this; 36 | } 37 | 38 | public function validate(string $attribute, mixed $value, Closure $fail): void 39 | { 40 | if ($this->nullable && $value === null) { 41 | return; 42 | } 43 | 44 | $stateClass = $this->baseStateClass::resolveStateClass($value); 45 | 46 | if(! is_subclass_of($stateClass, $this->baseStateClass)) { 47 | $fail('This value is invalid'); 48 | } 49 | } 50 | } 51 | --------------------------------------------------------------------------------