symfonyheadless-cmssulu

How to handle dynamic form submissions in Sulu CMS with JSON or x-www-form-urlencoded?


I’m working on a Sulu CMS project. I was asked to use the FormBundle and HeadlessBundle to create a contact form accessible at /contact.

I’ve set up the form, template, and page in the Sulu Admin UI, and I can submit data via POST to /contact (or /contact.json for JSON). The submissions are saved in the database (visible at Forms > Dynamic Form > Data), but Sulu did not return success or error messages in the response, which is critical for frontend feedback.

My requirements:

I’ve struggled with Sulu’s sparse documentation and found no clear examples for custom form handling with JSON. How can I extend Sulu’s FormBundle to meet these needs? Are there specific Symfony components (e.g., event listeners) I should use? Any guidance or examples would be appreciated!


Solution

  • To handle dynamic form submissions in Sulu CMS with x-www-form-urlencoded or JSON payloads, including success/error messages, you can extend Sulu’s RequestListener with a custom EventListener. I developed a solution after hours and hours of trial and error, addressing the lack of documentation for Sulu’s FormBundle in headless scenarios. Below, I share a concise implementation for both submission types, with links to detailed guides.

    Approach

    Sulu’s FormBundle saves submissions but doesn’t provide response feedback. I created a SpecialFormHandler that:

    The solution supports:

    1. x-www-form-urlencoded: For traditional form submissions to /contact.

    2. JSON: For headless submissions to /contact.json with robust validation.

    Prerequisites

    x-www-form-urlencoded Implementation

    Use a SpecialFormHandler to process POST requests to /contact.

    Core Code (src/EventListener/SpecialFormHandler.php):

    namespace App\EventListener;
    
    use App\Services\Form\CustomRequestValidator as RequestValidator;
    use Sulu\Bundle\FormBundle\Configuration\FormConfigurationFactory;
    use Sulu\Bundle\FormBundle\Entity\Dynamic;
    use Sulu\Bundle\FormBundle\Event\RequestListener as BaseRequestListener;
    use Sulu\Bundle\FormBundle\Form\BuilderInterface;
    use Sulu\Bundle\FormBundle\Form\HandlerInterface;
    use Symfony\Component\EventDispatcher\EventDispatcherInterface;
    use Symfony\Component\HttpFoundation\JsonResponse;
    use Symfony\Component\HttpFoundation\Request;
    use Symfony\Component\HttpKernel\Event\RequestEvent;
    use Symfony\Component\HttpKernel\Event\ResponseEvent;
    
    class SpecialFormHandler extends BaseRequestListener
    {
        private $formValidator;
        private $processed = false;
    
        public function __construct(
            BuilderInterface $formBuilder,
            HandlerInterface $formHandler,
            FormConfigurationFactory $formConfigurationFactory,
            EventDispatcherInterface $eventDispatcher,
            RequestValidator $formValidator
        ) {
            parent::__construct($formBuilder, $formHandler, $formConfigurationFactory, $eventDispatcher);
            $this->formValidator = $formValidator;
        }
    
        public function onKernelRequest(RequestEvent $event): void
        {
            $request = $event->getRequest();
            if (!$this->shouldProcess($request)) {
                parent::onKernelRequest($event);
                return;
            }
            try {
                $form = $this->formBuilder->buildByRequest($request);
                if (!$form || !$form->isSubmitted()) {
                    return;
                }
                $formEntity = $form->getConfig()->getOption('formEntity');
                if (!$this->formValidator->isRegisteredForm($formEntity->getId())) {
                    return;
                }
                if (!$form->isValid()) {
                    $request->attributes->set('_special_form', [
                        'errors' => $this->getFormErrors($form),
                        'form_id' => $formEntity->getId()
                    ]);
                    $this->processed = true;
                    return;
                }
                $dynamic = $form->getData();
                $locale = $dynamic->getLocale() ?? 'en';
                $configuration = $this->formConfigurationFactory->buildByDynamic($dynamic);
                $dynamic->setLocale($locale);
                if ($this->formHandler->handle($form, $configuration)) {
                    $translation = $formEntity->getTranslation($locale);
                    $request->attributes->set('_special_form', [
                        'success' => true,
                        'message' => $translation->getSuccessText() ?? 'Thank you!',
                        'form_id' => $formEntity->getId()
                    ]);
                    $this->processed = true;
                }
            } catch (\Exception $e) {
                $request->attributes->set('_special_form', [
                    'error' => $e->getMessage(),
                    'form_id' => $formEntity->getId() ?? null
                ]);
                $this->processed = true;
            }
        }
    
        public function onKernelResponse(ResponseEvent $event): void
        {
            if (!$this->processed) {
                return;
            }
            $request = $event->getRequest();
            $formData = $request->attributes->get('_special_form');
            if (isset($formData['errors'])) {
                $event->setResponse(new JsonResponse([
                    'success' => false,
                    'form_type' => $this->formValidator->getFormType($formData['form_id']),
                    'errors' => $formData['errors']
                ], 400));
            } elseif (isset($formData['error'])) {
                $event->setResponse(new JsonResponse([
                    'success' => false,
                    'form_type' => $this->formValidator->getFormType($formData['form_id']),
                    'error' => 'Form processing failed'
                ], 500));
            } elseif (isset($formData['success'])) {
                $event->setResponse(new JsonResponse([
                    'success' => true,
                    'form_type' => $this->formValidator->getFormType($formData['form_id']),
                    'message' => $formData['message']
                ], 201));
            }
        }
    
        private function shouldProcess(Request $request): bool
        {
            return $request->isMethod('POST') && $this->formValidator->isSpecialFormRequest($request);
        }
    
        private function getFormErrors($form): array
        {
            $errors = [];
            foreach ($form->getErrors(true) as $error) {
                $errors[$error->getOrigin() ? $error->getOrigin()->getName() : '_global'] = $error->getMessage();
            }
            return $errors;
        }
    }
    

    Validator (src/Services/Form/CustomRequestValidator.php):

    namespace App\Services\Form;
    
    use Symfony\Component\HttpFoundation\Request;
    
    class CustomRequestValidator
    {
        private const REGISTERED_FORMS = [1 => 'contact_form'];
        private const SPECIAL_FORM_ROUTES = ['/contact'];
    
        public function isSpecialFormRequest(Request $request): bool
        {
            return $this->isFormApiRoute($request) || $this->containsRegisteredFormId($request);
        }
    
        public function isFormApiRoute(Request $request): bool
        {
            if (!$request->isMethod('POST')) {
                return false;
            }
            $currentPath = $request->getPathInfo();
            foreach (self::SPECIAL_FORM_ROUTES as $route) {
                if (str_starts_with($currentPath, $route)) {
                    return true;
                }
            }
            return false;
        }
    
        public function containsRegisteredFormId(Request $request): bool
        {
            try {
                $formId = (int) $request->request->get('formId');
                return isset(self::REGISTERED_FORMS[$formId]);
            } catch (\Exception $e) {
                return false;
            }
        }
    
        public function isRegisteredForm(int $formId): bool
        {
            return isset(self::REGISTERED_FORMS[$formId]);
        }
    
        public function getFormType(int $formId): ?string
        {
            return self::REGISTERED_FORMS[$formId] ?? null;
        }
    }
    

    For service configuration and full details, see: Handling Form Submissions with x-www-form-urlencoded.

    Example Request:

    curl -X POST http://your-site/contact \https://github.com/yaovicoder/sulu-adventures/wiki/Handling-Form-Submissions-with-x-www-form-urlencoded
    -H "Content-Type: application/x-www-form-urlencoded" \
    -d "formId=1&name=John&email=john@example.com&message=Hello"
    

    Response (HTTP 201):

    {"success":true,"form_type":"contact_form","message":"Thank you!"}
    

    JSON Implementation

    For JSON submissions to /contact.json, enhance SpecialFormHandler with validators to handle JSON payloads.

    Core Code (Key Methods, src/EventListener/SpecialFormHandler.php):

    // Same namespace, imports, and constructor as above, with additional dependencies
    use App\Request\JsonRequestProcessor;
    use App\Services\Form\FormRequestValidator as FormValidator;
    use App\Services\Form\FormRequestPreparator;
    use Symfony\Component\HttpFoundation\Response;
    
    class SpecialFormHandler extends BaseRequestListener
    {
        private $formValidator;
        private $formPreparator;
        private $jsonProcessor;
        private $processed = false;
    
        public function __construct(
            BuilderInterface $formBuilder,
            HandlerInterface $formHandler,
            FormConfigurationFactory $formConfigurationFactory,
            EventDispatcherInterface $eventDispatcher,
            FormValidator $formValidator,
            FormRequestPreparator $formPreparator,
            JsonRequestProcessor $jsonProcessor
        ) {
            parent::__construct($formBuilder, $formHandler, $formConfigurationFactory, $eventDispatcher);
            $this->formValidator = $formValidator;
            $this->formPreparator = $formPreparator;
            $this->jsonProcessor = $jsonProcessor;
        }
    
        public function onKernelRequest(RequestEvent $event): void
        {
            $request = $event->getRequest();
            if (!$this->formValidator->isFormApiRoute($request)) {
                parent::onKernelRequest($event);
                return;
            }
            if (!$this->jsonProcessor->isJsonRequest($request)) {
                $request->attributes->set('_special_form', [
                    'error' => 'Only application/json supported',
                    'form_id' => null,
                    'error_code' => Response::HTTP_UNSUPPORTED_MEDIA_TYPE
                ]);
                $this->processed = true;
                return;
            }
            try {
                $request = $this->formPreparator->prepareFormRequest($request);
                $form = $this->formBuilder->buildByRequest($request);
                if (!$form) {
                    $request->attributes->set('_special_form', ['error' => 'Form could not be built', 'form_id' => null]);
                    $this->processed = true;
                    return;
                }
                $formEntity = $form->getConfig()->getOption('formEntity');
                if (!$form->isValid()) {
                    $request->attributes->set('_special_form', [
                        'errors' => $this->getFormErrors($form),
                        'form_id' => $formEntity->getId()
                    ]);
                    $this->processed = true;
                    return;
                }
                $dynamic = $form->getData();
                $locale = $dynamic->getLocale() ?? 'en';
                $configuration = $this->formConfigurationFactory->buildByDynamic($dynamic);
                $dynamic->setLocale($locale);
                if ($this->formHandler->handle($form, $configuration)) {
                    $translation = $formEntity->getTranslation($locale);
                    $request->attributes->set('_special_form', [
                        'success' => true,
                        'message' => $translation->getSuccessText() ?? 'Thank you!',
                        'form_id' => $formEntity->getId()
                    ]);
                    $this->processed = true;
                }
            } catch (\Exception $e) {
                $request->attributes->set('_special_form', ['error' => $e->getMessage(), 'form_id' => null]);
                $this->processed = true;
            }
        }
    
        // onKernelResponse and other methods same as x-www-form-urlencoded
    }
    

    The JSON solution requires additional validators (FormRequestValidator, FormRequestPreparator, etc.). For complete code, see: Enabling Headless JSON Form Submissions.

    Example Request:

    curl -X POST http://your-site/contact.json \
    -H "Content-Type: application/json" \
    -d '{"dynamic_form_contact1":{"formId":"1","formName":"form_contact","type":"contact","typeId":"1","checksum":"abc123","locale":"en","name":"John Doe","email":"john@example.com","message":"Hello"}}'
    

    Response (HTTP 201):

    {"success":true,"form_type":"contact_form","message":"Thank you!"}
    

    Full Series

    I’ve documented a comprehensive series on Sulu CMS dynamic form submissions, covering setup, x-www-form-urlencoded, and JSON handling.

    See the index: [Gist].

    Full series: on my sulu-adventures wiki.

    Notes

    This approach meets all requirements while maintaining compatibility with Sulu’s default form handling.