juststeveking/workflow-engine
A lightweight workflow engine for Laravel applications. Define workflows with states, transitions, guards, and actions to model approval or business processes, then move records through each step using a clean, expressive PHP API.
Install the package and publish its assets:
composer require juststeveking/workflow-engine
php artisan vendor:publish --tag="workflow-engine-config"
php artisan vendor:publish --tag="workflow-engine-migrations"
php artisan migrate
Where to look first, in order:
config/workflow-engine.php — table names, model bindings, and any guard/event toggles. Most integration surprises live here.HasWorkflow-style trait (or contract) the package expects you to add to your model — that is your primary entry point.First use case: attach the trait to an Eloquent model, declare its allowed states and transitions, then apply a named transition and let the package persist the resulting state plus the audit record.
use JustSteveKing\WorkflowEngine\Concerns\HasWorkflow;
class Order extends Model
{
use HasWorkflow;
protected function workflow(): array
{
return [
'states' => ['draft', 'placed', 'paid', 'shipped'],
'transitions' => [
'place' => ['from' => 'draft', 'to' => 'placed'],
'pay' => ['from' => 'placed', 'to' => 'paid'],
],
];
}
}
$order->apply('place');
Treat the config array as the single source of truth for legal moves — you should not be checking $order->status === 'draft' anywhere else in the codebase once this is wired up.
Wrap transitions in a domain action, not a controller. Controllers should validate intent; the action owns the transition, so retries, jobs, and console commands all go through the same path:
final class PayOrder
{
public function __construct(private PaymentGateway $gateway) {}
public function handle(Order $order): Order
{
return DB::transaction(function () use ($order) {
$this->gateway->charge($order); // side effect first
$order->apply('pay'); // then mutate state
return $order->refresh();
});
}
}
Guard on intent, not on state. Put precondition checks in a policy or a canTransitionTo() call so the UI and the API share one rule set. Rejecting an illegal transition inside the package should be the backstop, not the first line of defence.
Listen for the package's transition events. They are the cleanest seam for notifications, webhooks, and analytics, and they fire once per successful transition rather than on every model save():
Event::listen(TransitionApplied::class, SendOrderReceipt::class);
Use the history table as your audit log. Rather than a parallel order_events table, read transitions back from the package's records and join them to your own metadata if the package exposes a meta/payload column.
Keep workflows in an enum for the state names. A backed enum gives you IDE completion, a single place to rename, and typed comparisons — while the package stores strings in the database layer.
Testing and factories. v1.1.2 makes the factory resolution extensible: a subclassed model can now declare its own factory rather than inheriting whatever the parent hardcoded. If you extend the package's base workflow/transition model — for example to add a tenant column, a custom table, or seeded state fixtures — give the subclass a factory:
class TenantWorkflow extends Workflow
{
protected static function newFactory(): WorkflowFactory
{
return TenantWorkflowFactory::new();
}
}
Before v1.1.2 this override was effectively ignored and your subclass would be instantiated through the base factory, producing rows without your custom attributes. If you subclass any package model, pin ^1.1.2 in composer.json.
Seed realistic state. In tests, drive models through the transitions themselves rather than setting the state column directly. It's slower but it exercises the guards and produces the history rows that your assertions and observers depend on.
composer update.php artisan config:cache after changing config. Transition definitions read from config will appear to be ignored in production if the cache is stale — a very common false bug report.DB::transaction or you will eventually get a charged card against a placed order. Note the ordering in the example above: side effect inside the transaction, transition last, so a gateway exception leaves the state untouched.from/to values exist in states.saved observer applies a transition, and that transition saves the model again, you get an infinite loop that only shows up under load. Prefer the package's transition events, which fire after commit semantics you control.(model_type, model_id) if it isn't already indexed.draft and both attempt place. Use lockForUpdate() inside the transaction, or the package's optimistic check if it exposes one, before calling apply().newFactory(), return an instance (Factory::new()), not a class-string, and make sure Factory::guessFactoryNamesUsing hasn't already resolved your subclass to the wrong namespace. If your factory produces the right class but the wrong state, the bug is in your states definition, not the factory.newFactory() that was written defensively against the old behaviour, re-run your test suite to confirm it still returns the expected models.How can I help you explore Laravel packages today?