pixelworxio/livewire-workflows
Build multi-step Livewire workflows in Laravel with zero boilerplate. Define onboarding, checkouts, and surveys using an expressive DSL and get automatic route registration, guard-based navigation, state persistence, and Livewire 4.x support.
Install and scaffold
composer require pixelworxio/livewire-workflows
php artisan workflows:install # publishes config + routes/workflows.php
php artisan migrate # required for Eloquent-backed state
Confirm the two things that actually matter after install:
routes/workflows.php exists (this is where every flow lives — it is loaded by the service provider, not a normal route file).config/livewire-workflows.php exists and has a repository key (session, eloquent, or null). Pick this deliberately now; switching later is the workflows:upgrade path.Where to look first
routes/workflows.php — the only file you'll touch daily.php artisan workflows:scan — prints every registered flow, its entry route, steps, and generated route names. Run this before you debug anything else; it tells you instantly whether your DSL was parsed.php artisan workflows:audit — flags config problems (missing finishesAt(), duplicate ordering, guards that don't implement the contract).pixelworxio/livewire-workflows-testbench) — the README's examples are thin; the testbench has full working components.First use case: gate an onboarding flow
// routes/workflows.php
use Pixelworxio\LivewireWorkflows\Facades\Workflow;
Workflow::flow('onboarding')
->entersAt(name: 'onboarding.start', path: '/onboarding')
->finishesAt('dashboard')
->step('verify-email')
->goTo(\App\Livewire\Onboarding\VerifyEmail::class)
->unlessPasses(\App\Guards\EmailVerifiedGuard::class)
->order(10)
->step('profile')
->goTo(\App\Livewire\Onboarding\EditProfile::class)
->unlessPasses(\App\Guards\ProfileCompletedGuard::class)
->order(20);
php artisan make:workflow-guard EmailVerified
The generated guard implements GuardContract. Fill in passes():
public function passes(Request $request): bool
{
return $request->user()->hasVerifiedEmail();
}
Then annotate the component and add the trait:
#[WorkflowStep(flow: 'onboarding', key: 'verify-email')]
class VerifyEmail extends Component
{
use InteractsWithWorkflows;
public function goToNextStep(): void
{
$this->continue('onboarding');
}
}
Hit /onboarding. The package redirects to the first step whose guard fails. When all guards pass, it redirects to dashboard. That redirect-on-entry behaviour is the core mental model — the entry route is a dispatcher, not a page.
The pattern that scales is: never let a component decide "am I done?" — let a guard decide, and have the component just mutate the domain model and call continue().
class ProfileCompletedGuard implements GuardContract
{
public function passes(Request $request): bool
{
return filled($request->user()->profile->job_title);
}
public function onPass(Request $request): void {}
public function onEnter(Request $request): void {}
public function onExit(Request $request): void {}
public function onFail(Request $request): void {}
}
Because guards are re-evaluated from the entry point on every navigation, guards must be cheap and side-effect free in passes(). Use onPass/onFail/onEnter/onExit for the side effects (timestamps, analytics, notifications) — those are the observer hooks, not passes().
For "soft" steps (a survey the user may skip), the same guard pattern works: return true when they've already answered, and add a skip() method on the component that continue()s without filling anything in — but only if the guard passes on the next entry. If skipping shouldn't satisfy the guard, you need a separate "seen/skipped" flag persisted in workflow state instead.
order() is the only sequencing mechanism — there are no explicit after: dependencies. Use gaps of 10 so you can insert steps without renumbering:
->step('cart')->order(10)
->step('shipping')->order(20)
->step('payment')->order(30)
Guards that depend on earlier steps simply read the same model, so ordering is mostly about UX intent. Keep the order() values in routes/workflows.php and out of the components.
Prefer #[WorkflowState] over manual session reads. It auto-hydrates on mount and persists on both Livewire's updated() hook and on dehydrate.
#[WorkflowName('checkout')]
class CheckoutShipping extends Component
{
use InteractsWithWorkflows;
#[WorkflowState]
public ?string $address = null;
#[WorkflowState(namespace: 'shipping')]
public ?string $method = null;
#[WorkflowState(encrypt: true)]
public ?string $cardToken = null; // only tokens, not PANs
}
Two rules that save pain:
address in checkout and address in onboarding collide otherwise.Reach for the imperative API (putWorkflowState() / getWorkflowState() / clearWorkflowState()) for non-property values like arrays, IDs, or computed snapshots — e.g. clearing a cart namespace on completion.
->entersAt(name: 'checkout.start', path: '/shop/{shop:slug}/checkout/{order}')
continue() and back() re-extract route parameters from the current request automatically, so you don't have to thread them through. The component just needs mount($shop, $order) to receive them. This is the pattern for per-tenant or per-entity flows; without it you end up duplicating flows per tenant.
$progress = workflow('onboarding')->progressFor(request());
// total, completed, remaining, percentage, current_step, next_step, is_complete
Call progressFor() from a layout partial rather than each step component, so the progress bar stays consistent and the query cost is paid once.
For abandoned flows, eloquent repository plus signed resume links:
$url = workflow('onboarding')->resumeUrlFor(user: $user, expiresInMinutes: 2880);
Put this in a scheduled command or a "come back and finish" mailable. Note the security caveat from the README: the signature proves the URL is authentic, not who is opening it — keep auth middleware on the resume route.
Event::listen(WorkflowAdvanced::class, fn ($e) =>
Analytics::track('workflow_advanced', [
'flow' => $e->flow, 'from' => $e->fromKey, 'to' => $e->toKey,
])
);
This is the cheapest way to get funnel metrics — one listener covers every flow, and WorkflowCompleted doubles as the trigger for "onboarding finished" emails.
php artisan make:workflow checkout
php artisan make:workflow-step checkout payment \
--component=App\\Livewire\\Checkout\\Payment \
--guard=App\\Guards\\CartNotEmptyGuard \
--order=20
These append to routes/workflows.php correctly and generate the matching component/guard stubs. Use them for new steps even on mature flows — it's faster than remembering the DSL method order, and the generated component already has the right attribute and trait.
passes() = true means SKIP, not "allowed". This is the #1 source of inverted flows. Read it as unlessPasses() = "show this step unless the guard says you're done." Write a guard test for this before you write the step:
it('shows verify-email when unverified', function () {
$this->actingAs(User::factory()->unverified()->create())
->get('/onboarding')
->assertRedirect('/onboarding/verify-email');
});
A flow without finishesAt() may never register. The FlowBuilder auto-builds in its destructor only when finishesAt() was called. If workflows:scan doesn't list your flow, check for a missing finishesAt() and for a route name that doesn't exist. finishesAt() takes a route name, not a path — a typo silently produces a flow that redirects nowhere.
The entry route is not a page. Any component or view pointing at onboarding.start as a "landing page" will be surprised by the redirect. Link to the entry route and let the dispatcher do its job; don't link to step route names directly, or you bypass guard evaluation order.
Cache invalidation after editing workflows. Definitions are evaluated at boot from routes/workflows.php. After any DSL change, run php artisan optimize:clear (and route:clear if you cache routes). Stale route caches are the usual cause of "my new step 404s."
Session state doesn't survive login/logout. The user key resolution is: auth ID → session ID → guest-{md5(IP + UserAgent)}. A guest starting checkout and then logging in loses their step. For any flow that crosses an auth boundary (signup → onboarding), use the eloquent repository from day one.
Guest fingerprint is IP + User-Agent. Mobile users switching networks mid-flow lose progress. Again: eloquent for anything that matters.
Dual-layer persistence means state is written twice. Properties are persisted on updated() and on dehydrate. That's a reliability feature, but it means writes happen frequently — don't mark large arrays as #[WorkflowState] and expect it to be free. For big payloads, store an ID and reload the model.
#[WorkflowStep] key must match the DSL step key exactly. A mismatch silently falls back to route-based workflow detection, and continue() with no argument then has no flow to advance. If continue() does nothing, dump the resolved flow name first.
continue() can be called with no argument only when the attribute is present. Outside that, pass the flow name explicitly; the fallback is route-name parsing, which breaks for dynamic routes with parameters if the component isn't mounted via the workflow route.
Guards are container-resolved. Constructor injection works, so a guard can depend on a repository or a service. Keep it lightweight — it runs on every navigation from the entry route, i.e. potentially on every step transition.
back() needs historyMode('stack'). Default history mode won't let you return to the previous step. Set it on FlowBuilder if your UI has a Back button; otherwise use your own breadcrumbs.
Test the flow, not the components. The high-value tests are: actingAs($user)->get($flow.'.start') and assert the redirect target, run once per guard state. That's two or three tests per flow and it covers routing, ordering, and guard semantics in one shot.
Maturity caveat. 91 stars, no dependents, and a rapid release cadence. Pin the version in composer.json, read CHANGELOG.md before upgrading (there's an UPGRADE.md for the session→Eloquent migration), and prefer the documented public API (Workflow facade, InteractsWithWorkflows, GuardContract) over internals like WorkflowEngine or WorkflowRegistrar. If you must extend internals, bind your own WorkflowStateRepository implementation through the container rather than subclassing — that's the seam the package is explicitly designed around.
How can I help you explore Laravel packages today?