rapid/fsm

Laravel Plus+

1.0.0 2025-04-24 19:23 UTC

This package is auto-updated.

Last update: 2025-04-24 19:30:51 UTC


README

Finite state machine

Installation

composer require rapid/fsm

Context

Contexts are the building blocks of FSM. Each context is a collection of states that it manages.

class MyContext extends Context
{
    protected static string $model = MyModel::class;

    protected static array $states = [
        'first' => FirstState::class,
        'second' => SecondState::class,
    ];
}

Context model

Each context needs to be connected to a model and store information about the FSM in itself.

The model must use InteractsWithContext:

class MyModel extends Model
{
    use InteractsWithContext;
}

And have the following columns:

$table->string('current_state')->nullable();
$table->nullableMorphs('parent');

And also implement the following method:

protected function contextClass(): string
{
    return MyContext::class;
}

Then, if you have a record, you can use the following helpers:

// Context object
$context = $record->context;

// Current state value
$state = $record->state;

// Current deep state value
$state = $record->deepState;

Or you can use the following scopes in your queries:

MyModel::query()
    // Add condition where the state is instance of these classes (or interfaces)
    ->whereState(FooState::class)
    ->whereStateNot(BarState::class)
    ->whereStateIn([FooState::class, MyContract::class])
    ->whereStateNotIn([FooState::class, MyContract::class])
    
    // Add conditions where the state is these classes
    ->whereStateIs(FooState::class)
    ->whereStateIsNot(BarState::class)
    ->whereStateIsIn([FooState::class, BarState::class])
    ->whereStateIsNotIn([FooState::class, BarState::class])
;

Context states

You need to declare the list of states in the $states variable. It is absolutely necessary to declare all states, but one thing you need to know is that states are not ordered.

The key you define is stored in the current_state column of the model, instead of the state class name. Defining a key is optional, but it helps with database readability.

protected static array $states = [
    Foo::class,             // save as "App\States\Foo"
    'bar' => Bar::class,    // save as "bar"
];

If you need to dynamically define your states, just define the states method instead of defining the $states variable.

public static function states(): array
{
    return config('custom.my_fsm.states');
}

Note: Notice that we don't have anything for edges. In fact, adding edges only adds complexity to the application. We can handle the input with a controller/service, and the destination by passing the destination state name.

Transitions

If you want the current state to change, you can do so simply with transitionTo.

$state = $context->transitionTo(SecondState::class);

State

Each record can be in a state at any given time. Each state must specify the characteristics of that state.

class FirstState extends State
{
}

States support events:

class FirstState extends State
{
    public function onEnter(): void
    {
    }

    public function onLeave(): void
    {
    }
}

You can access the context with the $parent property.

class FooStep extends State
{
    public function onEnter(): void
    {
        if (!config('foo.enabled')) {
            $this->parent->transitionToNext();
        }
    }
}

Nested context

You can also create nested FSMs.

In fact, every Context is a State. So, you can use a context instead of your state, and then introduce that context in the states list of the parent context.

class MyFsmContext extends Context
{
    protected static string $model = MyFsmModel::class;

    protected static array $states = [
        'foo' => FooState::class,
        'bar' => BarContext::class,
        'baz' => BazState::class,
    ];
}

class FooState extends State {}
class BazState extends State {}

class BarContext extends Context
{
    protected static string $model = BarFsmModel::class;

    protected static array $states = [
        'foo' => FooState::class,
    ];
}

In the child context, you can customize the record creation (note that the record must exist)

  1. If you want it to be created once and loaded later:
public function onLoad(): void
{
    $this->loadRecord() or $this->createRecord([]);
}

You can also customize the logic that should be performed when entering this context. For example, upon entering this context, the context itself should go to a specific state:

public function onEnter(): void
{
    $this->transitionTo(FooState::class);
}

In a subcontext, you can also access the parent context:

class SubContext extends Context
{
    public function finish()
    {
        $this->transitionTo(Finished::class);           // End of this subcontext
        $this->parent->transitionTo(BarAccept::class);  // Changing the parent context state
    }
}

Start a fsm

Starting an FSM is as simple as this:

$myRecord = MyFsmRecord::create([
    'my_attribute' => $foo,
]);

$myRecord->context->transitionTo(FirstState::class);

Configuration

Inherit from the ContextConfiguration interface or the DefaultContextConfiguration class to customize your context settings.

class MyConfiguration extends DefaultContextConfiguration
{
}

Then introduce it into your context:

class MyContext extends Context
{
    protected static string $configurationClass = MyConfiguration::class;
}

Log

Inherit from the Logger interface or the EmptyLogger class to customize your logging.

class MyLogger extends EmptyLogger
{
}

For example, if you want to store a record in a table as a log when a transition occurs:

