Form 422 Status & renderForm()

Video not working?

It looks like your browser may not support the H264 codec. If you're using Linux, try a different browser or try installing the gstreamer0.10-ffmpeg gstreamer0.10-plugins-good packages.

Thanks! This saves us from needing to use Flash or encode videos in multiple formats. And that let's us get back to making more videos :). But as always, please feel free to message us.

We already know that Turbo Drive also works for form submits. To prove it, head to the login page and log in as shopper@example.com password buy... using these handy cheating links that are powered by a Stimulus controller.

Submit and... yep! That loaded via Turbo! Now head to the admin area. This is a generated CRUD for creating, editing and deleting products. Click to edit a product and... make it look a bit more exciting with some exclamation points. Hit enter to submit and... that worked too! It submitted via Ajax and redirected back to the list page. There are my exclamation points!

Failing Validation... Doesn't Work?

But now, let's make a change that will fail validation: clear out the name field and... hit Update. Uh... nothing happened? Check out the console. Ooh.

Form responses must redirect to another location.

Okay. Part of what makes Turbo so cool is that you get the single page app experience without making any changes to your server code. But the one big exception to that rule is forms. Don't worry: the change we need is minor... it's really an improvement on our code. And the change is especially easy in Symfony 5.3.

The 422 Status Code

Let's go find the controller for this page: it's in src/Controller/ProductAdminController.php... and edit action. Here we go. In short, if the form has a validation error, we need to return a 422 status code instead of a 200 status code.

... lines 1 - 63
/**
* @Route("/{id}/edit", name="product_admin_edit", methods={"GET","POST"})
*/
public function edit(Request $request, Product $product): Response
{
$form = $this->createForm(ProductType::class, $product);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->getDoctrine()->getManager()->flush();
return $this->redirectToRoute('product_admin_index');
}
return $this->render('product_admin/edit.html.twig', [
'product' => $product,
'form' => $form->createView(),
], new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200));
}
... lines 83 - 98

Right now, both when the page originally loads and when we have a validation error, we return $this->render(), which sets a 200 status code. Using a 422 status code when there's a validation error is actually more correct. And it tells Turbo that the form submit failed and it should re-render the page with the new HTML.

So how can we set the status code on the response that $this->render() creates? The easiest way is by passing the little-known third argument: a Response object that the render function will put the template content into. Say new Response() - get the one from HttpFoundation and pass null for the content, because that will be replaced by the template HTML. For the status code, we can't use 422 all the time because we don't want that status code when we simply navigate to this page. So use the ternary syntax: if $form->isSubmitted() and $form->isValid(), I mean if not $form->isValid(), then use 422. Else use 200.

... lines 1 - 76
return $this->render('product_admin/edit.html.twig', [
'product' => $product,
'form' => $form->createView(),
], new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200));
}
... lines 84 - 98

That's it! Back over at the browser, we don't even need to refresh. Hit update and... voilà! We see the validation error! Let's put the content back...remove my exclamation points, hit enter again and... it works.

Turbo Handles Redirects too

By the way, on success, in our controller, we are redirecting with a 302 status code, which is perfect! That is what you should do after a successful form submit.

The interesting thing is that... Turbo correctly handled this!

Check out your network tools. Let's look closely at what happened when we submitted the form. This request is the POST request from the submit. It returned a 302 redirect. When an Ajax request returns a redirect, your browser automatically follows it. What I mean is: in this case, our browser made a second Ajax request to the redirect URL - which is the product list page.

At this point, Turbo did something really smart: it detected that this 2nd Ajax request happened due to a redirect. It then used the HTML from that Ajax call to update the page like normal and it changed the URL in our browser to match the redirected URL. In other words, redirects work perfectly with Turbo Drive out of the box.

Now if you look at the Turbo documentation, they will tell you to return a 303 status code instead of 302 when redirecting after a form submit. But both work exactly the same. 303 is... technically a little bit more correct... and so more hipster... but it really doesn't matter.

Symfony 5.3's renderForm() Shortcut

Okay, back to this 422 status code fix. If you're using Symfony 5.3 - and I am - then fixing this is even easier thanks to a new renderForm() controller shortcut. Here's how it works: change render() to renderForm(). Then, remove the Response object.

That's it! Well, that's almost it. Also remove the createView() call on the form.

... lines 1 - 76
return $this->renderForm('product_admin/edit.html.twig', [
'product' => $product,
'form' => $form,
]);
}
... lines 83 - 98

Let's break this down. The renderForm() method is identical to $this->render() except that it loops over all of the variables that we pass into the template. If any of them are a Form object, it does two things. First, it calls createView(), which is just a really kind thing for it to do: we don't have to call that ourselves anymore. Second, if the Form has been submitted and it's invalid, it changes the status code to 422.

So all we need to do now is repeat this change everywhere else in our app... which is kind of boring, but simple! Copy renderForm() and scroll up to the new action. You can actually see that we did the 422 logic in the first tutorial because we wrote some custom JavaScript that - like Turbo - needed to know if a form was simply rendering or if it had a validation error.

Change this to renderForm(), we don't need createView()... and we don't need the third argument at all. Much nicer.

