Upgrade to Pro — share decks privately, control downloads, hide ads and more …

API Platform 4.2: Redefining API Development

API Platform 4.2: Redefining API Development

API Platform 4.2 marks a major step forward, focusing on developer experience and performance. In this session, I’ll guide you through its most impactful new features, all demonstrated on a real-world Symfony application.

We’ll explore the powerful new Object Mapper, a Symfony component I created to reduce boilerplate, and dive into the high-performance Json Streamer component integration, which reduces memory consumption and improves your API’s time-to-first-byte (TTFB). You’ll also see how a revamped core—with improved resource metadata, powerful new filters, and an intuitive design-first approach—makes development faster and more robust.

This session goes beyond a feature list to offer a practical guide for building better, more efficient APIs.

Avatar for Antoine Bluchet

Antoine Bluchet

September 18, 2025
Tweet

More Decks by Antoine Bluchet

Other Decks in Programming

Transcript

  1. SEPTEMBER 18 -19, 2025 - LILLE, FRANCE & ONLINE How

    API Platform 4.2 is Redefining API Development 5th edition
  2. ✔ API Platform release manager ✔ Developer, biker, builder ✔

    Father ✔ Open source advocate ✔ CTO at Les-Tilleuls.coop Antoine Bluchet aka soyuka https:/ /github.com/soyuka
  3. Retrospective 610 commits since 4.0 Lines of code +189 945

    -328 879 291 issues opened of which 230 are closed!
  4. ✔ FrankenPHP ✔ State Options magic (introduced in 3.1) ✔

    Query Parameters ✔ Performances ✔ Laravel ✔ OpenAPI & JSON Schema
  5. PHP File Metadata // config/api_platform/resources/speaker.php use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use

    ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Post; use App\Entity\Speaker; return (new ApiResource()) ->withClass(Speaker::class) ->withOperations(new Operations([ new Post(), new Get(), new GetCollection(), ])) ;
  6. Metadata Mutators use ApiPlatform\Metadata\AsResourceMutator; use ApiPlatform\Metadata\ApiResource; use App\Entity\Speaker; #[AsResourceMutator(resourceClass: Speaker::class)]

    final class SpeakerResourceMutator { public function __invoke(ApiResource $resource): ApiResource { $operations = $resource->getOperations(); $operations->remove('_api_Speaker_get_collection'); return $resource->withOperations($operations); } }
  7. Metadata mutators use ApiPlatform\Metadata\AsOperationMutator; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\OperationMutatorInterface; use App\Entity\Product\Product;

    #[AsOperationMutator(operationName: 'sylius_api_shop_product_get')] class ProductOperationMutator implements OperationMutatorInterface { public function __invoke(Operation $operation): Operation { return $operation->withJsonStream(true); } }
  8. 8 years old code… ✔ ApiFilter declares services and tag

    them as api_platform.filter ✔ A Filter service describes documentation ✔ A Filter service also computes the SQL Query ✔ A Filter acts on multiple properties ✔ A Filter can be applied on different PHP Types (SearchFilter on dates?)
  9. … and that’s fine! Gather together the things that change

    for the same reasons. Separate those things that change for different reasons. — Robert C. Martin https:/ /blog.cleancoder.com/uncle-bob/2014/05/08/SingleRepon sibilityPrinciple.html
  10. Transformations (IRIs, arrays etc.) 01 02 03 04 Documentation (e.g.

    OpenAPI) Validation (e.g. JSONSchema, PHP Type) Filtering data (e.g. SQL query) Responsibilities of our current Filters
  11. Filter Documentation interface FilterInterface { /** * Gets the description

    of this filter for the given resource. * * Returns an array with the filter parameter names as keys and array with the following data as values: * - property: the property where the filter is applied * - type: the type of the filter * - required: if this filter is required * - description : the description of the filter * - strategy: the used strategy * - is_collection: if this filter is for collection * - openapi: additional parameters for the path operation in the version 3 spec, * e.g. 'openapi' => ApiPlatform\OpenApi\Model\Parameter( * description: 'My Description', * name: 'My Name', * schema: [ * 'type' => 'integer', * ] * ) * - schema: schema definition, * e.g. 'schema' => [ * 'type' => 'string', * 'enum' => ['value_1', 'value_2'], * ] * The description can contain additional data specific to a filter. * * @see \ApiPlatform\OpenApi\Factory\OpenApiFactory::getFiltersParameters * * @param class-string $resourceClass * * @return array<string, array{property?: string, type?: string, required?: bool, description?: string, strategy?: string, is_collection?: bool, openapi?: \ApiPlatform\OpenApi\Model\Parameter, schema?: array<string, mixed>}> */ public function getDescription(string $resourceClass): array; } Current interface: ✔ type ✔ required ✔ description ✔ strategy ✔ schema ✔ openapi ✔ etc.
  12. 1. JSON Schema namespace ApiPlatform\Metadata; interface JsonSchemaFilterInterface { /** *

    @return array<string, mixed> */ public function getSchema(Parameter $parameter): array; } ✔ Document your parameter JSON Schema PHP type and Validation will be inferred from the JSON Schema if possible
  13. 2. OpenAPI namespace ApiPlatform\Metadata; use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; interface OpenApiParameterFilterInterface

    { public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null; } ✔ Document the OpenAPI Parameter (defaults to the JSON Schema)
  14. Filtering use ApiPlatform\Doctrine\Orm\FilterInterface; use Doctrine\ORM\QueryBuilder; final class PartialSearchFilter implements FilterInterface

    { public function apply(QueryBuilder $queryBuilder, /* ... */array $context = []): void { $parameter = $context['parameter']; $queryBuilder->setParameter( $parameter->getKey(), '%'.strtolower($parameter->getValue()).'%' ); $queryBuilder->andWhere($queryBuilder->expr()->like( sprintf('LOWER(o.%s)', $parameter->getProperty()), ':'.$parameter->getKey() )); } }
  15. Back to parameter definition abstract class Parameter { public function

    __construct( protected ?string $key = null, protected ?array $schema = null, protected OpenApiParameter|array|false|null $openApi = null, protected mixed $provider = null, /** @param FilterInterface|string $filter */ protected mixed $filter = null, protected ?string $property = null, protected ?string $description = null, protected ?bool $required = null, protected mixed $constraints = null, protected string|\Stringable|null $security = null, protected ?array $extraProperties = [], ... ) {} } Focus on the shape of your API parameter!
  16. Parameter usage use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; #[GetCollection( parameters:

    [ 'search[:property]' => new QueryParameter( properties: ['title', 'author'], filter: new PartialSearchFilter() ) ] )] class Book {}
  17. New ORM/ODM Filters ✔ ExactFilter ✔ IriFilter ✔ PartialSearchFilter ✔

    OrFilter ✔ FreeTextQueryFilter ✔ + existing filters
  18. Free text search use ApiPlatform\Doctrine\Orm\Filter\FreeTextQueryFilter; use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; use ApiPlatform\Metadata\GetCollection; use

    ApiPlatform\Metadata\QueryParameter; #[GetCollection( parameters: [ 'q' => new QueryParameter( filter: new FreeTextQueryFilter(new PartialSearchFilter()), properties: ['name', 'ean'], ), ], )] class Book {}
  19. Free text search use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; use ApiPlatform\Doctrine\Orm\Filter\FreeTextQueryFilter; use ApiPlatform\Doctrine\Orm\Filter\OrFilter; use

    ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; #[GetCollection( parameters: [ 'q' => new QueryParameter( filter: new FreeTextQueryFilter( new OrFilter(new ExactFilter()), ), properties: ['name', 'ean'], ), ], )] class Book {}
  20. URI Variable provider use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Operation; use

    ApiPlatform\State\ParameterProvider\ReadLinkParameterProvider; Mettre vrai exemple avec IRI #[Get( uriTemplate: '/do_something_with_dummy/{dummy}', uriVariables: [ 'dummy' => new Link( provider: ReadLinkParameterProvider::class, fromClass: Dummy::class ), ], provider: [self::class, 'provide'] )] class HasDummy { public string $id; public static function provide(Operation $operation, array $uriVariables = []) { assert($operation->getUriVariables()['dummy']->getValue() instanceof Dummy); } }
  21. URI Variable provider class EmployeeProvider implements ProviderInterface { public function

    provide(Operation $operation, ...): object { $link = $operation->getUriVariables()['company']; assert($link->getValue() instanceof Company); // ... } }
  22. JSON Schema enhancement ✔ Mutualizes JSON Schema ✔ 30% lower

    file size on an OpenAPI specification ✔ Less IO intensive https://github.com/api-platform/core/pull/6960
  23. Specification improvements ✔ Root level tags, licence and default values

    improved ✔ JSON Schema inconsistencies fixed ✔ Partial pagination documented https:/ /pb33f.io
  24. Nginx vs FrankenPHP Scaleway DEV1-S (2 vCPUs, 2 GB RAM)

    Database Server: 2 vCPUs, 2 GB RAM, MySQL Benchmark Tool: wrk (on a separate DEV1-S instance) Nginx + PHP-FPM: pm.max_children = 15 FrankenPHP (Worker Mode): worker.num = 36 FrankenPHP (Thread Mode): num_threads = 36 (Worker vs. Thread) Performance Benchmark on a Sylius Application
  25. Subresources queries use ApiPlatform\Doctrine\Orm\State\Options; use ApiPlatform\Metadata\GetCollection; use Doctrine\ORM\QueryBuilder; #[GetCollection( uriTemplate:

    '/company/{companyId}/employees', uriVariables: ['companyId'], stateOptions: new Options(handleLinks: [Employee::class, 'handleLinks']) )] #[ORM\Entity] class Employee { public static function handleLinks(QueryBuilder $queryBuilder, array $uriVariables, ...): void { $queryBuilder ->andWhere('o.company = :companyId') ->setParameter('companyId', $uriVariables['companyId']); }
  26. stateOptions + entityClass Magic Rest in peace Ryan <3 ✔

    Allows to use another entity as data source ✔ Now uses the Object Mapper under the hood ✔ Popularized by a Symfony cast tutorial ✔ Follows our design best practices
  27. stateOptions + entityClass Magic ✔ Allows to use another entity

    as data source ✔ Now uses the Object Mapper under the hood ✔ Popularized by a Symfony cast tutorial ✔ Follows our design best practices Thank you Ryan <3
  28. The idea namespace App\ApiResource; use App\Entity\User; #[ApiResource( shortName: 'User', stateOptions:

    new Options(entityClass: User::class), )] class UserApi { public ?int $id = null; }
  29. The problem The API Resource is different in the persistent

    storage namespace App\ApiResource; use App\Entity\User; class UserApi { public ?int $id = null; public ?string $userName = null; } namespace App\ApiResource; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] class User { public ?int $id = null; public ?string $firstName = null; public ?string $lastName = null; }
  30. The Solution ✔ Symfony ObjectMapper component ✔ Attribute-based configuration ✔

    Transform/Condition mapping ✔ Embed in API Platform 4.2 when stateOptions is used
  31. The Solution namespace App\ApiResource; use App\Entity\User; #[ApiResource( shortName: 'User', stateOptions:

    new Options(entityClass: User::class), )] #[Map(target: User::class)] class UserApi { public ?int $id = null; }
  32. Sylius party ✔ Sylius + API Platform ✔ JSON-LD ✔

    Rich results ✔ Using the API on the front-end
  33. schema.org / Product use ApiPlatform\Metadata\Get; use App\State\GetProductBySlugProvider; #[Get( types: ['https://schema.org/Product'],

    uriTemplate: '/products/{code}', uriVariables: ['code'], provider: GetProductBySlugProvider::class )] class Product { #[ApiProperty(identifier: true)] public string $code;
  34. use ApiPlatform\Metadata\ApiProperty; use App\Entity\Product\Product as EntityProduct; use Symfony\Component\ObjectMapper\Attribute\Map; #[Map(source: EntityProduct::class)]

    class Product { #[Map(source: 'averageRating', transform: [self::class, 'getAggregateRating'])] #[ApiProperty(genId: false, iris: ['https://schema.org/aggregateRating'])] public AggregateRating $aggregateRating; /** @param EntityProduct $source */ public static function getAggregateRating(mixed $value, object $source, ?object $target): AggregateRating { return new AggregateRating($value, \count($source->getReviews())); } }
  35. JSON Streamer x API Platform ✔ Opt-in (jsonStream: true) ✔

    TypeInfo component integration ✔ JSON and JSON-LD / Hydra compatible ✔ Read/write ability ✔ Caveat: Only public properties
  36. JSON Streamer vs Serializer API: Requests per Second Performance Benchmark

    on a Sylius API #[Get( types: ['https://schema.org/Product'], uriTemplate: '/products/{code}', uriVariables: ['code'], jsonStream: true )] ~32.4% more requests per second
  37. Laravel ✔ 124 merged PR since the release! ✔ 79

    of 99 issues closed ✔ Covers ~80% of Symfony supported features ✔ Special mention to our top Laravel contributors: @vinceAmstoutz, @cay89, @jonerickson, @amermchaudhary, @toitzi, @ttskch
  38. Backward compatibility ✔ JSON-Schema ✔ OpenAPI defaults ✔ Parameters are

    no longer experimental ✔ Link security enabled by default No deprecations, lots of new features! See also the Changelog! composer update api-platform/symfony:^4.2
  39. API Platform 5.0 is coming ! ✔ Deprecation of ApiFilter

    ✔ JSON Streamer ✔ Object Mapper ✔ Community’s improvements