Europe/Paris
Posts

Symfony 8.2 JsonSchema: one API contract for PHP, Vue and Angular

October 23, 2026 · 4 min de lecture
The PHP DTO says a book title is required, the TypeScript type written by hand on the front end says title?: string, and nobody notices before production. Symfony 8.2 adds a JsonSchema component that generates the schema from your PHP classes: enough to keep a single source of truth between the back end and a Vue or Angular front end. Presented by Javier Eguiluz on the Symfony blog, the component turns a PHP type into a JSON Schema (a standard JSON document that describes the expected shape of data: fields, types, required properties, constraints) or into an OpenAPI schema. Its engine comes from API Platform's json-schema package, contributed by Antoine Bluchet, without the API Platform-specific parts. If you already use API Platform, you already have this. The useful part is that the generator reads what your app already declares. It follows the Serializer metadata (groups, name converter) and the Validator constraints:
Php
<?php

declare(strict_types=1);

namespace App\Api\Payload;

use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;

final class Book
{
    public function __construct(
        #[Assert\NotBlank]
        #[Groups(['book:read', 'book:write'])]
        public string $title,

        #[Groups(['book:read'])]
        public ?\DateTimeImmutable $publishedAt = null,
    ) {
    }
}
NotBlank becomes minLength: 1, and $title is marked required because it has no default value. When the Serializer is enabled, the generator also uses its default datetime_format, so the schema describes the JSON your app actually produces. A word on timing: Symfony 8.2 is due in November 2026 and still in development. The component itself only requires PHP 8.2 and works with Symfony 7.4 components, so you can try it on a 7.4 project with a dev constraint until the stable release:
Bash
composer require "symfony/json-schema:^8.2@dev"
The idea: a console command that writes an OpenAPI 3.1 document containing only the schemas of the payloads, committed in the repository. OpenAPI 3.1 allows a document without paths, which is exactly what we need here.
Php
<?php

declare(strict_types=1);

namespace App\Command;

use App\Api\Payload\Book;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\JsonSchema\Configuration;
use Symfony\Component\JsonSchema\Dialect;
use Symfony\Component\JsonSchema\Direction;
use Symfony\Component\JsonSchema\SchemaGeneratorInterface;
use Symfony\Component\TypeInfo\Type;

#[AsCommand(name: 'app:contract:dump', description: 'Writes the OpenAPI contract of the API payloads')]
final class DumpApiContractCommand
{
    /** @var list<array{class-string, list<string>, Direction}> */
    private const PAYLOADS = [
        [Book::class, ['book:write'], Direction::Input],
        [Book::class, ['book:read'], Direction::Output],
    ];

    public function __construct(
        private readonly SchemaGeneratorInterface $generator,
        private readonly Filesystem $filesystem,
        #[Autowire('%kernel.project_dir%')]
        private readonly string $projectDir,
    ) {
    }

    public function __invoke(SymfonyStyle $io): int
    {
        $schemas = [];
        foreach (self::PAYLOADS as [$class, $groups, $direction]) {
            $document = $this->generator->generate(Type::object($class), new Configuration(
                dialect: Dialect::openApi31(),
                groups: $groups,
                direction: $direction,
            ));
            $schemas = [...$schemas, ...$document->getDefinitions()];
        }

        ksort($schemas);

        $contract = [
            'openapi' => '3.1.0',
            'info' => ['title' => 'Books API', 'version' => '1.0.0'],
            'components' => ['schemas' => $schemas],
        ];

        $this->filesystem->dumpFile(
            $this->projectDir.'/contracts/openapi.json',
            json_encode($contract, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_THROW_ON_ERROR)."\n",
        );

        $io->success(sprintf('%d schemas written to contracts/openapi.json', \count($schemas)));

        return Command::SUCCESS;
    }
}
Direction::Input describes what the API receives, Direction::Output what it sends back. The same class therefore yields two distinct schemas, one per group. The ksort() keeps a stable order, so the diffs stay readable. On the front side, openapi-typescript turns that document into TypeScript types, with no runtime code:
Bash
npx openapi-typescript contracts/openapi.json -o front/src/api/contract.d.ts
Then a small file that gives readable names to the generated schemas. The keys follow the format shown in the Symfony article (class, group, direction); check the exact keys in your generated file.
Typescript
// front/src/api/types.ts
import type { components } from './contract'

export type BookWrite = components['schemas']['Book-book.write_input']
export type BookRead = components['schemas']['Book-book.read_output']
In Vue 3, a composable that can only send a valid payload:
Typescript
// front/src/composables/useCreateBook.ts
import { ref } from 'vue'
import type { BookRead, BookWrite } from '@/api/types'

export function useCreateBook() {
  const pending = ref(false)
  const error = ref<string | null>(null)

  async function createBook(input: BookWrite): Promise<BookRead | null> {
    pending.value = true
    error.value = null
    try {
      const response = await fetch('/api/books', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(input),
      })
      if (!response.ok) {
        error.value = `HTTP ${response.status}`
        return null
      }
      return (await response.json()) as BookRead
    } finally {
      pending.value = false
    }
  }

  return { pending, error, createBook }
}
In Angular, the same contract in a service:
Typescript
// src/app/books/book-api.ts
import { HttpClient } from '@angular/common/http'
import { Injectable, inject } from '@angular/core'
import type { Observable } from 'rxjs'
import type { BookRead, BookWrite } from '../api/types'

@Injectable({ providedIn: 'root' })
export class BookApi {
  private readonly http = inject(HttpClient)

  create(input: BookWrite): Observable<BookRead> {
    return this.http.post<BookRead>('/api/books', input)
  }
}
If someone makes title optional on the PHP side, or renames a field, vue-tsc or ng build fails on every call that no longer matches. The contract only holds if nobody can forget to regenerate it. One job, in an image that has both PHP and Node:
Yaml
contract:
  stage: test
  script:
    - composer install --no-interaction --no-progress
    - php bin/console app:contract:dump
    - npx --yes openapi-typescript contracts/openapi.json -o front/src/api/contract.d.ts
    - git diff --exit-code -- contracts/ front/src/api/contract.d.ts
If a PR changes a PHP payload without committing the regenerated contract, git diff --exit-code fails and shows exactly what changed. The same component has a second use that its article highlights: generateForParameters() describes the parameters of a PHP method, which is the format expected to declare tools for AI agents, for example on an MCP server.
  • TypeScript types disappear at runtime. A response that doesn't respect the contract passes silently, and as BookRead is a promise, not a check. On sensitive boundaries, validate the payload with the JSON Schema itself, for example with Ajv in its 2020-12 version.
  • The schema doesn't prove the endpoint uses it. If the controller serializes with other groups than the ones in the command, the contract describes an API that doesn't exist. Keep the groups in one place, for example as constants on the payload class.
  • Version 8.2 isn't out yet. Until the stable release, the API of the component can still change. Pin the version, and don't put it in production on the strength of a blog post.
The contract file has another effect: in review, a change to the API now shows up as a diff in a JSON file, readable by the front-end team before it breaks their build.
On this page
Book a call