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

Formflow Bundle Laravel Package

megawilddaddy/formflow-bundle

Formflow Bundle is a PHP package for managing form flows in bundle-based applications, offering structure for defining form steps and handling submissions so developers can organize form-driven workflows with lightweight integration.

View on GitHub
Deep Wiki
Context7

Getting Started

Read this first: megawilddaddy/formflow-bundle is an unofficial rename/fork of craue/CraueFormFlowBundle. The repo URL you were given is the upstream project, but the Packagist name you'd install is the fork. Before anything else, confirm which one you actually want:

composer show craue/formflow-bundle        # upstream, maintained
composer require megawilddaddy/formflow-bundle   # fork, last release 2021, license NOASSERTION

Unless you have a hard reason (pinned Symfony version, local patch), install craue/formflow-bundle and treat the fork as a fallback. The API is the same.

Hard prerequisite: this is a Symfony bundle. It depends on symfony/form, the DI container, and HttpKernel. There is no Laravel adapter and no realistic way to mount it in a Laravel app — you cannot "just require it" next to illuminate/* and get working multi-step forms. The realistic options for a Laravel team are:

  1. Use it inside a Symfony service/app that owns the wizard, and call that from Laravel over HTTP.
  2. Re-implement the same mental model in Laravel (session-held wizard state + per-step FormRequest + PRG). The patterns below translate almost 1:1; only the class names change.

Assuming option 1 (or a Symfony colleague's codebase), the setup is short:

// config/bundles.php
Craue\FormFlowBundle\CraueFormFlowBundle::class => ['all' => true],

The bundle's config (config/packages/craue_form_flow.yaml) rarely needs touching — session storage is the default. First place to read is Resources/doc/index.md in the repo; that is the entire manual, and it is good. Second place is Form/FormFlow.php — specifically loadStepsConfig(), bind(), createForm(), saveCurrentStepData(), nextStep(), reset().

First use case — a 3-step "create vehicle" wizard:

// src/Form/CreateVehicleFlow.php
namespace App\Form;

use Craue\FormFlowBundle\Form\FormFlow;

class CreateVehicleFlow extends FormFlow
{
    protected function loadStepsConfig(): array
    {
        return [
            ['label' => 'vehicle',      'form_type' => VehicleType::class],
            ['label' => 'engine',       'form_type' => EngineType::class],
            ['label' => 'confirmation', 'form_type' => ConfirmationType::class],
        ];
    }
}

Register it as a service (autowiring picks up form.factory from the parent), then drive it from one controller action that handles every step:

public function create(Request $request, CreateVehicleFlow $flow): Response
{
    $flow->bind($request);

    if ($response = $flow->redirectAfterSubmit('app_vehicle_create')) {
        return $response;
    }

    $form = $flow->createForm();

    if ($flow->isValid($form)) {
        $flow->saveCurrentStepData($form);

        if ($flow->nextStep()) {
            $form = $flow->createForm();      // render the next step in the same request
        } else {
            $this->em->persist($flow->getData());
            $this->em->flush();
            $flow->reset();

            return $this->redirectToRoute('app_vehicle_created');
        }
    }

    return $this->render('vehicle/create.html.twig', [
        'form' => $form->createView(),
        'flow' => $flow,
    ]);
}

Implementation Patterns

One flow class per wizard, one action per flow. The controller is an orchestrator, not business logic: bind → redirectAfterSubmit → createForm → isValid → saveCurrentStepData → nextStep. Everything else lives in the flow class (loadStepsConfig) and the form types. This is the pattern you'll see in every example and it scales to 8–10 steps without change.

One DTO, many form types. The flow owns a single data object ($flow->getData(), set with setData() before the first step). Each form_type binds only the properties relevant to its step. Keep the DTO a plain PHP object with scalars — not a Doctrine entity with associations — because session storage serializes it (see Gotchas).

Conditional steps via skip. Returning a closure from a step's skip key makes the flow recompute the step sequence on every request, and the step list/back-navigation follows automatically:

[
    'label' => 'engine',
    'form_type' => EngineType::class,
    'skip' => fn ($flow) => !$flow->getData()->needsEngine(),
],

Per-step validation. Do not rely on the default validation group across the whole DTO — step 1 will fail on step 3's constraints. Use form_options:

['label' => 'vehicle', 'form_type' => VehicleType::class,
 'form_options' => ['validation_groups' => ['step_vehicle']]],

Then annotate/configure constraints per group.

PRG and browser-back. redirectAfterSubmit() exists specifically to prevent double submission when a user refreshes mid-flow; wire it at the top of the action. For user-initiated back navigation, enable dynamic step navigation ($flow->setAllowDynamicStepNavigation(true)) so the step list becomes clickable — this requires the step query parameter to survive on the form's route, so keep action pointing at the same URL.

Rendering. Include the bundle's step list and render the three transition buttons by hand:

{{ include('@CraueFormFlow/FormFlow/stepList.html.twig') }}

{{ form_start(form) }}
    {{ form_widget(form) }}

    {% if flow.currentStepNumber > 1 %}
        <button type="submit" name="flow" value="back">Back</button>
        <button type="submit" name="flow" value="reset">Reset</button>
    {% endif %}
    <button type="submit" name="flow" value="next">
        {{ flow.lastStep ? 'Finish' : 'Next' }}
    </button>
{{ form_end(form) }}

Laravel translation of the same shape, if you go that route: session key wizard.vehicle holds the partial payload; a VehicleStep{n}Request per step; a controller match ($request->input('flow')) on back/reset/next; redirect()->route(...) after a successful step transition to get PRG for free; a WizardService that mirrors saveCurrentStepData/nextStep. The skip closures become a shouldSkip($payload) method on the service. That's the whole bundle, conceptually.

Gotchas and Tips

Supply-chain. 0 stars, last release March 2021, NOASSERTION license. NOASSERTION means the license file is ambiguous/unrecognized to Composer's tooling — legal will care. Pin the upstream craue/formflow-bundle and, if you need the fork's patch, vendor the diff explicitly. Also check the Symfony version constraint: a 2021 release likely caps below Symfony 6, so a modern app may not install it at all.

Session serialization bites. With default session storage, the entire data object is serialized into the session between requests. Uploaded files can't survive that — enable $flow->setAllowFileUploads(true) if you use file inputs (e.g. VichUploader) and be prepared to handle the re-upload on navigation. Doctrine entities with lazy proxies do not serialize cleanly; store IDs or a DTO.

Call order matters. saveCurrentStepData($form) must run before nextStep() — otherwise the flow advances with stale data. In the same vein, nextStep() returns false on the last step, so the "persist and redirect" branch is the else, not an afterthought.

reset() or it comes back. After persisting, call $flow->reset(). Skip it and a user starting a second wizard will see the previous payload prefilled — the single most common bug report on this bundle.

Step numbering is 1-based and skip-aware. getCurrentStepNumber() is a flow position, not an index into loadStepsConfig(). With skip active, don't index step arrays by current step number; use getCurrentStep() / getSteps().

Submit button name is a magic string. The name="flow" value="back|next|reset" convention is what FormFlow::getRequestedTransition() reads. If back/reset silently do nothing, that's the first thing to check — confirm the exact parameter name in the version you installed rather than trusting a snippet. redirectAfterSubmit()'s exact signature has also changed across versions; read it before copying.

Multiple flows in one session collide unless each gets a distinct identifier; check setId/setRequest on FormFlow in your pinned version before building a dashboard with two wizards.

Debugging. The state is plain session data — dump($flow->getData(), $flow->getCurrentStepNumber(), $flow->getRequestedTransition()) at the top of the action answers 90% of questions. In tests, construct the flow with form.factory, bind(Request::create(..., 'POST', [... 'flow' => 'next'])), and assert on getCurrentStepNumber() — no browser needed.

Extension points worth knowing: subclass FormFlow to override getFormOptions(), loadStepData()/saveStepData() (if your DTO needs non-property mapping), or getUploadedFile(); swap storage by implementing StorageInterface and rebinding the storage service; override @CraueFormFlow/FormFlow/stepList.html.twig in your app's templates to restyle the tracker without touching the flow.

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.
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
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console