class MyLogger extends EmptyLogger
{
    public function transition(PendingLog $log): void
    {
        FsmTransitionLog::create([
            'from' => $this->fromState ? $log->context->getStateAliasName($log->fromState::class) : null,
            'to' => $this->toState ? $log->context->getStateAliasName($log->toState::class) : null,
        ]);
    }
}

Use log

By default, if you perform the transitionTo operation, no logging occurs:

$context->transitionTo(WaitingForResponse::class); // Without log

To use the log, you must first use useLog and then transitionTo.

$context->useLog()->transitionTo(WaitingForResponse::class); // With log
$context->transitionTo(WaitingForResponse::class, $context->useLog()); // With log

You can even pass properties with the PendingLog class.

$context->useLog()->with(['status' => 'approved'])->transitionTo(ProfileStep::class);
class MyLogger extends EmptyLogger
{
    public function transition(PendingLog $log): void
    {
        FsmTransitionLog::create([
            'from' => $this->fromState ? $log->context->getStateAliasName($log->fromState::class) : null,
            'to' => $this->toState ? $log->context->getStateAliasName($log->toState::class) : null,
            'status' => $log->attributes['status'] ?? 'none',
        ]);
    }
}

Or even customize the PendingLog class entirely and add new functions:

class RegistrationPendingLog extends PendingLog
{
    public string $withNotification;
    
    public function withNotification(string $message)
    {
        $this->withNotification = $message;
        return $this;
    }
}
class RegistrationContext extends Context
{
    public function useLog(): RegistrationPendingLog
    {
        return new RegistrationPendingLog($this);
    }
}
class RegistrationLogger extends EmptyLogger
{
    public function transition(PendingLog $log): void
    {
        if ($log instanceof RegistrationPendingLog && isset($log->withNotification)) {
            UserService::getAdmin()->notify(
                new MessageNotification($log->withNotification),
            );
        }
    }
}
$context->useLog()
    ->withNotification("A new user registered successfully!")
    ->transitionTo(Completed::class);

Force log

If you need your transitions to always be logged, just change the $forceLog variable to true:

class MyContext extends Context
{
    protected static bool $forceLog = true;
}
$context->transitionTo(Completed::class); // Now also uses logs

Authorization

Most of the time you need to check if this FSM is in the correct state or not. In these cases we use the authorize method.

$context->authorize(FooState::class);

Or we can use the is method to just get the true/false value.

if ($context->is(FooState::class)) {
    // code
}

But a question arises! How can more complex checks be performed?

Note: The difference between check and compare is that check focuses on the state and sub-states, but compare is responsible for comparing the set and compares the current state and the passed state.

Check type

Choose one of the following types to specify how to check your input state:

1. CHECK_HAS: Checks whether the current state or its sub-states match this state, at least one of which (default)

$context->authorize(Something::class, FsmManager::CHECK_HAS);

2. CHECK_HEAD: Checks what the main state is

$context->authorize(InfoReviewing::class, FsmManager::CHECK_HEAD);

3. CHECK_DEEP: Checks what the deepest state is

$context->authorize(TechnicalVisiting::class, FsmManager::CHECK_DEEP);

4. CHECK_BUILDING: Deep and one-by-one checking of fsm states

For example, if you had a state in the main context that was itself a context (nested context), you would pass an array and check it in depth order:

$context->authorize([InfoReviewing::class, TechnicalVisiting::class], FsmManager::CHECK_BUILDING);
//                   ^Check root context^, ^___Check sub-state___^

Compare type

Choose one of the following values to specify how the state you pass should be compared to the current state.

1. INSTANCE_OF: Checks if the state of an instance of the class is passed (default)

$context->authorize(Rejected::class, FsmManager::INSTANCE_OF);
$context->authorize(BaseState::class, FsmManager::INSTANCE_OF);
$context->authorize(SomeContract::class, FsmManager::INSTANCE_OF);

// Also accept array:
$context->authorize([A::class, B::class, C::class], FsmManager::INSTANCE_OF);

2. IS: Checks that the state is exactly the same as the class you passed.

$context->authorize(Rejected::class, FsmManager::IS);

// Also accept array:
$context->authorize([A::class, B::class, C::class], FsmManager::IS);

Mix check & compare

You can combine check and compare values.

$context->authorize(Rejected::class, FsmManager::CHECK_HEAD | FsmManager::IS);
$context->authorize([InfoReviewing::class, Rejected::class], FsmManager::CHECK_BUILDING | FsmManager::IS);

$context->authorize([InfoReviewing::class, [Rejected::class, Approved::class]], FsmManager::CHECK_BUILDING | FsmManager::IS);

Configure defaults

The default compare value can be set in multiple layers.

  1. Context configuration
class MyConfiguration extends DefaultContextConfiguration
{
    public function compare(): ?int
    {
        return FsmManager::IS;
    }
}
  1. Container
