Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Flow Bundle Laravel Package

sylius/flow-bundle

Sylius FlowBundle provides a flexible workflow and state-machine layer for Symfony applications, letting developers define flows, steps, transitions, and callbacks to model checkout, order, or custom business processes with clean, configurable PHP code.

View on GitHub
Deep Wiki
Context7

Getting Started

Reality check before you composer require anything. sylius/flow-bundle is a Symfony bundle (services, CompilerPass, @SyliusFlowBundle/Resources/config/routing.yml), archived read-only, with zero dependents. Every piece of its integration surface — the config tree, the container, the event dispatcher — is Symfony-shaped and will not port to Laravel. The concepts port in about a day. Treat the repo as a spec, not a dependency.

Where to look first in the source (read in this order, ~90 minutes):

  1. Process/Builder/ProcessBuilder.php — the step DSL (add('welcome', 'service.id')). This is the whole API in one file.
  2. Process/Step/ControllerStep.php — the displayAction() / forwardAction() contract and the complete() / redirect() return values. This is the step-result vocabulary you actually need.
  3. Process/Context/ProcessContext.php — what a step can see: request, storage, current step, progress.
  4. Process/Step/StepInterface.php and Process/Result/* — the minimal contract to copy verbatim.
  5. Process/Storage/* — how partial data is persisted between steps (session in the default implementation).

Everything else is Symfony plumbing you can skip.

First use case: a 3–4 step setup wizard (welcome → DB/API credentials → admin user → done), or a stripped-down linear checkout. Do not start with branching flows.

Minimal Laravel scaffold that replaces it. Write these five small classes yourself; they are the entirety of what the bundle gives you.

composer require symfony/workflow   # optional, only if you need guards/branching
php artisan make:middleware EnsureFlowStep
// app/Flow/Step.php
interface Step
{
    public function display(FlowContext $context): \Illuminate\Http\Response|\Illuminate\View\View;
    public function forward(FlowContext $context): StepResult;
}

// app/Flow/StepResult.php
final class StepResult
{
    public static function complete(): self { /* advance */ }
    public static function invalid(mixed $payload): self { /* re-render */ }
    public static function redirect(string $route): self { /* out of flow */ }
}
// app/Flow/Flow.php
final class Flow
{
    /** @param array<string, Step> $steps ordered */
    public function __construct(public readonly string $name, private array $steps) {}

    public function step(string $name): Step
    {
        return $this->steps[$name] ?? throw new \InvalidArgumentException("Unknown step {$name}");
    }

    public function names(): array { return array_keys($this->steps); }

    public function next(string $current): ?string
    {
        $names = $this->names();
        $i = array_search($current, $names, true);
        return $names[$i + 1] ?? null;
    }

    public function previous(string $current): ?string { /* mirror */ }
}
// app/Providers/FlowServiceProvider.php
$this->app->singleton(FlowRegistry::class, function () {
    $registry = new FlowRegistry();
    $registry->add(new Flow('setup', [
        'welcome'  => app(WelcomeStep::class),
        'database' => app(DatabaseStep::class),
        'admin'    => app(AdminStep::class),
        'finish'   => app(FinishStep::class),
    ]));
    return $registry;
});

That is the bundle, minus the framework tax. If it grows to need branching or guards, swap the Flow internals for symfony/workflow — the actively maintained, framework-agnostic state machine — and keep the step/context interfaces.

Implementation Patterns

Route-per-step, driven by the flow definition. Laravel route generation from the flow definition keeps URLs and ordering in one place; hand-declared routes drift:

// routes/web.php
use App\Flow\FlowRegistry;

$flow = app(FlowRegistry::class)->get('setup');

foreach ($flow->names() as $step) {
    Route::match(['get', 'post'], "/setup/{$step}", [FlowController::class, 'handle'])
        ->name("setup.{$step}")
        ->middleware(["web", "flow.step:setup,{$step}"]);
}

Wire it from a service provider so routes regenerate from the same definition the manager uses.

One controller, dispatched by flow. The bundle has ProcessController dispatching to the current step's displayAction/forwardAction. Mirror it:

final class FlowController
{
    public function handle(Request $request, string $flowName, string $stepName, FlowRegistry $registry)
    {
        $flow = $registry->get($flowName);
        $step = $flow->step($stepName);
        $context = new FlowContext($request, $flow, $stepName, app(FlowStorage::class));

        if ($request->isMethod('POST')) {
            $result = $step->forward($context);
            if ($result->isComplete()) {
                return redirect()->route("{$flowName}.{$flow->next($stepName)}");
            }
            if ($result->isRedirect()) {
                return redirect($result->route());
            }
        }

        return $step->display($context);
    }
}

Per-step form requests for validation, storage writes only after passes(). This is the pattern the bundle enforces implicitly via forwardAction; make it explicit:

final class DatabaseStep implements Step
{
    public function display(FlowContext $ctx)
    {
        return view('setup.database', ['data' => $ctx->storage()->get('database', [])]);
    }

    public function forward(FlowContext $ctx)
    {
        $data = $ctx->request()->validate([
            'host' => 'required|string',
            'user' => 'required|string',
        ]);

        try { DB::connection()->getPdo(); } catch (\Throwable $e) {
            return StepResult::invalid(['message' => $e->getMessage()]);
        }

        $ctx->storage()->put('database', $data);

        return StepResult::complete();
    }
}

Storage abstraction first, session second. Define the contract on day one so swapping to DB is a one-line rebind. The bundle defaults to session, and that is exactly the constraint you will hit when you scale horizontally or need resume-after-abandonment.

interface FlowStorage
{
    public function get(string $key, mixed $default = null): mixed;
    public function put(string $key, mixed $value): void;
    public function all(): array;
    public function flush(): void;
}

