Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

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!

7
Login or Register to join the conversation
akincer Avatar

Suppose a use case where a successful and valid form submission doesn't redirect to a new location but sends the user back to the same form page but with new information and action for the user to take. As I'm reading this wouldn't trigger a rerendering of the HTML and testing proves that's true. Is there an elegant solution to this or would disabling turbo/hotwire on the form be the better approach?

Reply
akincer Avatar
akincer Avatar akincer | akincer | posted 11 months ago | edited

This is the solution I cooked up.

if ($form->isSubmitted() && $form->isValid() && "Conditions which you return to the form are not true") {
    $statusCode = 200;
}
else {
    $statusCode = 422;
}

return $this->renderForm('template', [
    'form' => $form,
], new Response(null, $statusCode));
Reply

Hey Akincer,

I'm glad you were able to solve this yourself, well done!

Cheers!

Reply
Braunstetter Avatar
Braunstetter Avatar Braunstetter | posted 1 year ago

Hello Ryan. I start using turbo now first for the frontend in our company. I did the first turbo page now and I have the first problem :).
When I embed a controller with a (contact-)form (to be able to reuse it everywhere in a bunch of marketing sites) this redirect problem occors no matter what. A subrequest seems to be only allowed to send a 202, not a redirect or a 422. So without turbo I can use embed controllers with forms and with turbo not.
I guess this is a common usecase. When this is not possible you will never be able to create some sort of sitebuilding system for your sites - because almost every marketing site has also a form somewhere.

Is there a way to solve that? Do you have an idea?

Reply

Hey Michael B.!

Hmm, I don't do a lot of embedding of controllers... so it's very possible that there IS an issue here and I just haven't experienced it yet. But, I need a little help from you. So, I understand that in the template for a page, you embed a controller (render(controller()) correct?) that renders a contact form.

Question: (ignoring Turbo for a moment - just pretend you're on a non-Turbo site), where does that contact for submit to? Does it submit to a real route above some controller? Or does it submit right back to the same page... and then the sub-controller reads the POSTed data and, when it re-renders, it shows the errors? How did redirects work before?

The setup is a bit foreign to me (that doesn't mean it's wrong), so I need some more info :).

Cheers!

1 Reply
Braunstetter Avatar
Braunstetter Avatar Braunstetter | weaverryan | posted 1 year ago

Hey Ryan. I solved it by using turbo frames. It works like a charm now and I can put it everywhere I want to :D.
I created a route for the contact form and a partial I can include in my sitebuilding. In the partial I have a <turbo-frame url="{{path('contact-form')}}"> tag. It loads, it works even with success message. Like a javascript form. But without any javascript. Its awesome.

Reply

Oh, sweet! That's sounds perfect! Thanks for sharing your success :)

1 Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "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.18.5
        "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
    }
}

What JavaScript libraries does this tutorial use?

// package.json
{
    "devDependencies": {
        "@babel/preset-react": "^7.0.0", // 7.13.13
        "@fortawesome/fontawesome-free": "^5.15.3", // 5.15.3
        "@hotwired/turbo": "^7.0.0-beta.5", // 1.2.6
        "@popperjs/core": "^2.9.1", // 2.9.2
        "@symfony/stimulus-bridge": "^2.0.0", // 2.1.0
        "@symfony/ux-chartjs": "file:vendor/symfony/ux-chartjs/Resources/assets", // 1.1.0
        "@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/Resources/assets", // 0.1.0
        "@symfony/ux-turbo-mercure": "file:vendor/symfony/ux-turbo-mercure/Resources/assets", // 0.1.0
        "@symfony/webpack-encore": "^1.0.0", // 1.3.0
        "bootstrap": "^5.0.0-beta2", // 5.0.1
        "chart.js": "^2.9.4",
        "core-js": "^3.0.0", // 3.13.0
        "jquery": "^3.6.0", // 3.6.0
        "react": "^17.0.1", // 17.0.2
        "react-dom": "^17.0.1", // 17.0.2
        "regenerator-runtime": "^0.13.2", // 0.13.7
        "stimulus": "^2.0.0", // 2.0.0
        "stimulus-autocomplete": "https://github.com/weaverryan/stimulus-autocomplete#toggle-event-always-dist", // 2.0.0
        "stimulus-use": "^0.24.0-1", // 0.24.0-2
        "sweetalert2": "^11.0.8", // 11.0.12
        "webpack-bundle-analyzer": "^4.4.0", // 4.4.2
        "webpack-notifier": "^1.6.0" // 1.13.0
    }
}
userVoice