class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        Fsm::setDefaultCompare(FsmManager::IS);
    }
}
  1. Config

fsm.php:

'compare' => FsmManager::IS,

Controllers

To use FSM in your controller, we will give you a suggested method as an example. You have no restrictions on how you use FSM.

Consider a simple FSM for confirming a user's email; this FSM contains three states: RequestEmail, RequestCode, and Confirmed.

For this FSM, we need these routes:

Route::controller(EmailConfirmationController::class)->group(function () {
    Route::post('/new-process', 'newProcess');
    Route::post('/{fsm:id}/send-email', 'sendEmail');
    Route::post('/{fsm:id}/check-code', 'checkCode');
});

And the controller:

class EmailConfirmationController extends Controller
{
    /**
     * Starts a new process.
     * This route does not require the fsm to already exist.
     * This route checks that this user does not have any pending requests.
     */
    public function newProcess()
    {
        if (EmailConfirmationFsm
            ::where('user_id', auth()->id())
            ->whereStateIsNot(Confirmed::class)
            ->exists()) {
            abort(403);
        }
    
        $fsm = EmailConfirmationFsm::create([
            'user_id' => auth()->id(),
        ]);
        
        $fsm->context->transitionTo(RequestEmail::class);
        
        return view('request-email', compact('fsm'));
    }
    
    /**
     * Gets the email from the user and stores it in the model.
     * This route requires that the user is in the correct state, otherwise an error will occur.
     */
    public function sendEmail(EmailConfirmationFsm $fsm, Request $request)
    {
        $fsm->context->authorize(RequestEmail::class);
        
        $validated = $request->validate([
            'email' => 'required|string|email',
        ]);
        
        $fsm->update([
            'email' => $validated['email'],
            'code' => rand(10000, 99999),
        ]);
        
        $fsm->context->transitionTo(RequestCode::class);
        
        return view('request-code', compact('fsm'));
    }
    
    /**
     * Takes the code from the user, and compares it with the code stored in the database
     */
    public function checkCode(EmailConfirmationFsm $fsm, Request $request)
    {
        $fsm->context->authorize(RequestCode::class);
        
        $validated = $request->validate([
            'code' => 'required|string|digits:5',
        ]);
        
        if ($validated['code'] != $fsm->code) {
            throw ValidationException::withMessages([
                'code' => 'Code is wrong',
            ]);
        }
        
        $fsm->context->transitionTo(Confirmed::class);
        
        return view('confirmed', compact('fsm'));
    }
}

Linear context

A linear context is a simple, linear FSM where each state is executed in sequence.

This class provides you with a few helper methods that help you navigate between your states.

class RegistrationContext extends LinearContext
{
    protected static array $states = [
        'basic profile' => BasicProfileStep::class,
        'phone' => PhoneStep::class,
        'confirm code' => ConfirmCodeStep::class,
        'accept' => AcceptStep::class,
        'finished' => FinishedStep::class,
    ];

    protected static array $path = [
        BasicProfileStep::class,
        PhoneStep::class,
        ConfirmCodeStep::class,
        AcceptStep::class,
    ];
    
    protected static string $endState = FinishedStep::class;
}

The path variable specifies the execution order.

Now you use these methods to move to the next and previous state:

$this->transitionToNext();
$this->transitionToPrevious();

$this->useLog()->transitionToNext();
$this->useLog()->transitionToPrevious();

Api

You can define your APIs within the context.

Just add this code to your root path (such as web.php or api.php):

MyContext::routes();
Attribute In contexts In states Need to Description
Api yes yes Introducing a new route
WithoutRecord yes no Api Eliminates the need for records from the route
WithMiddleware (class) yes no Adds middleware to all routes
WithMiddleware (method) yes yes Api Adds middleware to the root
OverrideApi no yes Overrides a defined API
OnState yes yes Api Checks that the state matches
IntoTransaction yes yes Api Executes the function in a database transaction.
#[WithMiddleware(['auth'])]
class MyContext extends Context
{
    #[Api, WithoutRecord]
    public function initialize()
    {
        $this->createRecord([
            'user_id' => auth()->id(),
        ]);
        
        return response()->json([
            'ok' => true,
            'data' => [
                'id' => $this->record->id,
            ],
        ]);
    }
}

Use the Api attribute to specify which functions should be defined in routes.

Function inputs are exactly the same as controllers.

#[Api, WithoutRecord]
public function initialize()
{
}

#[Api]
public function foo(Request $request, MyService $service)
{
}

#[Api('users/{user:id}')]
public function showUser(User $user)
{
}

#[Api('users/{user:id}', method: 'POST', name: 'update_the_user', middleware: ['signed'])]
public function updateUser(User $user)
{
}