Search by

precision-soft / symfony-json-form

adrian.jeledintan

forms that serialize to json for frontend frameworks with symfony api backends

Package info

github.com/precision-soft/symfony-json-form

Type:symfony-bundle

pkg:composer/precision-soft/symfony-json-form

Statistics

Installs: 25

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.3.0 2026-09-03 10:37 UTC

README

ci PHP >= 8.2 PHPStan Level 8 Code Style PER-CS2.0 License MIT

You may fork and modify it as you wish.

Any suggestions are welcomed.

Purpose

The purpose of this library is to create forms for single page applications, with a symfony backend. The forms are constructed in the backend and serialized to json, that can be rendered in the frontend. In the assets folder you can find a react component to render the form.

A form is described by three pieces that you provide per form:

  • a DTO (DtoInterface) — the typed data structure the form maps to and from;
  • a form service (AbstractFormService) — declares the HTTP method, the submit action, and the elements;
  • the elements — the individual fields (NumberElement, StringElement, ...).

render() serializes the form (plus the DTO values) to a json structure for the frontend; handleRequest() takes the incoming request, sanitizes it, and denormalizes it back into the DTO.

render() builds a dto through getDtoClass() when none is given, so a dto must be constructible without arguments; one that is not is reported as the package's Exception (constructDto() is the hook to build it differently).

V1 vs V2

There are 2 versions of the react renderer. They consume the same backend json — only the frontend components differ:

  • formV1 — the original react components, kept for backwards compatibility.
  • formV2 — the new components and the recommended way to render the json.

Both are maintained in parallel and every fix lands in both. What actually differs:

formV1 formV2
the formik form passed down as a form prop read from FormContext
entry points Form.tsx, FormBuilder.tsx, FormButtons.tsx, FormControl.tsx Form.tsx re-exports everything; FormButton.tsx
prototype collections rendered inside FormField/FormBuilder its own PrototypeCollectionField.tsx
mutating a prototype collection FormFieldCallbacksType, filled in by the field PrototypeCollectionModifiersType, handed to the render prop
checkboxes the bool branch of FormField CheckboxField.tsx, with checkboxIcon/checkboxCheckedIcon render props
the language context React.useContext(LanguageContext) useLanguageContext()
the url generator useUrlGenerator() from service/UrlGenerator useUrlGeneratorContext(), host provided
mui autocomplete types @mui/base/AutocompleteUnstyled/useAutocomplete @mui/material/useAutocomplete

The field components themselves (TextField, DateField, DateTimeField, SelectField, AutocompleteField) are the same in both apart from those imports. The schema logic they share — turning the rendered json into the form's initial values — lives in service/Element.ts and is used by both.

Usage

Add this to your services.yaml so every form service receives the serializer:

services:
    _instanceof:
        PrecisionSoft\Symfony\JsonForm\Service\Contract\AbstractFormService:
            calls:
                - [ setSerializer, [ '@serializer' ] ]

A form service must implement four abstract methods: getDtoClass(), getMethod(), getAction(DtoInterface $dto) and build(Form $form, DtoInterface $dto).

<?php

declare(strict_types=1);

namespace Acme\Form;

use Acme\Dto\ProductEditDto;
use PrecisionSoft\Symfony\JsonForm\Contract\DtoInterface;
use PrecisionSoft\Symfony\JsonForm\Element\ArrayElement;
use PrecisionSoft\Symfony\JsonForm\Element\NumberElement;
use PrecisionSoft\Symfony\JsonForm\Form\Action;
use PrecisionSoft\Symfony\JsonForm\Form\Form;
use PrecisionSoft\Symfony\JsonForm\Service\Contract\AbstractFormService;
use Symfony\Component\HttpFoundation\Request;

class ProductEditForm extends AbstractFormService
{
    protected function getDtoClass(): string
    {
        return ProductEditDto::class;
    }

    protected function getMethod(): string
    {
        return Request::METHOD_POST;
    }

    protected function getAction(DtoInterface $dto): Action
    {
        return new Action('product-edit', ['id' => $dto instanceof ProductEditDto ? $dto->getId() : null]);
    }

    protected function build(Form $form, DtoInterface $dto): void
    {
        $form->addElement(new NumberElement('id', 'Id'))
            ->addElement(new ArrayElement('status', 'Status', ['active' => 'Active', 'inactive' => 'Inactive']));
    }
}
<?php

declare(strict_types=1);

namespace Acme\Dto;

use PrecisionSoft\Symfony\JsonForm\Contract\DtoInterface;

class ProductEditDto implements DtoInterface
{
    private int $id;
    private string $status;

    public function getId(): int
    {
        return $this->id;
    }

    public function setId(int $id): self
    {
        $this->id = $id;

        return $this;
    }

    public function getStatus(): string
    {
        return $this->status;
    }

    public function setStatus(string $status): self
    {
        $this->status = $status;

        return $this;
    }
}
<?php

declare(strict_types=1);

namespace Acme\Controller;

