Slide 1

Slide 1 text

SEPTEMBER 18 -19, 2025 - LILLE, FRANCE & ONLINE How API Platform 4.2 is Redefining API Development 5th edition

Slide 2

Slide 2 text

No content

Slide 3

Slide 3 text

✔ API Platform release manager ✔ Developer, biker, builder ✔ Father ✔ Open source advocate ✔ CTO at Les-Tilleuls.coop Antoine Bluchet aka soyuka https:/ /github.com/soyuka

Slide 4

Slide 4 text

Retrospective 610 commits since 4.0 Lines of code +189 945 -328 879 291 issues opened of which 230 are closed!

Slide 5

Slide 5 text

API Platform 4.2

Slide 6

Slide 6 text

✔ FrankenPHP ✔ State Options magic (introduced in 3.1) ✔ Query Parameters ✔ Performances ✔ Laravel ✔ OpenAPI & JSON Schema

Slide 7

Slide 7 text

Metadata

Slide 8

Slide 8 text

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(), ])) ;

Slide 9

Slide 9 text

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); } }

Slide 10

Slide 10 text

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); } }

Slide 11

Slide 11 text

No content

Slide 12

Slide 12 text

From API Filter to Parameters

Slide 13

Slide 13 text

API Filter use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Doctrine\Orm\Filter\DateFilter; #[ApiFilter(DateFilter::class, properties: ['dateProperty'])] class Offer { // ... }

Slide 14

Slide 14 text

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?)

Slide 15

Slide 15 text

… 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

Slide 16

Slide 16 text

No content

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

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}> */ public function getDescription(string $resourceClass): array; } Current interface: ✔ type ✔ required ✔ description ✔ strategy ✔ schema ✔ openapi ✔ etc.

Slide 19

Slide 19 text

1. JSON Schema namespace ApiPlatform\Metadata; interface JsonSchemaFilterInterface { /** * @return array */ public function getSchema(Parameter $parameter): array; } ✔ Document your parameter JSON Schema PHP type and Validation will be inferred from the JSON Schema if possible

Slide 20

Slide 20 text

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)

Slide 21

Slide 21 text

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() )); } }

Slide 22

Slide 22 text

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!

Slide 23

Slide 23 text

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 {}

Slide 24

Slide 24 text

No content

Slide 25

Slide 25 text

New ORM/ODM Filters ✔ ExactFilter ✔ IriFilter ✔ PartialSearchFilter ✔ OrFilter ✔ FreeTextQueryFilter ✔ + existing filters

Slide 26

Slide 26 text

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 {}

Slide 27

Slide 27 text

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 {}

Slide 28

Slide 28 text

No content

Slide 29

Slide 29 text

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); } }

Slide 30

Slide 30 text

URI Variable provider class EmployeeProvider implements ProviderInterface { public function provide(Operation $operation, ...): object { $link = $operation->getUriVariables()['company']; assert($link->getValue() instanceof Company); // ... } }

Slide 31

Slide 31 text

OpenAPI

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

Specification improvements ✔ Root level tags, licence and default values improved ✔ JSON Schema inconsistencies fixed ✔ Partial pagination documented https:/ /pb33f.io

Slide 34

Slide 34 text

Performances

Slide 35

Slide 35 text

No content

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

Requests per Second https:/ /soyuka.github.io/sylius-benchmarks/

Slide 38

Slide 38 text

Average latency (ms) https:/ /soyuka.github.io/sylius-benchmarks/

Slide 39

Slide 39 text

No content

Slide 40

Slide 40 text

State options

Slide 41

Slide 41 text

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']); }

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

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

Slide 44

Slide 44 text

The idea namespace App\ApiResource; use App\Entity\User; #[ApiResource( shortName: 'User', stateOptions: new Options(entityClass: User::class), )] class UserApi { public ?int $id = null; }

Slide 45

Slide 45 text

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; }

Slide 46

Slide 46 text

The Solution ✔ Symfony ObjectMapper component ✔ Attribute-based configuration ✔ Transform/Condition mapping ✔ Embed in API Platform 4.2 when stateOptions is used

Slide 47

Slide 47 text

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; }

Slide 48

Slide 48 text

Sylius party ✔ Sylius + API Platform ✔ JSON-LD ✔ Rich results ✔ Using the API on the front-end

Slide 49

Slide 49 text

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;

Slide 50

Slide 50 text

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())); } }

Slide 51

Slide 51 text

Rich results

Slide 52

Slide 52 text

Profiling is back! Add your text here

Slide 53

Slide 53 text

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

Slide 54

Slide 54 text

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

Slide 55

Slide 55 text

No content

Slide 56

Slide 56 text

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

Slide 57

Slide 57 text

No content

Slide 58

Slide 58 text

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

Slide 59

Slide 59 text

API Platform 5.0 is coming ! ✔ Deprecation of ApiFilter ✔ JSON Streamer ✔ Object Mapper ✔ Community’s improvements

Slide 60

Slide 60 text

Thanks! SPONSOR ME! github.com/soyuka