Woodlandtrek's avatar

Best Practices for Multi-Step form validation (wizard)

Hi, I'm working on an ecommerce project and can't wrap my head around a clean way to validate the information from the checkout process. I'll essentially have 4 steps: 1-Login/Register/GuestCheckout, 2-Shipping Address, 3-Shipping Method, 4-Billing Information.

Each step will need to be validated, as well as verify that all information from previous steps is still valid and unchanged. One of the tricky parts is that there are several variables that change the requirements for the data entered: guest checkout doesn't require a user, but does require an email; foreign countries don't require a state; digital or subscription items don't require a shipping address or shipping method.

Have any of you encountered this task before, and how did you approach it? Certainly everything could be put in the CheckoutController, but I'm sure a puppy would be killed somewhere if I did that.

0 likes
25 replies
rowanwins's avatar

+1 from me, perhaps there could be a video on the basics of multi-page forms?

1 like
pmall's avatar

An action for each steps and store the data in session for each steps, then save everything on the last step ? I've made nine steps conditionals forms this way.

Woodlandtrek's avatar

@roderick Angular could take care of the interface, but then I feel like I have to handle all those conditionals both in the angular app and on the server side (because I still need to validate all that data when it comes back to the server).

@pmall That's what I'm thinking of doing. Do you validate each step independently, and then do one final validation at the end that revalidates everything? Or how do you prevent parts from getting changed (say, in another browser window)?

I came across Kirk Bushell's Laracon EU talk last night and I'm going to try setting up the validation like he suggests. He recommends having a separate validation layer that has a getRules() method where the validation rules can be calculated depending on the data passed in.

Woodlandtrek's avatar

I thought the new FormRequests in L5 might be the solution, but I don't see a way to pass external data in. Those classes are instantiated automatically through reflection and provide access to the request data, but anything else I would have to hard code in the class itself (which would make testing difficult).

pmall's avatar

@Woodlandtrek never through about multiple windows (it was for a local app for my business). I think if you validate each step correctly and if you check that the session data coming from the previous step are coherent (otherwise redirect to the previous step, etc), everything should be fine even with multiple windows. You can validate everything on the last step so you can sleep at night but it might be overkill.

Idea : you can have a laravel 5 form request for each steps with it's own rules and a authorize method that check the session data from the previous step are coherent. Redirect to the previous step if the authorization fails.

rowanwins's avatar

Thanks @roderick and @pmall for the suggestions. I started playing with angular today, learning 2 new frameworks in 2 weeks certainly has its challenges, my mind is being stretched in a fun way!

keevitaja's avatar

Well in 5.0 you could just check which rules to use In Requests class rules() method.

unitedworx's avatar

i think the best way to think about this is that you have 4 different forms!

Validate each one of them how you would normally do. But here you need to save the validated form data in the session before redirecting to the next one. Also each form needs to make sure that data from the previous form is available in the session as well for it to be valid!

jaysen's avatar

I'm about to try to implement a wizard form (multiple steps).

  • I would like there to be some conditional logic as well.

Example:

  • Do you want to travel domestically or internationally?

-> if domestic show wizard2a, if international show wizard 2b

Was hoping there was some update on this topic sine Laravel 5 has been released now.

pmall's avatar

@jaysen you could just return different views in your controller action according to the previous answers stored in session.

About laravel 5, I think about doing form-request-like classes implementing the ValidatesWhenResolved interface, using session data instead of input data. And inject them in controller actions like regular form requests. Hehe :)

jaysen's avatar

@pmall

My resource is under: app/resources/views/profile/ step1.blade.php

Having different views such as: step1.blade.php step2.blade.php step3.bade.php

Is clear and a good idea.

Then the code within step1.blade.php: {!! Form::open( array('url' => 'step2', 'class' => 'form-horizontal')) !!}


But then I need to consider where the store, update, edit... methods would be organized.