use Acme\Dto\ProductEditDto;
use Acme\Form\ProductEditForm;
use Acme\Service\ProductEditService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class ProductController extends AbstractController
{
    public function edit(Request $request, ProductEditForm $productEditForm, ProductEditService $productEditService): Response
    {
        $id = (int)$request->get('id');

        if (Request::METHOD_POST === $request->getMethod()) {
            /** @var ProductEditDto $dto */
            $dto = $productEditForm->handleRequest($request);

            $productEditService->save($dto);
        } else {
            $dto = $productEditService->createDto($id);
        }

        return $this->json(['form' => $productEditForm->render($dto)]);
    }
}

Form elements

Each element renders to a json node with a type the frontend dispatches on. All take name and label first; the most relevant extra constructor arguments are noted below.

Element json type Extra arguments
StringElement string
NumberElement number ?float $min, ?float $max, ?float $step
BoolElement bool
DateElement date string $format = 'Y-m-d', ?string $min, ?string $max
DateTimeElement dateTime string $format = 'Y-m-d H:i', ?string $min, ?string $max
PasswordElement password
HiddenElement hidden (label is not required)
LabelElement label display-only
FileElement file
ArrayElement array array $options, string $mode (MODE_SINGLE/MODE_MULTIPLE)
AutocompleteElement autocomplete string $route, string $mode, string $parameter = 'query'
CollectionElement collection nested elements via addElement()
PrototypeCollectionElement prototypeCollection nested elements via addElement() (repeatable)

ArrayElement and AutocompleteElement throw InvalidModeException for an unknown $mode. Element names must be alphanumeric (ctype_alnum) — this is enforced and intentional.

DateElement and DateTimeElement validate the value strictly against $format: a value that does not round-trip through the format (e.g. an overflow date such as 2021-02-30) throws InvalidValueException. When $min and/or $max are set, the value is also enforced server-side to fall within that inclusive range — an out-of-range value throws InvalidValueException. A $min or $max that does not itself parse in the element's $format throws InvalidValueException at construction: it used to be skipped silently, which turned the range off without saying so.

NumberElement enforces $min/$max the same way — inclusive, server-side, InvalidValueException when the value falls outside — and rejects a $min greater than $max at construction. $step is a frontend hint only.

CollectionElement and PrototypeCollectionElement throw InvalidValueException when the value — or, for the prototype collection, any item — is not an array. ArrayElement throws it for any item that is not scalar, because the options it is compared against always are.

Request handling and sanitization

handleRequest(Request $request, ?DtoInterface $dto = null, bool $sanitizeData = true):

  • For GET the data is read from the query string; for POST/PUT/PATCH from the json body (falling back to request->all() when the body is empty).
  • A request body that decodes to a non-array scalar (e.g. 5) throws an Exception rather than a raw TypeError, and so does the form's own key inside it (e.g. {"myForm": "text"}).
  • Pass an existing $dto to populate it in place (OBJECT_TO_POPULATE) — useful for PATCH/PUT.
  • A $dto that is not an instance of the form's getDtoClass() throws an Exception, the same guard render() has always had; a subclass of the dto is accepted and populated in place. Previously the serializer dropped a mismatched dto silently, returned a freshly constructed dto and left the object you passed untouched.
  • The json body of a POST/PUT/PATCH request is denormalized as json (hasJsonBody()), so the serializer applies its json rules — a whole number such as 150 lands in a float property. GET and form-encoded input carry strings only; they are denormalized with no format and with the type enforcement disabled.

When $sanitizeData is true (default), sanitizeData() applies the following rules before denormalization:

  • empty arrays are dropped — an absent nested structure does not override DTO defaults;
  • empty strings are kept — so a PATCH/PUT can explicitly clear a field by sending "".

Pass sanitizeData: false to denormalize the raw payload unchanged.

Serializer context

render() and handleRequest() hand a context array to the serializer, and a form declares its own by overriding two hooks:

protected const FORMAT = DateElement::FORMAT_Y_M_D;

protected function getNormalizationContext(): array
{
    return [DateTimeNormalizer::FORMAT_KEY => static::FORMAT];
}

protected function getDenormalizationContext(): array
{
    return [DateTimeNormalizer::FORMAT_KEY => '!' . static::FORMAT];
}

This is what lets a DTO hold a real DateTimeImmutable rather than a string: with no datetime_format the serializer normalizes it to RFC3339, which then fails the element's own format validation.

The two formats are deliberately not the same string:

  • normalization takes it bare — DateTimeInterface::format() has no escape character, so a leading ! would be emitted literally, as !1990-05-17;
  • denormalization takes it prefixed with !createFromFormat() fills in every field the format does not carry, and for a format with no time part at all (Y-m-d) that means reading the hour, minute and second off the system clock. ! resets them to zero instead. For Y-m-d H:i only the seconds are at stake and php already zeroes them, but the prefix is the safe default either way.

Precedence: the context derived from the request wins over getDenormalizationContext(), and the $dto argument of handleRequest() always wins over an OBJECT_TO_POPULATE the form declared.

React

Use the components from ./assets/react to interpret the backend response. This package ships the sources only; the host application is responsible for bundling them (there is no build step here). The Config component is project specific — it holds the locale context of the application. It is integrated with:

  • willdurand/js-translation-bundle for the Translator.
  • friendsofsymfony/jsrouting-bundle for the UrlGenerator.

