SeviaFlow Get Started

Workflows

A workflow describes how a record moves between named states, such as an order moving from draft to pending and then to shipped. The Resource defines the state field, the states a record can have, and the transitions that are allowed.

A workflow uses an attribute on your Eloquent model. SeviaFlow does not create the database column or decide the business rules for you: add the column in your application's migration and choose which transitions make sense for your process.

Complete example: an order workflow

This example uses an order's status field. The code below is a complete Resource example; the sections after it explain each part.

WorkflowResource.php
<?php

declare(strict_types=1);

namespace App\SeviaFlow\Resources\Examples;

use App\Models\Order;
use SeviaFlow\Core\Resources\Columns\StatusColumn;
use SeviaFlow\Core\Resources\Columns\TextColumn;
use SeviaFlow\Core\Resources\Fields\SelectField;
use SeviaFlow\Core\Resources\Fields\TextField;
use SeviaFlow\Core\Resources\Form;
use SeviaFlow\Core\Resources\Resource;
use SeviaFlow\Core\Resources\Table;
use SeviaFlow\Core\Resources\Workflows\State;
use SeviaFlow\Core\Resources\Workflows\Transition;
use SeviaFlow\Core\Resources\Workflows\TransitionContext;
use SeviaFlow\Core\Resources\Workflows\Workflow;

final class WorkflowResource extends Resource
{
    protected static string $model = Order::class;

    public static function table(Table $table): Table
    {
        return $table->columns([
            TextColumn::make('number')->label('Order')->searchable()->sortable(),
            StatusColumn::make('status')->label('Status')->options(static::statusOptions()),
        ]);
    }

    public static function form(Form $form): Form
    {
        return $form->fields([
            TextField::make('number')->label('Order number')->required(),
            SelectField::make('status')->label('Status')->options(static::statusOptions())->required(),
        ]);
    }

    public static function workflow(Workflow $workflow): Workflow
    {
        return $workflow
            ->stateField('status')
            ->initialState('draft')
            ->states([
                State::make('draft')->label('Draft')->color('gray'),
                State::make('pending')->label('Pending')->color('yellow'),
                State::make('shipped')->label('Shipped')->color('green')->final(),
                State::make('cancelled')->label('Cancelled')->color('red')->final(),
            ])
            ->transitions([
                Transition::make('submit')
                    ->label('Submit for shipping')
                    ->from('draft')
                    ->to('pending'),
                Transition::make('ship')
                    ->from('pending')
                    ->to('shipped')
                    ->canRunIf(fn (Order $record): bool => $record->status === 'pending')
                    ->handle(function (Order $record, TransitionContext $context): void {
                        $record->forceFill(['shipped_by' => $context->user()?->getAuthIdentifier()])->save();
                    }),
                Transition::make('cancel')
                    ->from(['draft', 'pending'])
                    ->to('cancelled')
                    ->danger()
                    ->requiresComment()
                    ->requiresConfirmation(),
            ]);
    }

    /**
     * @return array<string, string>
     */
    private static function statusOptions(): array
    {
        return [
            'draft' => 'Draft',
            'pending' => 'Pending',
            'shipped' => 'Shipped',
            'cancelled' => 'Cancelled',
        ];
    }
}

1. Choose the field and starting state

stateField('status') tells SeviaFlow which model attribute contains the current state. It defaults to status. The column must exist in your database and be available on the Eloquent model.

initialState('draft') supplies the state name to use when that attribute is empty. It does not write a database default or save the record by itself. Make the initial state name match one declared in states().

return $workflow
    ->stateField('status')
    ->initialState('draft');

2. Declare the states

A state name is the stable value stored in the model, for example pending_payment. A label is the text shown to a person. If you omit the label, SeviaFlow turns the state name into readable text: pending_payment becomes “Pending payment”.

The optional color decorates the state badge in the UI. The API also returns the color name as metadata. The suggestions below are conventions for consistency; the color does not change permissions or workflow behavior.

ColorSuggested useExample states
grayNeutral, not started, or inactive.draft, archived
redRejected, cancelled, or requiring urgent attention.rejected, cancelled, failed
yellowWaiting, pending, or needing attention.pending, awaiting_payment
greenApproved or successfully completed.approved, shipped, complete
blueActive work or a neutral informational state.in_progress, scheduled
indigoReview or a distinct intermediate stage.under_review, quality_check
purpleA special or internal process stage.escalated, special_handling
pinkA customer-facing or follow-up stage that benefits from a distinct badge.awaiting_customer, follow_up

