From c84dd163d384c09444272f391df2cf21b3784a65 Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Sun, 22 Feb 2026 19:29:53 -0600 Subject: [PATCH 1/3] State machine --- composer.json | 1 - src/ChildWorkflow.php | 3 +- src/ChildWorkflowStub.php | 3 +- src/Exception.php | 3 +- src/Exceptions/TransitionNotFound.php | 42 ++++ src/Middleware/ActivityMiddleware.php | 3 +- src/Models/StoredWorkflow.php | 2 +- src/Signal.php | 3 +- src/States/HasStates.php | 142 +++++++++++ src/States/State.php | 264 ++++++++++++++++++++ src/States/StateCaster.php | 76 ++++++ src/States/StateConfig.php | 149 +++++++++++ src/States/StateMachine.php | 99 ++++++++ src/States/WorkflowStatus.php | 3 - src/Workflow.php | 5 +- src/WorkflowStub.php | 5 +- tests/Fixtures/StateMachine.php | 50 ---- tests/Fixtures/TestStateMachineWorkflow.php | 1 + tests/Unit/ChildWorkflowStubTest.php | 2 +- 19 files changed, 791 insertions(+), 65 deletions(-) create mode 100644 src/Exceptions/TransitionNotFound.php create mode 100644 src/States/HasStates.php create mode 100644 src/States/State.php create mode 100644 src/States/StateCaster.php create mode 100644 src/States/StateConfig.php create mode 100644 src/States/StateMachine.php delete mode 100644 tests/Fixtures/StateMachine.php diff --git a/composer.json b/composer.json index b08aa9b8..7ec379e2 100644 --- a/composer.json +++ b/composer.json @@ -51,7 +51,6 @@ "require": { "php": "^8.1", "laravel/framework": "^9.0|^10.0|^11.0|^12.0", - "spatie/laravel-model-states": "^2.0", "react/promise": "^2.9|^3.0" }, "require-dev": { diff --git a/src/ChildWorkflow.php b/src/ChildWorkflow.php index 23e8336a..3d0780ec 100644 --- a/src/ChildWorkflow.php +++ b/src/ChildWorkflow.php @@ -11,6 +11,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Workflow\Exceptions\TransitionNotFound; use Workflow\Middleware\WithoutOverlappingMiddleware; use Workflow\Models\StoredWorkflow; @@ -59,7 +60,7 @@ public function handle() } else { $workflow->next($this->index, $this->now, $this->storedWorkflow->class, $this->return); } - } catch (\Spatie\ModelStates\Exceptions\TransitionNotFound) { + } catch (TransitionNotFound) { if ($workflow->running()) { $this->release(); } diff --git a/src/ChildWorkflowStub.php b/src/ChildWorkflowStub.php index 36e70bb9..6599e6c6 100644 --- a/src/ChildWorkflowStub.php +++ b/src/ChildWorkflowStub.php @@ -8,6 +8,7 @@ use React\Promise\Deferred; use React\Promise\PromiseInterface; use function React\Promise\resolve; +use Workflow\Exceptions\TransitionNotFound; use Workflow\Serializers\Serializer; final class ChildWorkflowStub @@ -61,7 +62,7 @@ public static function make($workflow, ...$arguments): PromiseInterface if ($childWorkflow->running() && ! $childWorkflow->created()) { try { $childWorkflow->resume(); - } catch (\Spatie\ModelStates\Exceptions\TransitionNotFound) { + } catch (TransitionNotFound) { // already running } } elseif (! $childWorkflow->completed()) { diff --git a/src/Exception.php b/src/Exception.php index 3cfda11b..1785ec67 100644 --- a/src/Exception.php +++ b/src/Exception.php @@ -10,6 +10,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Workflow\Exceptions\TransitionNotFound; use Workflow\Middleware\WithoutOverlappingMiddleware; use Workflow\Models\StoredWorkflow; @@ -52,7 +53,7 @@ public function handle() } else { $workflow->next($this->index, $this->now, self::class, $this->exception); } - } catch (\Spatie\ModelStates\Exceptions\TransitionNotFound) { + } catch (TransitionNotFound) { if ($workflow->running()) { $this->release(); } diff --git a/src/Exceptions/TransitionNotFound.php b/src/Exceptions/TransitionNotFound.php new file mode 100644 index 00000000..c324184c --- /dev/null +++ b/src/Exceptions/TransitionNotFound.php @@ -0,0 +1,42 @@ +from = $from; + $exception->to = $to; + $exception->modelClass = $modelClass; + + return $exception; + } + + public function getFrom(): string + { + return $this->from; + } + + public function getTo(): string + { + return $this->to; + } + + public function getModelClass(): string + { + return $this->modelClass; + } +} diff --git a/src/Middleware/ActivityMiddleware.php b/src/Middleware/ActivityMiddleware.php index 3e4d7ed4..4ee91262 100644 --- a/src/Middleware/ActivityMiddleware.php +++ b/src/Middleware/ActivityMiddleware.php @@ -10,6 +10,7 @@ use Workflow\Events\ActivityCompleted; use Workflow\Events\ActivityFailed; use Workflow\Events\ActivityStarted; +use Workflow\Exceptions\TransitionNotFound; final class ActivityMiddleware { @@ -73,7 +74,7 @@ public function onUnlock(bool $shouldSignal): void } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $throwable) { $this->job->storedWorkflow->toWorkflow() ->fail($throwable); - } catch (\Spatie\ModelStates\Exceptions\TransitionNotFound) { + } catch (TransitionNotFound) { if ($this->job->storedWorkflow->toWorkflow()->running()) { $this->job->release(); } diff --git a/src/Models/StoredWorkflow.php b/src/Models/StoredWorkflow.php index cb91200d..abbac348 100644 --- a/src/Models/StoredWorkflow.php +++ b/src/Models/StoredWorkflow.php @@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Prunable; use Illuminate\Database\Eloquent\Relations\BelongsToMany; -use Spatie\ModelStates\HasStates; +use Workflow\States\HasStates; use Workflow\States\WorkflowContinuedStatus; use Workflow\States\WorkflowStatus; use Workflow\WorkflowStub; diff --git a/src/Signal.php b/src/Signal.php index 531719b2..bfcd6ed8 100644 --- a/src/Signal.php +++ b/src/Signal.php @@ -10,6 +10,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Workflow\Exceptions\TransitionNotFound; use Workflow\Middleware\WithoutOverlappingMiddleware; use Workflow\Models\StoredWorkflow; @@ -53,7 +54,7 @@ public function handle(): void try { $workflow->resume(); - } catch (\Spatie\ModelStates\Exceptions\TransitionNotFound) { + } catch (TransitionNotFound) { if ($workflow->running()) { $this->release(); } diff --git a/src/States/HasStates.php b/src/States/HasStates.php new file mode 100644 index 00000000..56378095 --- /dev/null +++ b/src/States/HasStates.php @@ -0,0 +1,142 @@ +setStateDefaults(); + }); + } + + public function initializeHasStates(): void + { + $this->setStateDefaults(); + } + + public static function getStates(): Collection + { + $model = new static(); + + return collect($model->getStateConfigs()) + ->map(static function (StateConfig $stateConfig) { + return $stateConfig->baseStateClass::getStateMapping()->keys(); + }); + } + + public static function getDefaultStates(): Collection + { + $model = new static(); + + return collect($model->getStateConfigs()) + ->map(static function (StateConfig $stateConfig) { + $defaultStateClass = $stateConfig->defaultStateClass; + + if ($defaultStateClass === null) { + return null; + } + + return $defaultStateClass::getMorphClass(); + }); + } + + public static function getDefaultStateFor(string $fieldName): ?string + { + return static::getDefaultStates()[$fieldName] ?? null; + } + + public static function getStatesFor(string $fieldName): Collection + { + return collect(static::getStates()[$fieldName] ?? []); + } + + public function scopeWhereState(Builder $builder, string $column, $states): Builder + { + $states = Arr::wrap($states); + $field = Str::afterLast($column, '.'); + + return $builder->whereIn($column, $this->getStateNamesForQuery($field, $states)); + } + + public function scopeWhereNotState(Builder $builder, string $column, $states): Builder + { + $states = Arr::wrap($states); + $field = Str::afterLast($column, '.'); + + return $builder->whereNotIn($column, $this->getStateNamesForQuery($field, $states)); + } + + public function scopeOrWhereState(Builder $builder, string $column, $states): Builder + { + $states = Arr::wrap($states); + $field = Str::afterLast($column, '.'); + + return $builder->orWhereIn($column, $this->getStateNamesForQuery($field, $states)); + } + + public function scopeOrWhereNotState(Builder $builder, string $column, $states): Builder + { + $states = Arr::wrap($states); + $field = Str::afterLast($column, '.'); + + return $builder->orWhereNotIn($column, $this->getStateNamesForQuery($field, $states)); + } + + /** + * @return array + */ + private function getStateConfigs(): array + { + $states = []; + + foreach ($this->getCasts() as $field => $state) { + if (! is_subclass_of($state, State::class)) { + continue; + } + + $states[$field] = $state::config(); + } + + return $states; + } + + private function getStateNamesForQuery(string $field, array $states): Collection + { + $stateConfig = $this->getStateConfigs()[$field] ?? null; + + if ($stateConfig === null) { + return collect([]); + } + + return $stateConfig->baseStateClass::getStateMapping() + ->filter(static function (string $className, string $morphName) use ($states) { + return in_array($className, $states, true) + || in_array($morphName, $states, true); + }) + ->keys(); + } + + private function setStateDefaults(): void + { + foreach ($this->getStateConfigs() as $field => $stateConfig) { + if ($this->{$field} !== null) { + continue; + } + + if ($stateConfig->defaultStateClass === null) { + continue; + } + + $this->{$field} = $stateConfig->defaultStateClass; + } + } +} diff --git a/src/States/State.php b/src/States/State.php new file mode 100644 index 00000000..74d075e0 --- /dev/null +++ b/src/States/State.php @@ -0,0 +1,264 @@ +> + */ + private static array $stateMapping = []; + + public function __construct($model) + { + $this->model = $model; + $this->stateConfig = static::config(); + } + + public function __toString(): string + { + return $this->getValue(); + } + + public static function config(): StateConfig + { + $reflection = new ReflectionClass(static::class); + $baseClass = $reflection->name; + + while ($reflection instanceof ReflectionClass && ! $reflection->isAbstract()) { + $parent = $reflection->getParentClass(); + + if (! $parent instanceof ReflectionClass) { + break; + } + + $reflection = $parent; + $baseClass = $reflection->name; + } + + return new StateConfig($baseClass); + } + + public static function castUsing(array $arguments) + { + return new StateCaster(static::class); + } + + public static function getMorphClass(): string + { + $defaultProperties = (new ReflectionClass(static::class))->getDefaultProperties(); + $name = $defaultProperties['name'] ?? null; + + return is_string($name) ? $name : static::class; + } + + public static function getStateMapping(): Collection + { + if (! isset(self::$stateMapping[static::class])) { + self::$stateMapping[static::class] = static::resolveStateMapping(); + } + + return collect(self::$stateMapping[static::class]); + } + + public static function resolveStateClass($state): ?string + { + if ($state === null) { + return null; + } + + if ($state instanceof self) { + return $state::class; + } + + if (is_string($state) && is_subclass_of($state, static::class)) { + return $state; + } + + foreach (static::getStateMapping() as $morphClass => $stateClass) { + // Loose comparison is needed to support values casted to strings by Eloquent. + if ($morphClass === $state) { + return $stateClass; + } + } + + return is_string($state) ? $state : null; + } + + public static function make(string $name, $model): self + { + $stateClass = static::resolveStateClass($name); + + if (! is_string($stateClass) || ! is_subclass_of($stateClass, static::class)) { + throw new InvalidArgumentException("{$name} does not extend " . static::class . '.'); + } + + return new $stateClass($model); + } + + public function getModel() + { + return $this->model; + } + + public function getField(): string + { + return $this->field; + } + + public static function all(): Collection + { + return collect(self::resolveStateMapping()); + } + + public function setField(string $field): self + { + $this->field = $field; + + return $this; + } + + public function transitionTo($newState, ...$transitionArgs) + { + $newState = $this->resolveStateObject($newState); + + $from = static::getMorphClass(); + $to = $newState::getMorphClass(); + + if (! $this->stateConfig->isTransitionAllowed($from, $to)) { + throw TransitionNotFound::make($from, $to, $this->model::class); + } + + $this->model->{$this->field} = $newState; + $this->model->save(); + + $currentState = $this->model->{$this->field}; + + if ($currentState instanceof self) { + $currentState->setField($this->field); + } + + return $this->model; + } + + public function canTransitionTo($newState, ...$transitionArgs): bool + { + $newState = $this->resolveStateObject($newState); + + return $this->stateConfig->isTransitionAllowed(static::getMorphClass(), $newState::getMorphClass()); + } + + public function getValue(): string + { + return static::getMorphClass(); + } + + public function equals(...$otherStates): bool + { + foreach ($otherStates as $otherState) { + $otherState = $this->resolveStateObject($otherState); + + if ( + $this->stateConfig->baseStateClass === $otherState->stateConfig->baseStateClass + && $this->getValue() === $otherState->getValue() + ) { + return true; + } + } + + return false; + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return $this->getValue(); + } + + private function resolveStateObject($state): self + { + if (is_object($state) && is_subclass_of($state, $this->stateConfig->baseStateClass)) { + return $state; + } + + $stateClassName = $this->stateConfig->baseStateClass::resolveStateClass($state); + + if (! is_string($stateClassName) || ! is_subclass_of($stateClassName, $this->stateConfig->baseStateClass)) { + throw new InvalidArgumentException("{$state} does not extend {$this->stateConfig->baseStateClass}."); + } + + return new $stateClassName($this->model); + } + + /** + * @return array + */ + private static function resolveStateMapping(): array + { + $reflection = new ReflectionClass(static::class); + $stateConfig = static::config(); + + $fileName = $reflection->getFileName(); + + if (! is_string($fileName)) { + return []; + } + + $directory = dirname($fileName); + $files = scandir($directory); + + if ($files === false) { + return []; + } + + $namespace = $reflection->getNamespaceName(); + $resolvedStates = []; + + foreach ($files as $file) { + if ($file === '.' || $file === '..') { + continue; + } + + $fileInfo = pathinfo($file); + $extension = $fileInfo['extension'] ?? null; + $className = $fileInfo['filename'] ?? null; + + if ($extension !== 'php' || ! is_string($className)) { + continue; + } + + $stateClass = $namespace . '\\' . $className; + + if (! class_exists($stateClass)) { + continue; + } + + if (! is_subclass_of($stateClass, $stateConfig->baseStateClass)) { + continue; + } + + $resolvedStates[$stateClass::getMorphClass()] = $stateClass; + } + + foreach ($stateConfig->registeredStates as $stateClass) { + $resolvedStates[$stateClass::getMorphClass()] = $stateClass; + } + + return $resolvedStates; + } +} diff --git a/src/States/StateCaster.php b/src/States/StateCaster.php new file mode 100644 index 00000000..35adab5c --- /dev/null +++ b/src/States/StateCaster.php @@ -0,0 +1,76 @@ +baseStateClass = $baseStateClass; + } + + public function get($model, string $key, $value, array $attributes) + { + if ($value === null) { + return null; + } + + $stateClassName = $this->baseStateClass::resolveStateClass($value); + + if (! is_string($stateClassName) || ! is_subclass_of($stateClassName, $this->baseStateClass)) { + throw new InvalidArgumentException("Unknown state `{$value}` for `{$this->baseStateClass}`."); + } + + $state = new $stateClassName($model); + $state->setField($key); + + return $state; + } + + public function set($model, string $key, $value, array $attributes): ?string + { + if ($value === null) { + return null; + } + + if ($value instanceof $this->baseStateClass) { + $value->setField($key); + + return $value::getMorphClass(); + } + + if (is_string($value) && is_subclass_of($value, $this->baseStateClass)) { + return $value::getMorphClass(); + } + + $mapping = $this->getStateMapping(); + + if (! $mapping->has($value)) { + throw new InvalidArgumentException("Unknown state `{$value}` for `{$this->baseStateClass}`."); + } + + /** @var string $stateClass */ + $stateClass = $mapping->get($value); + + return $stateClass::getMorphClass(); + } + + public function serialize($model, string $key, $value, array $attributes) + { + return $value instanceof State ? $value->jsonSerialize() : $value; + } + + private function getStateMapping(): Collection + { + return $this->baseStateClass::getStateMapping(); + } +} diff --git a/src/States/StateConfig.php b/src/States/StateConfig.php new file mode 100644 index 00000000..8f62f7c8 --- /dev/null +++ b/src/States/StateConfig.php @@ -0,0 +1,149 @@ + + */ + public array $allowedTransitions = []; + + /** + * @var string[] + */ + public array $registeredStates = []; + + public bool $shouldIgnoreSameState = false; + + public function __construct(string $baseStateClass) + { + $this->baseStateClass = $baseStateClass; + } + + public function default(string $defaultStateClass): self + { + $this->defaultStateClass = $defaultStateClass; + + return $this; + } + + public function ignoreSameState(): self + { + $this->shouldIgnoreSameState = true; + + return $this; + } + + public function allowTransition($from, string $to, ?string $transition = null): self + { + if (is_array($from)) { + foreach ($from as $fromState) { + $this->allowTransition($fromState, $to, $transition); + } + + return $this; + } + + if (! is_subclass_of($from, $this->baseStateClass)) { + throw new InvalidArgumentException("{$from} does not extend {$this->baseStateClass}."); + } + + if (! is_subclass_of($to, $this->baseStateClass)) { + throw new InvalidArgumentException("{$to} does not extend {$this->baseStateClass}."); + } + + $this->allowedTransitions[$this->createTransitionKey($from, $to)] = $transition; + + return $this; + } + + /** + * @param array> $transitions + */ + public function allowTransitions(array $transitions): self + { + foreach ($transitions as $transition) { + $this->allowTransition($transition[0], $transition[1], $transition[2] ?? null); + } + + return $this; + } + + public function isTransitionAllowed(string $fromMorphClass, string $toMorphClass): bool + { + if ($this->shouldIgnoreSameState && $fromMorphClass === $toMorphClass) { + return true; + } + + return $this->stateMachine() + ->canTransition($fromMorphClass, $toMorphClass); + } + + /** + * @return string[] + */ + public function transitionableStates(string $fromMorphClass): array + { + return $this->stateMachine() + ->transitionableStates($fromMorphClass); + } + + /** + * @param string|string[] $stateClass + */ + public function registerState($stateClass): self + { + if (is_array($stateClass)) { + foreach ($stateClass as $state) { + $this->registerState($state); + } + + return $this; + } + + if (! is_subclass_of($stateClass, $this->baseStateClass)) { + throw new InvalidArgumentException("{$stateClass} does not extend {$this->baseStateClass}."); + } + + $this->registeredStates[] = $stateClass; + + return $this; + } + + private function createTransitionKey(string $from, string $to): string + { + if (is_subclass_of($from, $this->baseStateClass)) { + $from = $from::getMorphClass(); + } + + if (is_subclass_of($to, $this->baseStateClass)) { + $to = $to::getMorphClass(); + } + + return "{$from}->{$to}"; + } + + private function stateMachine(): StateMachine + { + $stateMachine = new StateMachine(); + + foreach (array_keys($this->allowedTransitions) as $allowedTransition) { + [$from, $to] = explode('->', $allowedTransition, 2); + + $stateMachine->addState($from); + $stateMachine->addState($to); + $stateMachine->addTransition($allowedTransition, $from, $to); + } + + return $stateMachine; + } +} diff --git a/src/States/StateMachine.php b/src/States/StateMachine.php new file mode 100644 index 00000000..5c261ff7 --- /dev/null +++ b/src/States/StateMachine.php @@ -0,0 +1,99 @@ + + */ + private array $transitions = []; + + private ?string $currentState = null; + + public function addState(string $state): void + { + if (! in_array($state, $this->states, true)) { + $this->states[] = $state; + } + } + + public function addTransition(string $action, string $fromState, string $toState): void + { + $this->transitions[$action] = [ + 'from' => $fromState, + 'to' => $toState, + ]; + } + + public function initialize(?string $initialState = null): void + { + if ($initialState !== null) { + $this->currentState = $initialState; + + return; + } + + if (count($this->states) > 0) { + $this->currentState = $this->states[0]; + } + } + + public function getCurrentState(): ?string + { + return $this->currentState; + } + + public function canApply(string $action): bool + { + return isset($this->transitions[$action]) + && $this->transitions[$action]['from'] === $this->currentState; + } + + public function apply(string $action): void + { + if (! $this->canApply($action)) { + throw new Exception('Transition not found.'); + } + + $this->currentState = $this->transitions[$action]['to']; + } + + public function canTransition(string $fromState, string $toState): bool + { + foreach ($this->transitions as $transition) { + if ($transition['from'] === $fromState && $transition['to'] === $toState) { + return true; + } + } + + return false; + } + + /** + * @return string[] + */ + public function transitionableStates(string $fromState): array + { + $states = []; + + foreach ($this->transitions as $transition) { + if ($transition['from'] !== $fromState) { + continue; + } + + $states[] = $transition['to']; + } + + return $states; + } +} diff --git a/src/States/WorkflowStatus.php b/src/States/WorkflowStatus.php index 7ac117fb..dcca98f3 100644 --- a/src/States/WorkflowStatus.php +++ b/src/States/WorkflowStatus.php @@ -4,9 +4,6 @@ namespace Workflow\States; -use Spatie\ModelStates\State; -use Spatie\ModelStates\StateConfig; - abstract class WorkflowStatus extends State { public static function config(): StateConfig diff --git a/src/Workflow.php b/src/Workflow.php index 0942dd99..155454ff 100644 --- a/src/Workflow.php +++ b/src/Workflow.php @@ -19,6 +19,7 @@ use React\Promise\PromiseInterface; use Throwable; use Workflow\Events\WorkflowCompleted; +use Workflow\Exceptions\TransitionNotFound; use Workflow\Middleware\WithoutOverlappingMiddleware; use Workflow\Models\StoredWorkflow; use Workflow\Serializers\Serializer; @@ -150,7 +151,7 @@ public function failed(Throwable $throwable): void try { $this->storedWorkflow->toWorkflow() ->fail($throwable); - } catch (\Spatie\ModelStates\Exceptions\TransitionNotFound) { + } catch (TransitionNotFound) { return; } } @@ -167,7 +168,7 @@ public function handle(): void if (! $this->replaying) { $this->storedWorkflow->status->transitionTo(WorkflowRunningStatus::class); } - } catch (\Spatie\ModelStates\Exceptions\TransitionNotFound) { + } catch (TransitionNotFound) { if ($this->storedWorkflow->toWorkflow()->running()) { $this->release(); } diff --git a/src/WorkflowStub.php b/src/WorkflowStub.php index 84e33ac2..96bbcf6c 100644 --- a/src/WorkflowStub.php +++ b/src/WorkflowStub.php @@ -12,6 +12,7 @@ use SplFileObject; use Workflow\Events\WorkflowFailed; use Workflow\Events\WorkflowStarted; +use Workflow\Exceptions\TransitionNotFound; use Workflow\Models\StoredWorkflow; use Workflow\Serializers\Serializer; use Workflow\States\WorkflowCompletedStatus; @@ -293,7 +294,7 @@ public function fail($exception): void try { $parentWorkflow->toWorkflow() ->fail($exception); - } catch (\Spatie\ModelStates\Exceptions\TransitionNotFound) { + } catch (TransitionNotFound) { return; } }); @@ -383,7 +384,7 @@ private function dispatch(): void try { $this->storedWorkflow->status->transitionTo(WorkflowPendingStatus::class); - } catch (\Spatie\ModelStates\Exceptions\TransitionNotFound $exception) { + } catch (TransitionNotFound $exception) { $this->storedWorkflow->refresh(); if ($this->status() !== WorkflowPendingStatus::class) { diff --git a/tests/Fixtures/StateMachine.php b/tests/Fixtures/StateMachine.php deleted file mode 100644 index cd735a37..00000000 --- a/tests/Fixtures/StateMachine.php +++ /dev/null @@ -1,50 +0,0 @@ -states[] = $state; - } - - public function addTransition($action, $fromState, $toState) - { - $this->transitions[$action] = [ - 'from' => $fromState, - 'to' => $toState, - ]; - } - - public function initialize() - { - if (count($this->states) > 0) { - $this->currentState = $this->states[0]; - } - } - - public function getCurrentState() - { - return $this->currentState; - } - - public function apply($action) - { - if (isset($this->transitions[$action]) && $this->transitions[$action]['from'] === $this->currentState) { - $this->currentState = $this->transitions[$action]['to']; - } else { - throw new Exception('Transition not found.'); - } - } -} diff --git a/tests/Fixtures/TestStateMachineWorkflow.php b/tests/Fixtures/TestStateMachineWorkflow.php index 89a8db02..e170e352 100644 --- a/tests/Fixtures/TestStateMachineWorkflow.php +++ b/tests/Fixtures/TestStateMachineWorkflow.php @@ -7,6 +7,7 @@ use function Workflow\await; use Workflow\Models\StoredWorkflow; use Workflow\SignalMethod; +use Workflow\States\StateMachine; use Workflow\Workflow; class TestStateMachineWorkflow extends Workflow diff --git a/tests/Unit/ChildWorkflowStubTest.php b/tests/Unit/ChildWorkflowStubTest.php index f3d90dc3..0977638f 100644 --- a/tests/Unit/ChildWorkflowStubTest.php +++ b/tests/Unit/ChildWorkflowStubTest.php @@ -5,11 +5,11 @@ namespace Tests\Unit; use Mockery; -use Spatie\ModelStates\Exceptions\TransitionNotFound; use Tests\Fixtures\TestChildWorkflow; use Tests\Fixtures\TestParentWorkflow; use Tests\TestCase; use Workflow\ChildWorkflowStub; +use Workflow\Exceptions\TransitionNotFound; use Workflow\Models\StoredWorkflow; use Workflow\Serializers\Serializer; use Workflow\States\WorkflowPendingStatus; From 25634eb09d2303736c95f1e1d93df94df056f246 Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Sun, 22 Feb 2026 20:24:34 -0600 Subject: [PATCH 2/3] Cleanup --- src/States/HasStates.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/States/HasStates.php b/src/States/HasStates.php index 56378095..1d0a45c1 100644 --- a/src/States/HasStates.php +++ b/src/States/HasStates.php @@ -25,7 +25,7 @@ public function initializeHasStates(): void public static function getStates(): Collection { - $model = new static(); + $model = static::make(); return collect($model->getStateConfigs()) ->map(static function (StateConfig $stateConfig) { @@ -35,7 +35,7 @@ public static function getStates(): Collection public static function getDefaultStates(): Collection { - $model = new static(); + $model = static::make(); return collect($model->getStateConfigs()) ->map(static function (StateConfig $stateConfig) { From 1e1964d315316ded7a1b308b71832fbf3fa381d1 Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Mon, 23 Feb 2026 02:34:07 -0600 Subject: [PATCH 3/3] Cleanup --- tests/Unit/States/StateInfrastructureTest.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/Unit/States/StateInfrastructureTest.php b/tests/Unit/States/StateInfrastructureTest.php index a11ed4e0..1a1ea703 100644 --- a/tests/Unit/States/StateInfrastructureTest.php +++ b/tests/Unit/States/StateInfrastructureTest.php @@ -80,10 +80,7 @@ public function testStateConfigSupportsArraysAndTransitionLookup(): void ); $this->assertSame( $config, - $config->registerState([ - StateInfraInitialState::class, - StateInfraNextState::class, - ]) + $config->registerState([StateInfraInitialState::class, StateInfraNextState::class]) ); $this->assertTrue($config->isTransitionAllowed(StateInfraInitialState::$name, StateInfraInitialState::$name));