A small, framework-agnostic PHP state machine for modeling valid transitions between states.
It is built around three contracts:
StateContractTransitionContractStateMachineContract
You define your own states, transitions, and domain events. The package validates transitions and throws clear exceptions when a transition is invalid.
- PHP 8.5+
composer require juststeveking/state-machineIf you already have your own states and transitions, this is the smallest working flow:
<?php
declare(strict_types=1);
use App\Domain\Posts\PostStateMachine;
use App\Domain\Posts\Transitions\PublishPost;
use JustSteveKing\StateMachine\Exceptions\InvalidTransitionException;
use JustSteveKing\StateMachine\StateMachine;
$stateMachine = new StateMachine(
machine: new PostStateMachine(currentStatus: 'draft'),
);
try {
$event = $stateMachine->transition(
transition: new PublishPost(),
context: ['can_publish' => true],
);
// Persist new status, dispatch event, etc.
} catch (InvalidTransitionException $exception) {
// Return/log why the transition was denied
}When you attempt a transition, the package checks:
- Is this transition class registered on the machine?
- Is the current state one of the transition's allowed
from()states? - Does the transition
guard()allow it? - If all pass, create and return the domain event from
eventClass().
If any check fails, an InvalidTransitionException is thrown.
This example models publishing a blog post from draft to published.
<?php
declare(strict_types=1);
namespace App\Domain\Posts\States;
use JustSteveKing\StateMachine\Contracts\StateContract;
final class DraftState implements StateContract
{
public function value(): string
{
return 'draft';
}
public function label(): string
{
return 'Draft';
}
}
final class PublishedState implements StateContract
{
public function value(): string
{
return 'published';
}
public function label(): string
{
return 'Published';
}
}<?php
declare(strict_types=1);
namespace App\Domain\Posts\Events;
use JustSteveKing\StateMachine\Events\DomainEvent;
final class PostPublished extends DomainEvent
{
public function name(): string
{
return 'post.published';
}
}<?php
declare(strict_types=1);
namespace App\Domain\Posts\Transitions;
use App\Domain\Posts\Events\PostPublished;
use App\Domain\Posts\States\DraftState;
use App\Domain\Posts\States\PublishedState;
use JustSteveKing\StateMachine\Contracts\StateContract;
use JustSteveKing\StateMachine\Contracts\TransitionContract;
final class PublishPost implements TransitionContract
{
/**
* @return array<int, StateContract>
*/
public function from(): array
{
return [new DraftState()];
}
public function to(): StateContract
{
return new PublishedState();
}
/**
* @return class-string<PostPublished>
*/
public function eventClass(): string
{
return PostPublished::class;
}
public function guard(mixed $context): ?string
{
if (! is_array($context) || ($context['can_publish'] ?? false) !== true) {
return 'User is not allowed to publish this post.';
}
return null;
}
}<?php
declare(strict_types=1);
namespace App\Domain\Posts;
use App\Domain\Posts\States\DraftState;
use App\Domain\Posts\Transitions\PublishPost;
use JustSteveKing\StateMachine\Contracts\StateContract;
use JustSteveKing\StateMachine\Contracts\StateMachineContract;
use JustSteveKing\StateMachine\Contracts\TransitionContract;
final readonly class PostStateMachine implements StateMachineContract
{
public function __construct(
private string $currentStatus,
) {}
/**
* @return array<int, TransitionContract>
*/
public function transitions(): array
{
return [
new PublishPost(),
];
}
public function currentState(): StateContract
{
return match ($this->currentStatus) {
'draft' => new DraftState(),
default => new DraftState(),
};
}
}<?php
declare(strict_types=1);
use App\Domain\Posts\PostStateMachine;
use App\Domain\Posts\Transitions\PublishPost;
use JustSteveKing\StateMachine\Exceptions\InvalidTransitionException;
use JustSteveKing\StateMachine\StateMachine;
$machine = new StateMachine(
machine: new PostStateMachine(currentStatus: 'draft'),
);
try {
$event = $machine->transition(
transition: new PublishPost(),
context: ['can_publish' => true],
);
// $event->from, $event->to, $event->context, $event->occurredAt
} catch (InvalidTransitionException $exception) {
// Handle denial / invalid transition
// Example: log, return a validation message, etc.
}This example shows one machine with multiple transitions and state-dependent rules.
Use this when you want to support a lifecycle like draft -> published -> archived.
<?php
declare(strict_types=1);
namespace App\Domain\Posts\Transitions;
use App\Domain\Posts\Events\PostArchived;
use App\Domain\Posts\States\ArchivedState;
use App\Domain\Posts\States\PublishedState;
use JustSteveKing\StateMachine\Contracts\StateContract;
use JustSteveKing\StateMachine\Contracts\TransitionContract;
final class ArchivePost implements TransitionContract
{
/**
* @return array<int, StateContract>
*/
public function from(): array
{
return [new PublishedState()];
}
public function to(): StateContract
{
return new ArchivedState();
}
/**
* @return class-string<PostArchived>
*/
public function eventClass(): string
{
return PostArchived::class;
}
public function guard(mixed $context): ?string
{
if (! is_array($context) || ($context['can_archive'] ?? false) !== true) {
return 'User is not allowed to archive this post.';
}
return null;
}
}<?php
declare(strict_types=1);
namespace App\Domain\Posts;
use App\Domain\Posts\States\ArchivedState;
use App\Domain\Posts\States\DraftState;
use App\Domain\Posts\States\PublishedState;
use App\Domain\Posts\Transitions\ArchivePost;
use App\Domain\Posts\Transitions\PublishPost;
use JustSteveKing\StateMachine\Contracts\StateContract;
use JustSteveKing\StateMachine\Contracts\StateMachineContract;
use JustSteveKing\StateMachine\Contracts\TransitionContract;
final readonly class PostStateMachine implements StateMachineContract
{
public function __construct(
private string $currentStatus,
) {}
/**
* @return array<int, TransitionContract>
*/
public function transitions(): array
{
return [
new PublishPost(),
new ArchivePost(),
];
}
public function currentState(): StateContract
{
return match ($this->currentStatus) {
'draft' => new DraftState(),
'published' => new PublishedState(),
'archived' => new ArchivedState(),
default => new DraftState(),
};
}
}<?php
declare(strict_types=1);
use App\Domain\Posts\PostStateMachine;
use App\Domain\Posts\Transitions\ArchivePost;
use JustSteveKing\StateMachine\StateMachine;
$machine = new StateMachine(
machine: new PostStateMachine(currentStatus: 'published'),
);
$event = $machine->transition(
transition: new ArchivePost(),
context: ['can_archive' => true],
);
// $event->from->value() === 'published'
// $event->to->value() === 'archived'InvalidTransitionException includes named constructors and clear messages for:
- Transition class is not registered on the machine
- Current state is not allowed by the transition
- Guard rejected the transition
This makes it easy to return user-friendly validation errors or audit denied actions.
- Transition registration is class-based, not instance-based.
- If the machine registers
PublishPost::class, anynew PublishPost()instance is accepted. - A different class with similar logic is still rejected as not registered.
- If the machine registers
guard()must returnnullto allow transition.- Any non-null string from
guard()denies transition and becomes part of the exception message.
If you are contributing to this package:
composer test
composer stan
composer lint