Only these eight color names are accepted. A state may omit its color; the UI then uses its neutral gray badge. Use final() to mark a state as final in workflow metadata. The API includes this as state.final. This flag does not block transitions by itself: to make a state terminal, do not define transitions leaving it.

3. Define the allowed transitions

A transition is an action that changes a record from one state to another. Its name is the stable identifier used in code and in the API URL; its optional label is the button text. If you omit the label, SeviaFlow derives readable text from the name.

MethodWhat it controlsWhen to use it
from('draft')The state the record must currently be in. You can pass one state name or an array of names.Use multiple source states when the same action is allowed from more than one state.
to('pending')The destination state written to the configured state field. Every transition must have a destination.Use a name declared in states().
danger()Marks the action as dangerous in the UI and requires explicit confirmation.Use for actions such as cancelling, rejecting, or permanently closing a record.
requiresConfirmation()Requires the caller to confirm before execution. This is already enabled by danger().Use when a safe-looking action still needs an explicit confirmation.
requiresComment()Requires a non-empty comment. The UI displays a comment field; the API accepts comment in the request body. The comment is stored in workflow activity.Use when a rejection, override, or cancellation needs an explanation.
visibleIf(...)Controls whether the transition is listed for the current record and user.Use for presentation, such as hiding an option from a particular audience. This does not prevent a direct request from executing the transition, so do not use it for authorization.
canRunIf(...)Checks a business precondition, such as whether an order is paid before it can be shipped. It is checked when options are listed and again when one is executed.Use when an action depends on the record's current data.
authorize(...)Adds a transition-specific authorization check. It is checked when options are listed and again during execution.Use for a rule such as “only a manager may approve this order”. Resource-level update authorization still applies separately.
successMessage('...')Sets the success message shown in the UI and returned in the API response.Use to say what happened in language that fits the application.
errorMessage('...')Sets a safe fallback message for the UI and API if the handler fails unexpectedly.Use a helpful message without exposing exception details or sensitive data.
handle(...)Runs application code for the transition. Accepts a callable or the class name of an invokable handler.Use for work such as recording who shipped an order or starting another application-specific operation.

4. Write condition and authorization callbacks

The callbacks for visibleIf, canRunIf, and authorize receive positional values in this order: record, user, and TransitionContext. You can declare only the values you need. Return true to allow the condition and false to hide or block the transition.

Transition::make('ship')
    ->from('pending')
    ->to('shipped')
    ->canRunIf(fn (Order $record): bool => $record->status === 'pending')
    ->authorize(fn (Order $record, $user): bool => $user?->can('ship', $record) ?? false);

visibleIf only changes what is shown. Use authorize for the transition-specific permission check, and keep the Resource's policy or SeviaFlow permission rules in place. canRunIf is for business conditions, not a replacement for access control.

In the UI, a transition that fails canRunIf can be shown as blocked. A transition that fails visibleIf or authorize is omitted.

5. Add optional code for extra work

A transition changes the state on its own. You do not need a handler just to move an order from pending to shipped. Add handle(...) only when the application also needs to do something as part of that transition, such as recording who shipped the order.

SeviaFlow saves the new state first, then calls the handler with the updated Eloquent record and a TransitionContext. The context lets your handler use details about this execution, including the acting user, transition name, previous and next states, optional comment, and workflow definition. For example, $context->user() returns the acting user and $context->fromState() and $context->toState() return the state names before and after the transition.

->handle(function (Order $record, TransitionContext $context): void {
    $record->forceFill([
        'shipped_by' => $context->user()?->getAuthIdentifier(),
    ])->save();
});

Here, the transition has already changed the order's status to shipped; the handler stores the current user's ID in shipped_by. If updating the status is all you need, leave out handle(...).

The state update, handler, and workflow activity entry run in one database transaction. If the handler throws an exception, those database changes are rolled back. A database rollback cannot undo an email that was already sent or an external API call, so queue that work to run after the transaction commits or make it safe to retry.

When a transition is executed

  1. SeviaFlow reads the current value from the configured state field. If it is empty, it uses initialState().
  2. The transition must allow the current state through from().
  3. SeviaFlow checks Resource update authorization, the transition's authorize and canRunIf callbacks, and any required confirmation or comment.
  4. It writes the destination state, runs the handler, and records the actor, transition, state change, and comment as workflow activity.

The UI shows available transitions and recent workflow activity on the Resource's show page. To expose transitions through REST, enable the Resource API and ApiResource::flow(); see Workflow API endpoints. Read Authorization before exposing real data.