final class SessionFlowStorage implements FlowStorage
{
    public function __construct(private \Illuminate\Contracts\Session\Session $session, private string $flow) {}

    public function get(string $key, mixed $default = null): mixed
    {
        return $this->session->get($this->key($key), $default);
    }

    // ...
    private function key(string $k): string { return "flow:{$this->flow}:{$k}"; }
}

A user-keyed DB store (flow_states with user_id, flow, current_step, payload json, expires_at) is the production version. Build it when a step needs to survive device change or longer-than-session abandonment; don't build it first.

Guard middleware enforces order — this is the load-bearing piece the bundle calls "context." Without it, a user can POST directly to /setup/admin before /setup/database has run:

final class EnsureFlowStep
{
    public function handle(Request $request, Closure $next, string $flow, string $step)
    {
        $current = $request->session()->get("flow:{$flow}:current");

        if ($current === null) {
            return redirect()->route("{$flow}.welcome"); // fresh start
        }
        if ($current !== $step) {
            return redirect()->route("{$flow}.{$current}"); // silently correct
        }

        return $next($request);
    }
}

Update flow:{name}:current in FlowController after complete(), and clear it on the final step.

Back navigation is cheap, forward skip is not. Allow previous() freely — steps re-read from storage and re-render. Never allow the user to skip forward to a step whose prerequisites are unmet; the middleware above handles this. If you need "jump to a completed step," gate it on a per-step isComplete() marker stored alongside the payload.

Testing pattern matches the bundle's design intent. ProcessContext/FlowContext being a plain object is the point — write step tests without touching HTTP:

$ctx = new FlowContext(
    request: Request::create('/', 'POST', ['host' => 'localhost']),
    flow: $flow,
    stepName: 'database',
    storage: new ArrayFlowStorage(),
);

$result = app(DatabaseStep::class)->forward($ctx);
$this->assertTrue($result->isComplete());
$this->assertSame('localhost', $ctx->storage()->get('database')['host']);

Use ArrayFlowStorage in tests; never touch the session in step unit tests.

Gotchas and Tips

Archived means zero upstream — this is the single defining constraint. No security patches for the Symfony bundle, no PHP 8.3 compatibility work, no issue triage. That is disqualifying if anything in the flow touches payments, PII, or compliance. The mitigation is not "pin and hope"; it's don't take the runtime dependency. Read it, port it, own the ~200 lines yourself.

Session-backed state breaks the moment you scale. With two app servers behind a load balancer and default file sessions, a user's step progress depends on which node they hit. You will need Redis (or the DB store). The bundle inherits Symfony's session, so this is not a bug in the bundle — it's a bug in assuming the default is production-ready. Plan the storage abstraction before you plan the feature.

Double-submit on forward duplicates side effects. The user double-clicks "Next," two POSTs land, you create two admin users or two DB configs. Mitigate with one of: a per-step CSRF-bound idempotency key stored in flow state, a unique DB constraint on the persisted artifact, or a DB::transaction + conditional insert. Step-result complete() should be idempotent — writing the storage payload twice is fine; side-effecting twice is not.

Backward navigation re-fires side effects. If AdminStep::forward creates a user, going back and forward again creates another. Either make the step's write an upsert keyed on something stable, or model the step as "validate + store payload" and defer side effects to a Finish step that runs once. The latter is usually cleaner and easier to test.

Abandoned flows leave orphaned state. Session storage is GC'd eventually; DB storage is not. Add expires_at and a scheduled prune command from day one if you go the DB route, otherwise you accumulate dead rows for every abandoned wizard.

complete() and redirect() are semantically different, and the bundle's naming hides it. complete() advances within the flow; redirectAction/redirect() leaves the flow for an arbitrary route. When porting, name them StepResult::advance() and StepResult::redirect() — the confusion cost me a debugging session the first time.

Do not attempt composer require sylius/flow-bundle in a Laravel app. It will install Symfony components alongside Illuminate, and you will spend a day on autoload and container bridging to discover the value was the pattern. If you genuinely want a maintained state machine, symfony/workflow is the framework-agnostic option — it composes into Laravel cleanly (it's plain PHP with no container assumptions) and solves branching/guards properly. It does not solve step UI, storage, or ordering; those remain yours.

symfony/workflow is not a drop-in replacement for step orchestration. It models state transitions (place → pay → ship), not wizard steps with display/process and per-step form state. Use it as the guard/branching engine inside a Flow-shaped class, not as the whole thing.

Config quirks in the bundle you will read past and should not emulate. The scenario alias map (sylius_flow.scenarios.setup.class), the service-tagged sylius.flow.step, and the ControllerStep base class all assume the Symfony container lifecycle. When porting, use plain constructor injection and skip the registry indirection unless you genuinely have multiple flows — one Flow class and a route group is enough for a single checkout.

Extension point worth copying: the StepResult value object. The bundle's Result classes (complete, redirect, invalid) are what let forward stay declarative and testable inside a controller-driven flow. Keep that design. Do not return Response objects from forward — then the step knows about HTTP and your tests get ugly.

Debugging tip. When a step silently re-renders after POST instead of advancing, log the StepResult type and — in the bundle's case — check that complete() actually returned rather than the form failing validation. In Laravel, log ['step' => $stepName, 'result' => $result->type()] at the end of FlowController::handle. Nine times out of ten the guard middleware is fine and a form rule is failing without surfacing a message.

If you must keep an existing dependency (you inherited a Sylius shop). Mark it frozen: no upgrades, pin the exact version, and add a composer outdated check to CI so an accidental bump doesn't ship. Read Process/Step/ControllerStep.php once so you can reason about the checkout path — because when it breaks, nobody upstream is going to help you.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource