Package provides workflow functionality to Eloquent Models.
Workflow is a sequence of states, document evolve through. Transitions between states inflicts the evolution road.
First, describe the workflow blueprint with available states and transitions. You MUST use enum values.
use \Codewiser\Workflow\Example\Enum;
use \Codewiser\Workflow\WorkflowBlueprint;
class ArticleWorkflow extends WorkflowBlueprint
{
public function states(): array
{
return Enum::cases();
}
public function transitions(): array
{
return [
[Enum::new, Enum::review],
[Enum::review, Enum::published],
[Enum::review, Enum::correction],
[Enum::correction, Enum::review]
];
}
}Use HasWorkflow trait and make a method(s) that will return
StateMachine object associated with an attribute. There may be a few
workflows at the same time. Each method MUST be marked with Workflow
attribute.
use Codewiser\Workflow\Attributes\Workflow;
use Codewiser\Workflow\Traits\HasWorkflow;
use Codewiser\Workflow\StateMachine;
use Codewiser\Workflow\Example\ArticleWorkflow;
use Codewiser\Workflow\Example\Enum;
use Illuminate\Database\Eloquent\Model;
/**
* @property Enum $state Current workflow state.
*/
class Article extends Model
{
use HasWorkflow;
protected function casts(): array
{
return [
'state' => Enum::class
];
}
/**
* @return StateMachine<self, Enum>
*/
#[Workflow]
public function state(): StateMachine
{
return $this->workflow(ArticleWorkflow::class, 'state');
}
}WorkflowObserver observes Model and keeps state machine consistency healthy.
use \Codewiser\Workflow\Example\Article;
use \Codewiser\Workflow\Example\Enum;
// creating: will set proper initial state
$article = new Article();
$article->save();
assert($article->state === Enum::new);
// updating: will examine state machine consistency
$article->state = Enum::review;
$article->save();
// No exceptions thrown as such transition exists
assert($article->state === Enum::review);
$article->state = Enum::new;
$article->save();
// throws TransitionException as such transition doesn't existIn an example above we describe blueprint with enum values, but actually they will be transformed to the special objects. Those objects bring some additional functionality to the states and transitions, such as human-readable captions, routing rules, pre- and post-transition callbacks etc...
use \Codewiser\Workflow\Example\Enum;
use \Codewiser\Workflow\State;
use \Codewiser\Workflow\Transition;
use \Codewiser\Workflow\WorkflowBlueprint;
class ArticleWorkflow extends WorkflowBlueprint
{
public function states(): array
{
return [
State::make(Enum::new)->as(__('New')),
State::make(Enum::review)->as(__('Review')),
State::make(Enum::published)->as(__('Published')),
State::make(Enum::correction)->as(__('Correction')),
];
}
public function transitions(): array
{
return [
Transition::make(Enum::new, Enum::review),
Transition::make(Enum::review, Enum::published),
Transition::make(Enum::review, Enum::correction)
// Set caption as a string
->as(__('Need correction')),
Transition::make(Enum::correction, Enum::review)
// Set caption with callable
->as(fn(Article $article) => __('Review :name', [
'name' => $article->name
])),
];
}
}If a Transition has no caption, it will use the caption of its target State.
As model's actions are not allowed to any user, as changing state is not allowed to any user.
When describing the workflow blueprint, you may implement authorization
method. This method is used to authorize running transitions by default.
If default authorization returns
null— all transitions allowed to any user.
You may override authorization for every Transition.
Authorization callback may return bool, Response or throw an
AuthorizationException.
use Codewiser\Workflow\Context;
use Codewiser\Workflow\Transition;
use Codewiser\Workflow\WorkflowBlueprint;
class ArticleWorkflow extends WorkflowBlueprint
{
public function authorization() : ?callable
{
// Default authorization for all transitions
return fn(Article $article, Context $context)
=> Gate::authorize('transit', [$article, $context->transition()]);
}
public function transitions(): array
{
// Authorization for a single transition
return [
Transition::make(Enum::new, Enum::review)
->authorizedBy(fn(Article $article, Context $context)
=> Gate::authorize('transit', [$article, $context->transition()])
);
];
}
}When accepting user request, do not forget to authorize workflow state changing.
use Codewiser\Workflow\Example\Enum;
use Codewiser\Workflow\Example\Article;
use Codewiser\Workflow\Transition;
use Illuminate\Http\Request;
public function update(Request $request, Article $article)
{
// Authorize update
Gate::authorize('update', $article);
if ($state = $request->enum('state', Enum::class)) {
// Authorize transition to a new state
$article->state()->authorize($state);
}
$article->fill($request->validated());
$article->save();
}To get only transitions that are authorized to the current user, use
authorized filter of TransitionCollection. Then pass the list of allowed
transitions to the front-end.
use Codewiser\Workflow\Example\Article;
use Codewiser\Workflow\Transition;
use Illuminate\Http\Resources\Json\JsonResource;
public function show(Article $article)
{
Gate::authorize('view', $article);
return JsonResource::make($article)
->additional([
'transitions' => $article->state()
// Get available transitions
->transitions()
// Filter only authorized transitions
->authorized();
])
}In some cases workflow routes may divide into branches. Way to go is forced by business logic, not by user. User even shouldn't know about other ways.
use \Codewiser\Workflow\Example\Enum;
use \Codewiser\Workflow\Transition;
Transition::make(Enum::new, Enum::to_local_manager)
->when(fn(Order $model) => $model->amount <= 1000000);
Transition::make(Enum::new, Enum::to_region_manager)
->unless(fn(Order $model) => $model->amount <= 1000000); User will see only one possible transition depending on order amount value.
Transition becomes forbidden if its target State is forbidden too.
Transition may have some conditions to run. If model fits this conditions then the transition is possible.
If transition doesn't meet the condition, the callback should return human-readable description of a problem.
Here is an example of problems user may resolve.
use \Codewiser\Workflow\Example\Enum;
use \Codewiser\Workflow\Transition;
Transition::make(Enum::new, Enum::review)
->condition(function(Article $model) {
if (strlen($model->body) < 1000) {
return 'Your article should contain at least 1000 symbols. Then you may send it to review.'
}
})
->condition(function(Article $model) {
if ($model->images->count() == 0) {
return 'Your article should contain at least 1 image. Then you may send it to review.';
}
});User will see problematic transitions in a list of available transitions. User follows instructions to resolve an issue and then may try to run a transition again.
Transition inherits conditions from its target State.
Sometimes a transition requires an additional context to run. For example, it may be a reason why the article was rejected by the reviewer.
First, declare validation rules in transition or state definition. You may
declare just validation rules as an array, or use Validation object.
use Codewiser\Workflow\Example\Enum;
use Codewiser\Workflow\Transition;
use Codewiser\Workflow\Validation;
Transition::make(Enum::review, Enum::reject)
->context([
'reason' => 'required|string|min:100'
]);
Transition::make(Enum::review, Enum::reject)
->context(Validation::rules([
'reason' => 'required|string|min:100'
])->messages([
'reason.required' => 'Describe why you rejecting the article.'
])
);Transition context rules includes the context rules of its target State.
Next, handle the context in the controller.
When creating a model:
use Codewiser\Workflow\Example\Article;
use Illuminate\Http\Request;
public function store(Request $request)
{
Gate::authorize('create', Article::class);
$article = new Article();
$article->fill($request->all());
$article->state()
// Init workflow with additional context
->init($request->all());
// Now save model
$article->save();
}When transiting model:
use \Codewiser\Workflow\Example\Enum;
use Codewiser\Workflow\Example\Article;
use Illuminate\Http\Request;
public function update(Request $request, Article $article)
{
Gate::authorize('update', $article);
if ($state = $request->enum('state', Enum::class)) {
$article->state()
// Authorize transition
->authorize($state)
// Transit to the new state, passing additional context
->transit($state, $request->all())
// Now save model
->save();
}
}The context will be validated while saving, and you may catch a
ValidationException.
After all you may handle validated user data in events.
Sometimes we need to add some additional attributes (not only caption) to the workflow states and transitions. For example, we may group states by levels and use this information to color states and transitions in user interface.
use \Codewiser\Workflow\Example\Enum;
use \Codewiser\Workflow\State;
use \Codewiser\Workflow\Transition;
use \Codewiser\Workflow\WorkflowBlueprint;
class ArticleWorkflow extends WorkflowBlueprint
{
protected function transitions(): array
{
return [
Transition::make(Enum::new, Enum::review)
// Set single attribute as a string
->attribute('level', 'warning'),
Transition::make(Enum::review, Enum::published)
// Set single attribute with callable
->attribute('level', fn(Article $article) => 'success'),
Transition::make(Enum::review, Enum::correction)
// Set multiple attributes with array
->attributes([
'level' => 'danger'
]),
Transition::make(Enum::correction, Enum::review)
// Set multiple attributes with callable
->attributes(fn(Article $article) => [
'level' => 'warning'
])
];
}
}Transition will inherit attributes from its target State.
For user to interact with model's workflow we should pass the data to a
front-end of the application. StateMachine object is Arrayable, so
passing it is enough.
use Codewiser\Workflow\Example\Article;
use Illuminate\Http\Resources\Json\JsonResource;
public function view(Article $article)
{
return JsonResource::make($article)
->additional([
'state' => $article->state()
]);
}The payload of StateMachine object will be like that. We hope this is
enough to build a user interface.
{
"value": "review",
"name": "Review",
"transitions": [
{
"source": "review",
"target": "publish",
"name": "Publish",
"issues": [
"Publisher should provide a foreword."
],
"level": "success"
},
{
"source": "review",
"target": "correction",
"name": "Need correction",
"context": {
"rules": {
"reason": ["required", "string", "min:100"]
},
"messages": {
"reason.required": "Describe why you rejecting the article."
}
},
"level": "danger"
}
]
}You may define state callback(s), that will be called then state is reached.
Callback is a callable with Model and optional Context arguments.
Callback may be defined as on Transition, as on State.
There are two type of callbacks: saving and saved. It is absolutely the
same as well-known Eloquent events.
use \Codewiser\Workflow\Example\Enum;
use \Codewiser\Workflow\Context;
use \Codewiser\Workflow\Transition;
Transition::make(Enum::review, Enum::correcting)
->context(['reason' => 'required|string|min:100'])
->saving(function (Article $article, Context $context) {
$article->last_problem = $context->data()->get('reason');
})
->saved(function(Article $article, Context $context) {
$article->author->notify(
new ArticleHasProblemNotification(
$article, $context->data()->get('reason')
)
);
}); You may define few callbacks to a single transition.
State machine will invoke both sets of callbacks: from Transition and from its target State.
Transition generates ModelInitialized and ModelTransited events.
You may define listener to handle it.
use \Codewiser\Workflow\Example\Enum;
use \Codewiser\Workflow\Events\ModelTransited;
class ModelTransitedListener
{
public function handle(ModelTransited $event)
{
if ($event->model instanceof Article) {
$article = $event->model;
if ($event->context->target()->is(Enum::correction)) {
// Article was send to correction, the reason described in context
$article->author->notify(
new ArticleHasProblemNotification(
$article, $event->context->data()->get('reason')
)
);
}
}
}
}Chargeable transition will fire only then accumulates some charge. For example, we may want to publish an article only then at least three editors has accepted it.
use Codewiser\Workflow\Example\Article;
use Codewiser\Workflow\Example\Enum;
use Codewiser\Workflow\Charger;
use Codewiser\Workflow\Context;
use Codewiser\Workflow\Transition;
Transition::make(Enum::review, Enum::publish)
->context(['comment' => 'required'])
->chargeable(Charger::make(
progress: function(Article $article) {
// Return float (0÷1) with charge progress.
return $article->votes->count() / 3;
},
callback: function(Article $article, Context $context) {
// Store transition charge increment.
// It wouldn't be such a bad idea
// to validate user data from given context.
$data = validator(...$context->validation())->validated();
$article->votes->add(auth()->user());
})
// Optional callbacks
->allow(function (Article $article, Context $context) {
// Prevent charging twice!
return $article->votes->doesntContain(auth()->user());
})
->withHistory(function (Article $article, Context $context) {
// Provide votes history to a front-end
return $article->votes->toArray();
})
);The package may log transitions to a database table.
Register \Codewiser\Workflow\WorkflowServiceProvider.
Publish and run migrations:
php artisan vendor:publish --tag=workflow-migrations
php artisan migrate
It's done.
To get historical records, add \Codewiser\Workflow\Traits\HasTransitionHistory
to a Model with workflow. It brings transitions relation.
Historical records presented by \Codewiser\Workflow\Models\TransitionHistory
model, that holds information about transition performer, source and target
states and a context, if it were provided.
Sometimes you may need to eager load the latest transition:
Article::query()->withLatestTransition();Or:
$article->loadLatestTransition();You may add a constraining:
Article::query()->withLatestTransition(
performer: fn(MorphTo $builder) => $builder->withTrashed(),
transitionable: fn(MorphTo $builder) => $builder->withTrashed()
);