Tests

PHP tests run in the dev container:

./dc exec dev php vendor/bin/simple-phpunit

The suite is written on precision-soft/symfony-phpunit: every test class extends AbstractTestCase and declares its subject or collaborator through getMockDto(); AbstractFormServiceTest drives the form services against a Mockery double of the Symfony Serializer (the class implements the three interfaces the intersection type demands), so an expectation the code does not meet fails the test. composer test excludes the integration group; composer test-integration runs only it. The integration suite (tests/Functional/) drives the whole round trip with nothing mocked — a form service renders a DTO to json, that json comes back as a real request body, and the service denormalizes it into a DTO again. It needs no external service.

The framework-agnostic react asset services (service/Element.ts, service/Utility.ts) are covered by a dependency-free harness using Node's built-in test runner (the dev container ships Node):

./dc exec dev sh -c 'cd assets/react && npm test'

The react sources are type-checked with tsc --noEmit. There is no build step and no node_modules here: the host application bundles these sources and owns the dependencies, so the typescript compiler lives in the dev image and the dependencies are declared in assets/react/types/vendor.d.ts. The modules the host is expected to provide (../context/*, ../component/*, ../config/Config, ../exception/Exception, ../form/Form, ../service/Logger) are declared as .d.ts stubs beside their import paths, which doubles as the contract the host has to satisfy; a real .tsx in the host tree takes precedence over the stub.

./dc exec dev sh -c 'cd assets/react && npm run typecheck'

The sources import types with import type, and the typecheck runs with verbatimModuleSyntax, so a host bundling them with a compiler that does not elide unused imports — or with verbatimModuleSyntax of its own — meets no type-only import at run time.

Exception context

Every exception in this package carries a structured context array next to its message, so the facts describing a failure do not have to be parsed back out of a string:

try {
    // ...
} catch (Exception $exception) {
    $logger->error($exception->getMessage(), $exception->getContext());
}

getContext() returns [] when nothing was attached. setContext() replaces it and returns the exception, and the constructor accepts it as an optional fourth argument. Values are expected to be scalars, so the array stays serialisable by a logger.

The context is purely additive: no message, code or previous throwable changed when it was introduced, so code that logs only getMessage() behaves exactly as before.

What this package attaches: AbstractFormService::getDataAndContext() reports formName and requestMethod when a request body is not valid JSON. The form name was previously only available interpolated into the message, and the request method was not reported at all.

Every exception in the package implements Contract\ExceptionInterface, so a consumer can read the context off any of them without knowing the concrete class. A subclass of your own that already declares a $context property or a getContext()/setContext() method will collide with Exception\Trait\ExceptionTrait.

Example application

A runnable product editor lives under .example/: three form services on the product nomenclator that use every element type the package ships, a value object built through the context hooks, and the react half that renders the same json with both formV1 and formV2 and derives its initial values from it — the minimum of code that shows the maximum of the library, with a test for every scenario. It installs the package from the working tree through a path repository, so it always tests the code as it stands; run it with .dev/validate/all.sh --example or cd .example && composer install && composer check, then cd assets/react && npm run typecheck && node --test for the react half. The directory is export-ignored and never reaches a consumer's vendor/.

Dev

git clone git@github.com:precision-soft/symfony-json-form.git
cd symfony-json-form

./dc build && ./dc up -d

Run the full gate the way the pre-commit hook runs it - the CI workflow in .github/workflows/ci.yml calls the same composer scripts and node commands, so the two cannot drift — cs-check, phpstan, phpunit, tsc --noEmit and the node tests:

.dev/validate/all.sh
.dev/validate/all.sh --integration   # also runs the integration suite
.dev/validate/all.sh --audit         # also audits the locked dependencies ( needs the network )
.dev/validate/all.sh --example       # also installs and checks the example application under .example/, php and react halves
.dev/validate/all.sh --staged        # what the pre-commit hook runs: only the languages the index touches

Mutation testing is opt-in for the same reason, plus cost - it runs the suite once per mutant:

.dev/validate/all.sh --mutation

Infection is a pinned phar in the image, not a composer dependency, and infection.json5 carries a minMsi floor equal to the last measured score, so the section fails when a change makes the suite weaker rather than only reporting a number. Raise the floor when the score improves.

The React asset tests run through Node's own test runner, inside the same container:

./dc exec dev sh -c 'cd assets/react && node --test'

Build against another PHP version with the PHP_VERSION build argument - each version is tagged as its own image, so switching back and forth costs nothing:

PHP_VERSION=8.4 ./dc build && PHP_VERSION=8.4 ./dc up -d

Coverage is available through pcov, which is installed but disabled by default:

./dc exec dev php -d pcov.enabled=1 vendor/bin/simple-phpunit --coverage-text

After editing a file, ./dc restart dev (a few seconds) is enough to be sure the container is not serving a stale copy - the bind mount can keep the old inode after an atomic rewrite.

Todo

  • Per-property serializer context, so one DTO can mix date-only and date-time properties: datetime_format currently applies to the whole DTO.