So instead, I'm thinking of having a resource for each step in the wizard. app/resources/views/profile-step1.blade.php app/resources/views/profile-step2.blade.php

And then having ProfileStep1Controller() which handles store, update, edit... methods for each step.

I would have two submit buttons: would view redirect to the prior step's urls (ProfileStep1Controller.edit) would view redirect the next step's url (ProfileStep2Controller.create)

The final step would redirect to the completed profile.

Thanks for your reply by the way. And let me know if you would do it the same way. I wasn't fully clear on just keeping everything in the session.

pmall's avatar

@jaysen

And then having ProfileStep1Controller() which handles store, update, edit... methods for each step.

You just need one action per step and a final action which persist the data.

jaysen's avatar

@pmall

Ok. But I need to setup each step as it's own resource with a controller and views. Is that what you would do?

jaysen's avatar

@pmall

Have you done a Form Wizard in Laravel with multiple steps (or do you know of a good example of one)?

I'd love to have some example code to discuss.

pmall's avatar

@jaysen I have no example of code. I have no idea why you want to do a resource controller for each steps, you only need one action per step. i would do only one controller for the form with step1, step2, ..., stepN actions.

bluescreen's avatar

I recently implemented a wizard using the laracasts/Commander package. You can simply create a command for every step and keep your controller very clean. In the Commandhandler you can pull in the validation and throw Exceptions if anything fails. You can catch them and redirect in the controller to the refering step. It also makes your code very explicit.

But still i'm very curious how Jeffrey would tackle this. Would be great if he can do a lesson for multipart forms.

5 likes
errorcounter's avatar

@jaysen

I think this is what @pmall means. I have actually the problem that my input rules for my "next"-button only works if i write something in my input field. The "next"-button isn't invalid at the start.

Have someone any idea to solve this?

Woodlandtrek's avatar

For anyone interested, here's the solution I came up with (note that this was originally done in L4.2, so there may be some newer ways of handling this). There are other elements involved here, but I'll try to simplify to the relevant parts for a multi-step form:

Classes involved:

  • CheckoutController: The controller that handles all the steps of the multi-step form. Each of the GET requests to a checkout step begins with this code:
if (!$this->isCustomerAllowedToBeAtThisStep('shipping_method')) {
   return redirect()->route($this->checkoutSteps->currentStep()->getRoute());
        };

Each of the post requests saves the data for that form step, then redirects to the next step with this code:

return redirect()->route($this->checkoutSteps->currentStep()->getRoute());
  • CheckoutStepCollectionFactory - A class that creates a collection of steps involved in the form given certain parameters (in my case, which types of products are in the cart). All branching or conditional step logic is included in this class. It's used like this from the CheckoutController:
$this->checkoutSteps = $checkoutStepCollectionFactory->createFor($this->cart);
  • CheckoutStepCollection - The collection of all the steps involved in this instance of the form. Extends laravel's Collection class and adds some helper methods.
    public function currentStep()
    {
        $this->reSort();
        foreach ($this->items as $step) {
            if (!$step->isFulfilledBy($this->cart)) {
                return $step;
            }
        }

        // Get the last one if all have been completed
        return last($this->items);
    }

    /**
     * Check if all steps have been completed
     */
    public function areComplete()
    {
        $this->reSort();
        foreach ($this->items as $step) {
            if (!$step->isFulfilledBy($this->cart)) {
                return false;
            }
        }
        // All have been completed
        return true;
    }

    /**
     * This function will tell us whether or not a person should have access to a particular
     * step. It's based on the sortOrder of each step.
     */
    public function isAccessible($step)
    {
        if (!$step instanceof BaseCheckoutStep) {
            $step = $this->get($step);
        }

        if (!$step) {
            return false;
        }

        return $step->getOrder() <= $this->currentStep()->getOrder();
    }

    public function isLast($step)
    {
        if (!$step instanceof BaseCheckoutStep) {
            $step = $this->get($step);
        }

        if (!$step) {
            return false;
        }

        return $step === $this->last();
    }

    public function reSort()
    {
        $collection = $this->sortBy(function ($step) {
            return $step->getOrder();
        });

        return $collection->setCart($this->cart);
    }

    public function getSteps()
    {
        return $this->items;
    }

    public function nextStep($currentStep)
    {
        if (!$currentStep instanceof BaseCheckoutStep) {
            $currentStep = $this->get($currentStep);
        }

        if (!$currentStep) {
            return false;
        }

        $next = false;

        foreach ($this->items as $step) {
            if ($next == true) {
                return $step;
            }

            if ($step == $currentStep) {
                $next = true;
            }
        }

        return $step;
    }
  • Various "Step" classes - Define the requirements, route, order, and other basic information needed for each step of the form. The key method on these classes is isFulfilledBy($data) to indicate based on the data provided if that step has been completed. Example:
