What the component does
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,
) {
}
}
Bash
composer require "symfony/json-schema:^8.2@dev"
A versioned contract, generated by a command
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;
}
}
The types on the front end
Bash
npx openapi-typescript contracts/openapi.json -o front/src/api/contract.d.ts
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']
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 }
}
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)
}
}
The CI that catches the drift
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
The traps
- 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.
Sources
- New in Symfony 8.2: JsonSchema Component — Symfony blog, Javier Eguiluz, October 20, 2026
- Symfony 8.2 release — symfony.com
- openapi-typescript: introduction — openapi-typescript documentation