... lines 1 - 53
return $this->renderForm('product_admin/' . $template, [
'product' => $product,
'form' => $form,
]);
}
... lines 60 - 95

Let's clear the tabs and go to CartController. There are two spots inside here. I'll search for createView().

... lines 1 - 29
return $this->renderForm('cart/cart.html.twig', [
'cart' => $cartStorage->getOrCreateCart(),
'featuredProduct' => $featuredProduct,
'addToCartForm' => $addToCartForm,
]);
}
... lines 37 - 68
return $this->renderForm('product/show.html.twig', [
'product' => $product,
'categories' => $categoryRepository->findAll(),
'addToCartForm' => $addToCartForm,
]);
}
... lines 76 - 107

Cool: renderForm(), then take off createView(). For the next one... it's exactly the same. I'll take a big sip of coffee... and speed through the rest of the controllers: CheckoutController has one spot, ProductController has two spots, one of which renders two forms including a conditional reviewForm that can be simplified, RegistrationController has one spot... and ReviewAdminController has two spots.

... lines 1 - 45
return $this->renderForm('checkout/checkout.html.twig', [
'checkoutForm' => $checkoutForm,
'featuredProduct' => $featuredProduct,
'addToCartForm' => $addToCartForm,
]);
}
... lines 53 - 71

... lines 1 - 58
return $this->renderForm('product/show.html.twig', [
'product' => $product,
'currentCategory' => $product->getCategory(),
'categories' => $categoryRepository->findAll(),
'addToCartForm' => $addToCartForm,
'reviewForm' => $reviewForm ?: null,
]);
}
... lines 68 - 91
return $this->renderForm('product/reviews.html.twig', [
'product' => $product,
'currentCategory' => $product->getCategory(),
'categories' => $categoryRepository->findAll(),
'reviewForm' => $reviewForm?: null,
]);
}
... lines 100 - 109

... lines 1 - 47
return $this->renderForm('registration/register.html.twig', [
'registrationForm' => $form,
'featuredProduct' => $productRepository->findFeatured(),
]);
}
}

... lines 1 - 43
return $this->renderForm('review_admin/new.html.twig', [
'review' => $review,
'form' => $form,
]);
}
... lines 50 - 63
return $this->renderForm('review_admin/edit.html.twig', [
'review' => $review,
'form' => $form,
]);
}
... lines 70 - 85

Phew! Good, straightforward, boring work. The only form we didn't need to change was the login form. That's because the login form works a bit differently than other forms on our site. On failure, it redirects and stores the error in the session. So if we put some bad info and submit... it already works fine.

Hey! With a few small changes to our code, our site now has fully-functional Ajax submitted forms! That's just... incredible.

Next, let's talk more about that snapshot functionality: the feature that instantly shows you a page from cache when hitting the back button or when navigating to a page that we've already been to. As awesome as that feature is - and it really makes the site feel fast - sometimes it can take a snapshot when the page is in a "state" that we don't want.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=7.4.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "1.11.99.1", // 1.11.99.1
        "doctrine/annotations": "^1.0", // 1.13.1
        "doctrine/doctrine-bundle": "^2.2", // 2.3.2
        "doctrine/orm": "^2.8", // 2.9.1
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "sensio/framework-extra-bundle": "^6.1", // v6.1.4
        "symfony/asset": "5.3.*", // v5.3.0-RC1
        "symfony/console": "5.3.*", // v5.3.0-RC1
        "symfony/dotenv": "5.3.*", // v5.3.0-RC1
        "symfony/flex": "^1.3.1", // v1.13.3
        "symfony/form": "5.3.*", // v5.3.0-RC1
        "symfony/framework-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/property-access": "5.3.*", // v5.3.0-RC1
        "symfony/property-info": "5.3.*", // v5.3.0-RC1
        "symfony/proxy-manager-bridge": "5.3.*", // v5.3.0-RC1
        "symfony/runtime": "5.3.*", // v5.3.0-RC1
        "symfony/security-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/serializer": "5.3.*", // v5.3.0-RC1
        "symfony/twig-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/ux-chartjs": "^1.1", // v1.3.0
        "symfony/ux-turbo": "^1.3", // v1.3.0
        "symfony/ux-turbo-mercure": "^1.3", // v1.3.0
        "symfony/validator": "5.3.*", // v5.3.0-RC1
        "symfony/webpack-encore-bundle": "^1.9", // v1.11.2
        "symfony/yaml": "5.3.*", // v5.3.0-RC1
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.1
        "twig/intl-extra": "^3.2", // v3.3.0
        "twig/string-extra": "^3.3", // v3.3.1
        "twig/twig": "^2.12|^3.0" // v3.3.2
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.0
        "symfony/debug-bundle": "^5.2", // v5.3.0-RC1
        "symfony/maker-bundle": "^1.27", // v1.31.1
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/stopwatch": "^5.2", // v5.3.0-RC1
        "symfony/var-dumper": "^5.2", // v5.3.0-RC1
        "symfony/web-profiler-bundle": "^5.2", // v5.3.0-RC1
        "zenstruck/foundry": "^1.10" // v1.10.0
    }
}