diff --git a/ChangeLog.md b/ChangeLog.md
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -1,5 +1,28 @@
 # ChangeLog for yesod-test
 
+## 1.7.0.3
+
+* Adopt the `runFormPRG` integration tests from yesod-form's test
+  suite, whose yesod-test dependency created a package-level cycle
+  that broke the Stackage build plan. No library changes.
+  [#1928](https://github.com/yesodweb/yesod/issues/1928)
+
+## 1.7.0.2
+
+* Support `yesod-core` 1.7
+
+## 1.7.0.1
+
+* When `bodyContains` or `bodyNotContains` fail and the content is UTF-8, a partial body is printed to aid debugging. This matches the behavior of `statusIs`. [#1900](https://github.com/yesodweb/yesod/pull/1900)
+
+## 1.7.0
+
+* Make post params be added in addition order like normal forms. [#1867](https://github.com/yesodweb/yesod/pull/1867)
+
+## 1.6.23.1
+
+* Set `base >= 4.11` for less CPP and imports [#1876](https://github.com/yesodweb/yesod/pull/1876)
+
 ## 1.6.23
 
 * Add `MonadFail` to `SIO` so tests can fail on pattern match failure. [#1874](https://github.com/yesodweb/yesod/pull/1874)
diff --git a/Yesod/Test.hs b/Yesod/Test.hs
--- a/Yesod/Test.hs
+++ b/Yesod/Test.hs
@@ -1,13 +1,13 @@
-{-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE CPP #-}
+{-# LANGUAGE ConstraintKinds #-}
 {-# LANGUAGE FlexibleContexts #-}
-{-# LANGUAGE RecordWildCards #-}
 {-# LANGUAGE FlexibleInstances #-}
-{-# LANGUAGE TypeFamilies #-}
-{-# LANGUAGE ImplicitParams #-}
-{-# LANGUAGE ConstraintKinds #-}
 {-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE ImplicitParams #-}
 {-# LANGUAGE MultiParamTypeClasses #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE TypeFamilies #-}
 
 {-|
 Yesod.Test is a pragmatic framework for testing web applications built
@@ -266,18 +266,8 @@
 import qualified Web.Cookie as Cookie
 import qualified Blaze.ByteString.Builder as Builder
 import Data.Time.Clock (getCurrentTime)
-import Control.Applicative ((<$>))
 import Text.Show.Pretty (ppShow)
-import Data.Monoid (mempty)
-#if MIN_VERSION_base(4,9,0)
 import GHC.Stack (HasCallStack)
-#elif MIN_VERSION_base(4,8,1)
-import GHC.Stack (CallStack)
-type HasCallStack = (?callStack :: CallStack)
-#else
-import GHC.Exts (Constraint)
-type HasCallStack = (() :: Constraint)
-#endif
 import Data.ByteArray.Encoding (convertToBase, Base(..))
 import Network.HTTP.Types.Header (hContentType)
 import Data.Aeson (eitherDecode')
@@ -285,6 +275,7 @@
 
 import Yesod.Test.Internal (getBodyTextPreview, contentTypeHeaderIsUtf8)
 import Yesod.Test.Internal.SIO
+import qualified Data.Maybe as Maybe
 
 import System.Directory (getTemporaryDirectory)
 import System.Info (os)
@@ -683,10 +674,19 @@
 -- > get HomeR
 -- > bodyContains "<h1>Foo</h1>"
 bodyContains :: HasCallStack => String -> YesodExample site ()
-bodyContains text = withResponse $ \ res ->
-  liftIO $ HUnit.assertBool ("Expected body to contain " ++ text) $
-    (simpleBody res) `contains` text
+bodyContains text = withResponse $ \(SResponse status headers body) -> do
+  let mContentType = lookup hContentType headers
+      isUTF8ContentType = maybe False contentTypeHeaderIsUtf8 mContentType
 
+  liftIO $ flip HUnit.assertBool (body `contains` text) $ concat
+    [ "Expected body to contain `"
+    , text
+    , "`"
+    , if isUTF8ContentType
+         then ". For debugging, the body was: " <> (T.unpack $ getBodyTextPreview body)
+         else ""
+    ]
+
 -- | Assert the last response doesn't have the given text. The check is performed using the response
 -- body in full text form.
 --
@@ -697,10 +697,19 @@
 --
 -- @since 1.5.3
 bodyNotContains :: HasCallStack => String -> YesodExample site ()
-bodyNotContains text = withResponse $ \ res ->
-  liftIO $ HUnit.assertBool ("Expected body not to contain " ++ text) $
-    not $ contains (simpleBody res) text
+bodyNotContains text = withResponse $ \(SResponse status headers body) -> do
+  let mContentType = lookup hContentType headers
+      isUTF8ContentType = maybe False contentTypeHeaderIsUtf8 mContentType
 
+  liftIO $ flip HUnit.assertBool (not $ body `contains` text) $ concat
+    [ "Expected body to not contain `"
+    , text
+    , "`"
+    , if isUTF8ContentType
+         then ". For debugging, the body was: " <> (T.unpack $ getBodyTextPreview body)
+         else ""
+    ]
+
 contains :: BSL8.ByteString -> String -> Bool
 contains a b = DL.isInfixOf b (TL.unpack $ decodeUtf8 a)
 
@@ -721,7 +730,7 @@
   matches <- htmlQuery query
   case matches of
     [] -> failure $ "Nothing matched css query: " <> query
-    _ -> liftIO $ HUnit.assertBool ("Not all "++T.unpack query++" contain "++search  ++ " matches: " ++ show matches) $
+    _ -> liftIO $ HUnit.assertBool ("Not all " ++ T.unpack query ++ " contain " ++ search ++ " matches: " ++ show matches) $
           DL.all (DL.isInfixOf (escape search)) (map (TL.unpack . decodeUtf8) matches)
 
 -- | puts the search trough the same escaping as the matches are.
@@ -744,7 +753,7 @@
   matches <- htmlQuery query
   case matches of
     [] -> failure $ "Nothing matched css query: " <> query
-    _ -> liftIO $ HUnit.assertBool ("None of "++T.unpack query++" contain "++search ++ " matches: " ++ show matches) $
+    _ -> liftIO $ HUnit.assertBool ("None of " ++ T.unpack query ++ " contain " ++ search ++ " matches: " ++ show matches) $
           DL.any (DL.isInfixOf (escape search)) (map (TL.unpack . decodeUtf8) matches)
 
 -- | Queries the HTML using a CSS selector, and fails if any matched
@@ -778,7 +787,7 @@
 htmlCount query count = do
   matches <- fmap DL.length $ htmlQuery query
   liftIO $ flip HUnit.assertBool (matches == count)
-    ("Expected "++(show count)++" elements to match "++T.unpack query++", found "++(show matches))
+    ("Expected " ++ (show count) ++ " elements to match " ++ T.unpack query ++ ", found " ++ (show matches))
 
 -- | Parses the response body from JSON into a Haskell value, throwing an error if parsing fails.
 --
@@ -1254,10 +1263,15 @@
 -- >   addToken_ "#formID"
 addToken_ :: HasCallStack => Query -> RequestBuilder site ()
 addToken_ scope = do
-  matches <- htmlQuery' rbdResponse ["Tried to get CSRF token with addToken'"] $ scope <> " input[name=_token][type=hidden][value]"
+  matches <-
+    htmlQuery' rbdResponse ["Tried to get CSRF token with addToken'"] $
+      scope <> " input[name=_token][type=hidden][value]"
   case matches of
+    [element] ->
+      case attribute "value" $ parseHTML element of
+        [] -> failure "Expected at least one value in 'value' attribute"
+        valAttr : _ -> addPostParam "_token" valAttr
     [] -> failure $ "No CSRF token found in the current page"
-    element:[] -> addPostParam "_token" $ head $ attribute "value" $ parseHTML element
     _ -> failure $ "More than one CSRF token found in the page"
 
 -- | For responses that display a single form, just lookup the only CSRF token available.
@@ -1322,7 +1336,7 @@
 getRequestCookies :: HasCallStack => RequestBuilder site Cookies
 getRequestCookies = do
   requestBuilderData <- getSIO
-  headers <- case simpleHeaders Control.Applicative.<$> rbdResponse requestBuilderData of
+  headers <- case simpleHeaders <$> rbdResponse requestBuilderData of
                   Just h -> return h
                   Nothing -> failure "getRequestCookies: No request has been made yet; the cookies can't be looked up."
 
@@ -1424,10 +1438,10 @@
       Just h -> case parseRoute $ decodePath h of
         Nothing -> return $ Left "getLocation called, but couldn’t parse it into a route"
         Just l -> return $ Right l
-  where decodePath b = let (x, y) = BS8.break (=='?') b
+  where decodePath b = let (x, y) = BS8.break (== '?') b
                        in (H.decodePathSegments x, unJust <$> H.parseQueryText y)
         unJust (a, Just b) = (a, b)
-        unJust (a, Nothing) = (a, Data.Monoid.mempty)
+        unJust (a, Nothing) = (a, mempty)
 
 -- | Sets the HTTP method used by the request.
 --
@@ -1466,7 +1480,7 @@
     let (urlPath, urlQuery) = T.break (== '?') url
     modifySIO $ \rbd -> rbd
         { rbdPath =
-            case DL.filter (/="") $ H.decodePathSegments $ TE.encodeUtf8 urlPath of
+            case DL.filter (/= "") $ H.decodePathSegments $ TE.encodeUtf8 urlPath of
                 ("http:":_:rest) -> rest
                 ("https:":_:rest) -> rest
                 x -> x
@@ -1652,23 +1666,22 @@
       SRequest simpleRequest' (simpleRequestBody' rbdPostData)
       where
         simpleRequest' = (mkRequest
-                          ([ ("Cookie", cookieValue) ] ++ headersForPostData rbdPostData)
+                          (headersForPostData rbdPostData [ ("Cookie", cookieValue) ])
                           method extraHeaders urlPath urlQuery)
         simpleRequestBody' (MultipleItemsPostData x) =
           BSL8.fromChunks $ return $ H.renderSimpleQuery False
-          $ concatMap singlepartPart x
+          $ reverse $ Maybe.mapMaybe singlepartPart x
         simpleRequestBody' (BinaryPostData x) = x
         cookieValue = Builder.toByteString $ Cookie.renderCookies cookiePairs
         cookiePairs = [ (Cookie.setCookieName c, Cookie.setCookieValue c)
                       | c <- map snd $ M.toList cookies ]
-        singlepartPart (ReqFilePart _ _ _ _) = []
-        singlepartPart (ReqKvPart k v) = [(TE.encodeUtf8 k, TE.encodeUtf8 v)]
+        singlepartPart (ReqFilePart _ _ _ _) = Nothing
+        singlepartPart (ReqKvPart k v) = Just (TE.encodeUtf8 k, TE.encodeUtf8 v)
 
         -- If the request appears to be submitting a form (has key-value pairs) give it the form-urlencoded Content-Type.
         -- The previous behavior was to always use the form-urlencoded Content-Type https://github.com/yesodweb/yesod/issues/1063
-        headersForPostData (MultipleItemsPostData []) = []
-        headersForPostData (MultipleItemsPostData _ ) = [("Content-Type", "application/x-www-form-urlencoded")]
-        headersForPostData (BinaryPostData _ ) = []
+        headersForPostData (MultipleItemsPostData (_:_)) = (("Content-Type", "application/x-www-form-urlencoded"):)
+        headersForPostData _ = id
 
 
     -- General request making
@@ -1684,7 +1697,7 @@
 
 
 parseSetCookies :: [H.Header] -> [Cookie.SetCookie]
-parseSetCookies headers = map (Cookie.parseSetCookie . snd) $ DL.filter (("Set-Cookie"==) . fst) $ headers
+parseSetCookies headers = map (Cookie.parseSetCookie . snd) $ DL.filter (("Set-Cookie" ==) . fst) $ headers
 
 -- Yes, just a shortcut
 failure :: (HasCallStack, MonadIO a) => T.Text -> a b
diff --git a/Yesod/Test/CssQuery.hs b/Yesod/Test/CssQuery.hs
--- a/Yesod/Test/CssQuery.hs
+++ b/Yesod/Test/CssQuery.hs
@@ -1,4 +1,5 @@
 {-# LANGUAGE OverloadedStrings #-}
+
 -- | Parsing CSS selectors into queries.
 module Yesod.Test.CssQuery
     ( SelectorGroup (..)
@@ -9,7 +10,7 @@
 import Prelude hiding (takeWhile)
 import Data.Text (Text)
 import Data.Attoparsec.Text
-import Control.Applicative
+import Control.Applicative (many, (<|>))
 import Data.Char
 
 import qualified Data.Text as T
diff --git a/Yesod/Test/Internal.hs b/Yesod/Test/Internal.hs
--- a/Yesod/Test/Internal.hs
+++ b/Yesod/Test/Internal.hs
@@ -18,7 +18,6 @@
 import qualified Data.Text.Lazy as TL
 import qualified Data.Text.Lazy.Encoding as DTLE
 import qualified Yesod.Core.Content as Content
-import Data.Semigroup (Semigroup(..))
 
 -- | Helper function to get the first 1024 characters of the body, assuming it is UTF-8.
 -- This function is used to preview the body in case of an assertion failure.
diff --git a/Yesod/Test/Internal/SIO.hs b/Yesod/Test/Internal/SIO.hs
--- a/Yesod/Test/Internal/SIO.hs
+++ b/Yesod/Test/Internal/SIO.hs
@@ -1,14 +1,8 @@
-{-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE CPP #-}
-{-# LANGUAGE FlexibleContexts #-}
-{-# LANGUAGE RecordWildCards #-}
 {-# LANGUAGE FlexibleInstances #-}
-{-# LANGUAGE TypeFamilies #-}
-{-# LANGUAGE ImplicitParams #-}
-{-# LANGUAGE ConstraintKinds #-}
 {-# LANGUAGE GeneralizedNewtypeDeriving #-}
-{-# LANGUAGE MultiParamTypeClasses #-}
 {-# LANGUAGE InstanceSigs #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
 
 -- | The 'SIO' type is used by "Yesod.Test" to provide exception-safe
 -- environment between requests and assertions.
@@ -24,7 +18,9 @@
 import qualified Control.Monad.State.Class as MS
 import Yesod.Core
 import Data.IORef
+#if MIN_VERSION_base(4,13,0)
 import GHC.Stack (HasCallStack)
+#endif
 
 -- | State + IO
 --
@@ -32,10 +28,12 @@
 newtype SIO s a = SIO (ReaderT (IORef s) IO a)
   deriving (Functor, Applicative, Monad, MonadIO, MonadThrow, MonadUnliftIO)
 
+#if MIN_VERSION_base(4,13,0)
 -- | @since 1.6.23
 instance MonadFail (SIO s) where
   fail :: HasCallStack => String -> SIO s a
   fail = error
+#endif
 
 instance MS.MonadState s (SIO s)
   where
diff --git a/Yesod/Test/TransversingCSS.hs b/Yesod/Test/TransversingCSS.hs
--- a/Yesod/Test/TransversingCSS.hs
+++ b/Yesod/Test/TransversingCSS.hs
@@ -1,5 +1,5 @@
 {-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE CPP #-}
+
 {- |
 This module uses HXT to transverse an HTML document using CSS selectors.
 
@@ -42,7 +42,6 @@
 
 import Yesod.Test.CssQuery
 import qualified Data.Text as T
-import qualified Control.Applicative
 import Text.XML
 import Text.XML.Cursor
 import qualified Data.ByteString.Lazy as L
@@ -60,7 +59,7 @@
 -- * Right: List of matching Html fragments.
 findBySelector :: HtmlLBS -> Query -> Either String [String]
 findBySelector html query =
-  map (renderHtml . toHtml . node) Control.Applicative.<$> findCursorsBySelector html query
+  map (renderHtml . toHtml . node) <$> findCursorsBySelector html query
 
 -- | Perform a css 'Query' on 'Html'. Returns Either
 --
@@ -69,8 +68,7 @@
 -- * Right: List of matching Cursors
 findCursorsBySelector :: HtmlLBS -> Query -> Either String [Cursor]
 findCursorsBySelector html query =
-  runQuery (fromDocument $ HD.parseLBS html)
-       Control.Applicative.<$> parseQuery query
+  runQuery (fromDocument $ HD.parseLBS html) <$> parseQuery query
 
 -- | Perform a css 'Query' on 'Html'. Returns Either
 --
@@ -81,7 +79,7 @@
 -- @since 1.5.7
 findAttributeBySelector :: HtmlLBS -> Query -> T.Text -> Either String [[T.Text]]
 findAttributeBySelector html query attr =
-  map (laxAttribute attr) Control.Applicative.<$> findCursorsBySelector html query
+  map (laxAttribute attr) <$> findCursorsBySelector html query
 
 
 -- Run a compiled query on Html, returning a list of matching Html fragments.
diff --git a/test/PRGSpec.hs b/test/PRGSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/PRGSpec.hs
@@ -0,0 +1,248 @@
+{-# LANGUAGE FlexibleContexts      #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
+{-# LANGUAGE OverloadedStrings     #-}
+{-# LANGUAGE QuasiQuotes           #-}
+{-# LANGUAGE TemplateHaskell       #-}
+{-# LANGUAGE TypeFamilies          #-}
+{-# LANGUAGE ViewPatterns          #-}
+
+-- | Integration tests for yesod-form's 'runFormPRG'.
+--
+-- These live in yesod-test rather than yesod-form: a yesod-test
+-- dependency in yesod-form's test suite creates a package-level
+-- dependency cycle that Stackage's build planner rejects, see
+-- <https://github.com/yesodweb/yesod/issues/1928>.
+module PRGSpec (spec) where
+
+import qualified Data.ByteString.Lazy as BL
+import Data.Text (Text)
+import qualified Data.Text as T
+import qualified Data.Text.Encoding as TE
+import Network.Wai.Test (simpleBody)
+import Test.Hspec (Spec)
+import qualified Web.ClientSession as CS
+import Yesod.Core
+import Yesod.Form
+import Yesod.Test
+
+data App = App
+
+mkYesod "App" [parseRoutes|
+/form FormR GET POST
+/two TwoR GET POST
+/corrupt CorruptR GET
+/stale StaleR GET
+|]
+
+instance Yesod App where
+    -- A random key per test run, so no client_session_key.aes file is
+    -- written into the repository.
+    makeSessionBackend _ = do
+        key <- snd `fmap` CS.randomKey
+        (getCachedDate, _closeDateCacher) <- clientSessionDateCacher (120 * 60)
+        return $ Just $ clientSessionBackend key getCachedDate
+
+instance RenderMessage App FormMessage where
+    renderMessage _ _ = defaultFormMessage
+
+fs :: Text -> FieldSettings App
+fs name = FieldSettings (SomeMessage name) Nothing Nothing (Just name) []
+
+personForm :: Html -> MForm Handler (FormResult (Text, Int), Widget)
+personForm csrf = do
+    (nameRes, nameView) <- mreq textField (fs "name") Nothing
+    (ageRes, ageView) <- mreq ageField (fs "age") Nothing
+    let widget = [whamlet|
+            #{csrf}
+            ^{fvInput nameView}
+            $maybe err <- fvErrors nameView
+                <p .error>#{err}
+            ^{fvInput ageView}
+            $maybe err <- fvErrors ageView
+                <p .error>#{err}
+            |]
+    return ((,) <$> nameRes <*> ageRes, widget)
+  where
+    ageField = checkBool (>= (18 :: Int)) ("Too young" :: Text) intField
+
+-- | A one-field form for the two-forms-on-a-page tests; submitting
+-- non-numeric input fails with the input preserved.
+numForm :: Text -> Html -> MForm Handler (FormResult Int, Widget)
+numForm name csrf = do
+    (res, view) <- mreq intField (fs name) Nothing
+    let widget = [whamlet|
+            #{csrf}
+            ^{fvInput view}
+            $maybe err <- fvErrors view
+                <p .error>#{name}: #{err}
+            |]
+    return (res, widget)
+
+getFormR :: Handler Html
+getFormR = do
+    mmsg <- getMessage
+    ((_res, widget), enctype) <- runFormPRG personForm
+    defaultLayout [whamlet|
+        $maybe msg <- mmsg
+            <p .message>#{msg}
+        <form method=post action=@{FormR} enctype=#{enctype}>
+            ^{widget}
+            <button>go
+    |]
+
+postFormR :: Handler Html
+postFormR = do
+    ((res, _widget), _enctype) <- runFormPRG personForm
+    case res of
+        FormSuccess _ -> setMessage "registered" >> redirect FormR
+        _ -> redirect FormR
+
+getTwoR :: Handler Html
+getTwoR = do
+    mmsg <- getMessage
+    ((_, fooW), fooE) <- runFormPRG $ identifyForm "foo" $ numForm "foonum"
+    ((_, barW), barE) <- runFormPRG $ identifyForm "bar" $ numForm "barnum"
+    defaultLayout [whamlet|
+        $maybe msg <- mmsg
+            <p .message>#{msg}
+        <form method=post action=@{TwoR} enctype=#{fooE}>
+            ^{fooW}
+            <button>foo
+        <form method=post action=@{TwoR} enctype=#{barE}>
+            ^{barW}
+            <button>bar
+    |]
+
+postTwoR :: Handler Html
+postTwoR = do
+    ((fooRes, _), _) <- runFormPRG $ identifyForm "foo" $ numForm "foonum"
+    ((barRes, _), _) <- runFormPRG $ identifyForm "bar" $ numForm "barnum"
+    case (fooRes, barRes) of
+        (FormSuccess _, _) -> setMessage "foo ok" >> redirect TwoR
+        (_, FormSuccess _) -> setMessage "bar ok" >> redirect TwoR
+        _ -> redirect TwoR
+
+-- | Plant garbage bytes where the stash for \/form lives.
+getCorruptR :: Handler ()
+getCorruptR = setSessionBS "_PRG:/form" "definitely not json"
+
+-- | Plant a decodable stash for \/form, as if a submission with
+-- name=stale had failed earlier.
+getStaleR :: Handler ()
+getStaleR = setSessionBS "_PRG:/form" "{\"name\":[\"stale\"],\"age\":[\"12\"]}"
+
+-- | The CSRF token stays constant for a session, so grab it once from the
+-- current response body and reuse it across requests. This avoids
+-- 'addToken', which can only read the token from the /previous/ response.
+extractToken :: YesodExample App Text
+extractToken = do
+    mres <- getResponse
+    let body = maybe "" (TE.decodeUtf8 . BL.toStrict . simpleBody) mres
+        (_, rest) = T.breakOn "name=\"_token\" value=\"" body
+    case T.splitOn "\"" (T.drop (T.length "name=\"_token\" value=\"") rest) of
+        (token:_) | not (T.null token) -> return token
+        _ -> error "no _token in response body"
+
+postParams :: Route App -> Text -> [(Text, Text)] -> YesodExample App ()
+postParams route token params = request $ do
+    setMethod "POST"
+    setUrl route
+    addPostParam "_token" token
+    mapM_ (uncurry addPostParam) params
+
+spec :: Spec
+spec = yesodSpec App $
+    ydescribe "runFormPRG" $ do
+        yit "renders a blank form when nothing is stashed" $ do
+            get FormR
+            statusIs 200
+            bodyContains "name=\"name\""
+            bodyNotContains "error"
+
+        yit "replays invalid input and errors after the redirect, once" $ do
+            get FormR
+            token <- extractToken
+            postParams FormR token [("name", "alice"), ("age", "12")]
+            statusIs 303
+            get FormR
+            statusIs 200
+            bodyContains "value=\"alice\""
+            bodyContains "value=\"12\""
+            bodyContains "Too young"
+            get FormR
+            statusIs 200
+            bodyNotContains "alice"
+            bodyNotContains "Too young"
+
+        yit "does not stash on success" $ do
+            get FormR
+            token <- extractToken
+            postParams FormR token [("name", "alice"), ("age", "30")]
+            statusIs 303
+            get FormR
+            statusIs 200
+            bodyContains "registered"
+            bodyNotContains "value=\"alice\""
+
+        yit "success clears a stale stash" $ do
+            get FormR
+            token <- extractToken
+            get StaleR
+            postParams FormR token [("name", "alice"), ("age", "30")]
+            statusIs 303
+            get FormR
+            statusIs 200
+            bodyContains "registered"
+            bodyNotContains "stale"
+
+        yit "still enforces CSRF on POST" $ do
+            get FormR
+            postParams FormR "wrong-token" [("name", "alice"), ("age", "30")]
+            statusIs 303
+            get FormR
+            statusIs 200
+            bodyNotContains "registered"
+            bodyContains "value=\"alice\""
+
+        yit "replays only the submitted one of two identified forms" $ do
+            get TwoR
+            token <- extractToken
+            postParams TwoR token
+                [("_formid", "identify-foo"), ("foonum", "foo-banana")]
+            statusIs 303
+            get TwoR
+            statusIs 200
+            bodyContains "value=\"foo-banana\""
+            bodyContains "foonum: "
+            bodyNotContains "barnum: "
+
+        yit "replays the second form on the page too" $ do
+            get TwoR
+            token <- extractToken
+            postParams TwoR token
+                [("_formid", "identify-bar"), ("barnum", "bar-banana")]
+            statusIs 303
+            get TwoR
+            statusIs 200
+            bodyContains "value=\"bar-banana\""
+            bodyContains "barnum: "
+            bodyNotContains "foonum: "
+
+        yit "treats an undecodable stash as absent" $ do
+            get CorruptR
+            statusIs 200
+            get FormR
+            statusIs 200
+            bodyContains "name=\"name\""
+
+        yit "keys the stash by path" $ do
+            get FormR
+            token <- extractToken
+            postParams FormR token [("name", "alice"), ("age", "12")]
+            statusIs 303
+            get TwoR
+            statusIs 200
+            bodyNotContains "alice"
+            get FormR
+            statusIs 200
+            bodyContains "value=\"alice\""
diff --git a/test/main.hs b/test/main.hs
--- a/test/main.hs
+++ b/test/main.hs
@@ -1,16 +1,15 @@
--- Ignore warnings about using deprecated byLabel/fileByLabel functions
-{-# OPTIONS_GHC -fno-warn-warnings-deprecations #-}
-
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
 {-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes #-}
 {-# LANGUAGE ScopedTypeVariables #-}
-{-# LANGUAGE CPP #-}
-{-# LANGUAGE MultiParamTypeClasses #-}
-{-# LANGUAGE LambdaCase #-}
 {-# LANGUAGE TemplateHaskell #-}
-{-# LANGUAGE QuasiQuotes #-}
 {-# LANGUAGE TypeFamilies #-}
 {-# LANGUAGE ViewPatterns #-}
 {-# OPTIONS_GHC -fno-warn-orphans #-}
+-- Ignore warnings about using deprecated byLabel/fileByLabel functions
+{-# OPTIONS_GHC -fno-warn-warnings-deprecations #-}
+
 module Main
     ( main
     -- avoid warnings
@@ -30,8 +29,6 @@
 import Text.XML
 import Data.Text (Text, pack)
 import Data.Char (toUpper)
-import Data.Monoid ((<>))
-import Control.Applicative
 import Network.Wai (pathInfo, rawQueryString, requestHeaders)
 import Network.Wai.Test (SResponse(simpleBody))
 import Numeric (showHex)
@@ -49,7 +46,10 @@
 import qualified Data.Text as T
 import qualified Data.ByteString.Char8 as B8
 import Yesod.Test.Internal (contentTypeHeaderIsUtf8)
+import Data.Foldable (traverse_)
 
+import qualified PRGSpec
+
 parseQuery_ :: Text -> [[SelectorGroup]]
 parseQuery_ = either error id . parseQuery
 
@@ -66,10 +66,12 @@
 /resources       ResourcesR POST
 /resources/#Text ResourceR  GET
 /get-integer     IntegerR   GET
+/multi-param MultiParamR GET POST
 |]
 
 main :: IO ()
 main = hspec $ do
+    PRGSpec.spec
     describe "CSS selector parsing" $ do
         it "elements" $ parseQuery_ "strong" @?= [[DeepChildren [ByTagName "strong"]]]
         it "child elements" $ parseQuery_ "strong > i" @?= [[DeepChildren [ByTagName "strong"], DirectChildren [ByTagName "i"]]]
@@ -329,7 +331,7 @@
                     checkByLabel "Red"
                     checkByLabel "Gray"
                     addToken
-                bodyContains "colorCheckBoxes = [Gray,Red]"
+                bodyContains "colorCheckBoxes = [Red,Gray]"
             yit "can select from select list" $ do
                 get ("/labels-select" :: Text)
                 request $ do
@@ -575,6 +577,19 @@
             statusIs 200
             (requireJSONResponse :: YesodExample site [Text]) `liftedShouldThrow` (\(e :: SomeException) -> True)
 
+    describe "'addPostParam' order is correct" $ yesodSpec defaultRoutedApp $ do
+        yit "should add the parameters in the order they were added" $ do
+            let params = [("foo", "foobarbaz"), ("bar", "barbaz"), ("baz", "bazqux")]
+            get MultiParamR
+            statusIs 200
+            request $ do
+                setMethod "POST"
+                setUrl MultiParamR
+                addToken
+                traverse_ (uncurry addPostParam) params
+            statusIs 200
+            bodyContains $ T.unpack $ T.intercalate " -- " $ map (\(k, v) -> k <> ": " <> v) params
+
 instance RenderMessage LiteApp FormMessage where
     renderMessage _ _ = defaultFormMessage
 
@@ -603,7 +618,7 @@
         ((mfoo, widget), _) <- runFormPost
                         $ renderDivs
                         $ (,)
-                      Control.Applicative.<$> areqMsg textField "Some Label" ("Missing Label" :: SomeMessage LiteApp) Nothing
+                      <$> areqMsg textField "Some Label" ("Missing Label" :: SomeMessage LiteApp) Nothing
                       <*> areq fileField "Some File" Nothing
         case mfoo of
             FormSuccess (foo, _) -> return $ toHtml foo
@@ -614,7 +629,7 @@
           (field2F, field2V) <- mreq fileField "Some MFile" Nothing
 
           return
-            ( (,) Control.Applicative.<$> field1F <*> field2F
+            ( (,) <$> field1F <*> field2F
             , [field1V, field2V]
             )
         case mfoo of
@@ -625,7 +640,7 @@
           field1F <- wreqMsg textField "Some WLabel" ("Missing WLabel" :: SomeMessage LiteApp) Nothing
           field2F <- wreq fileField "Some WFile" Nothing
 
-          return $ (,) Control.Applicative.<$> field1F <*> field2F
+          return $ (,) <$> field1F <*> field2F
         case mfoo of
             FormSuccess (foo, _) -> return $ toHtml foo
             _                    -> defaultLayout widget
@@ -776,6 +791,22 @@
     app <- getYesod
     pure $ T.pack $ show (routedAppInteger app)
 
+getMultiParamR :: Handler Html
+getMultiParamR = do
+    req <- getRequest
+    defaultLayout [whamlet|
+        <form method=post action=@{MultiParamR}>
+            <input type=hidden name=#{defaultCsrfParamName} value=#{fromMaybe mempty (reqToken req)}>
+        |]
+
+postMultiParamR :: Handler Html
+postMultiParamR = do
+    (reqParts,_) <- runRequestBody
+    pure [shamlet|
+            $forall (name, part) <- reqParts
+                $if name /= "_token"
+                    #{name}: #{part} -- #
+        |]
 
 -- infix Copied from HSpec's version
 infix 1 `liftedShouldThrow`
diff --git a/yesod-test.cabal b/yesod-test.cabal
--- a/yesod-test.cabal
+++ b/yesod-test.cabal
@@ -1,5 +1,5 @@
 name:               yesod-test
-version:            1.6.23
+version:            1.7.0.3
 license:            MIT
 license-file:       LICENSE
 author:             Nubis <nubis@woobiz.com.ar>
@@ -24,7 +24,7 @@
   build-depends:
       aeson             >= 1.0     && < 2.3
     , attoparsec        >= 0.10    && < 0.15
-    , base              >= 4.10    && < 5
+    , base              >= 4.11    && < 5
     , blaze-builder     >= 0.3.1   && < 0.5
     , blaze-html        >= 0.5     && < 0.10
     , blaze-markup      >= 0.6     && < 0.9
@@ -44,13 +44,13 @@
     , pretty-show       >= 1.6     && < 1.11
     , process           >= 1.6     && < 1.7
     , text              >= 1.2     && < 2.2
-    , time              >= 1.5     && < 1.13
+    , time              >= 1.5     && < 1.15
     , transformers      >= 0.2.2   && < 0.7
     , wai               >= 3.0     && < 3.3
     , wai-extra         >= 3.0     && < 3.2
-    , xml-conduit       >= 1.0     && < 1.10
+    , xml-conduit       >= 1.0     && < 1.11
     , xml-types         >= 0.3     && < 0.4
-    , yesod-core        >= 1.6.17  && < 1.7
+    , yesod-core        >= 1.6.17  && < 1.8
 
   exposed-modules:
     Yesod.Test
@@ -66,9 +66,11 @@
   type:             exitcode-stdio-1.0
   main-is:          main.hs
   hs-source-dirs:   test
+  other-modules:    PRGSpec
   build-depends:
       base
     , bytestring
+    , clientsession
     , containers
     , cookie
     , hspec