class ShippingMethodCheckoutStep extends BaseCheckoutStep
{
    protected $display = true; //Show this in the progress bar?
    protected $order = 30; //Value to indicate the relative position of this step to others
    protected $title = 'Shipping Method';
    protected $description = '';
    protected $key = 'shipping_method'; //How to identify this step when passed as a string

    public function isFulfilledBy(Cart $cart)
    {
        //Logic to determine if the step is completed
    }
}

One gotcha to look out for is to make sure that you don't inadvertently create a situation in which the customer gets stuck at a certain step and cannot progress. Since the "current step" is reevaluated on each request, it's easier to create a loop than you might think.

I hope this helps someone, and I welcome any feedback on the solution I came up with.

2 likes
bramlaravel's avatar

@Woodlandtrek Your idea of implementing this looks interesting to me, but a few things I don’t understand. Can you help me understanding the idea by giving answers to a few questions?

  • How are the routes defined? Do you have fixed routes for each step? (and fixed methods in CheckoutController for each step?)
  • Do you have all the GET and POST methods for each individual step defined in the CheckoutController class? So the form data of each step is processed in this class?
  • How does the CheckoutStepCollectionFactory class look like? I’m interested in how the steps are defined and how the conditional logic can be handled.

Hopefully you can point me in the right direction. Many thanks!

Woodlandtrek's avatar

@bramk91

  1. My checkout routes are hardcoded in web.php, and I use the route name as a key in each checkout step to determine what the URL is. So the route definition looks like this: $router->get('shipping_address', 'CheckoutController@address')->name('checkout.shipping_address'); and then within the checkout step it looks like this
class ShippingAddressCheckoutStep extends BaseCheckoutStep
{
    protected $display = true;
    protected $order = 20;
    protected $title = 'Shipping Address';
    protected $description = '';
    protected $key = 'shipping_address';

    public function isFulfilledBy(Cart $cart)
    {
        $this->setRoute($this->calculateRouteRoot($cart) . '.' . $this->key);
        return $cart->getCheckoutBag()->getShippingAddress() instanceof AddressableInterface;
    }

(the calculateRouteRoot method just accomodates for both "Buy Now" and "Cart" flows)

  1. Yep, The CheckoutController handles both GET and POST
  2. CheckoutStepCollectionFactory's job is just to examine the cart and figure out what steps are needed. It has just one method createFor($cart):
class CheckoutStepCollectionFactory
{
    public function createFor(Cart $cart)
    {
        $app = Container::getInstance();
        $checkoutStepCollection = $app[CheckoutStepCollection::class];
        $checkoutStepCollection->setCart($cart);

        // Account or guest is always required
        $checkoutStepCollection->add($app[AccountCheckoutStep::class]);

        // Shipping only required if cart contains physical items
        if ($cart->requiresShipping()) {
            $checkoutStepCollection->add($app[ShippingAddressCheckoutStep::class]);
            $checkoutStepCollection->add($app[ShippingMethodCheckoutStep::class]);
        }

    //etc.

        return $checkoutStepCollection;
    }
}

Please or to participate in this conversation.