From ebcbe241cf0d10a9836c37b6677bd00a1c5f2383 Mon Sep 17 00:00:00 2001 From: lacatoire Date: Wed, 25 Mar 2026 09:29:33 +0100 Subject: [PATCH 1/2] Rewrite Bootstrapping the Core Library documentation --- core/bootstrap.md | 746 +++++++++++++++++++++++++++++++++++++++++++++- laravel/index.md | 15 +- 2 files changed, 742 insertions(+), 19 deletions(-) diff --git a/core/bootstrap.md b/core/bootstrap.md index 5e65f9cbe7a..b360be2fbfa 100644 --- a/core/bootstrap.md +++ b/core/bootstrap.md @@ -1,27 +1,747 @@ # Bootstrapping the Core Library -You may want to run a minimal version of API Platform. This one file runs API Platform (without -GraphQL, Eloquent, Doctrine MongoDB...). It requires the following Composer packages: +You may want to run a minimal version of API Platform without the full Symfony or Laravel framework. +This guide shows how to bootstrap the core library using only Symfony components and the Symfony +HttpKernel. > [!NOTE] > -> This documentation is a work in progress we're working on improving it, in the mean time we -> declare most of the services manually in the -> [ApiPlatformProvider](https://github.com/api-platform/core/blob/64768a6a5b480e1b8e33c639fb28b27883c69b79/src/Laravel/ApiPlatformProvider.php) -> it can be source of inspiration. +> The +> [ApiPlatformProvider](https://github.com/api-platform/core/blob/main/src/Laravel/ApiPlatformProvider.php) +> registers all API Platform services for the Laravel framework. It can be a useful reference for +> understanding how services are wired together. -## Components - -API Platform is installable as a set of components, for example: +## Required Packages ```console composer require \ - api-platform/serializer \ api-platform/metadata \ + api-platform/serializer \ api-platform/state \ api-platform/jsonld \ - phpdocumentor/reflection-docblock \ - symfony/property-info \ + api-platform/hydra \ + api-platform/symfony \ + symfony/http-kernel \ + symfony/event-dispatcher \ symfony/routing \ - symfony/validator + symfony/serializer \ + symfony/property-info \ + symfony/property-access \ + phpdocumentor/reflection-docblock \ + willdurand/negotiation +``` + +## Full Bootstrap Example + +Create the following file structure: + +```text +├── bootstrap.php +├── composer.json +└── src/ + ├── Book.php + ├── BookProcessor.php + └── BookProvider.php +``` + +Create `src/Book.php`: + +```php +id = 1; + $book->title = 'API Platform'; + + return [$book]; + } + + $book = new Book(); + $book->id = $uriVariables['id']; + $book->title = 'API Platform'; + + return $book; + } +} +``` + +Create `src/BookProcessor.php`: + +```php + ['application/ld+json']]; +$patchFormats = ['json' => ['application/merge-patch+json']]; +$errorFormats = [ + 'jsonproblem' => ['application/problem+json'], + 'jsonld' => ['application/ld+json'], +]; + +$logger = new Logger(); + +// ────────────────────────────────────────────── +// 2. Property Info +// ────────────────────────────────────────────── + +$phpDocExtractor = new PhpDocExtractor(); +$reflectionExtractor = new ReflectionExtractor(); +$propertyInfo = new PropertyInfoExtractor( + [$reflectionExtractor], + [$phpDocExtractor, $reflectionExtractor], + [$phpDocExtractor], + [$reflectionExtractor], + [$reflectionExtractor] +); + +// ────────────────────────────────────────────── +// 3. Serializer Class Metadata +// ────────────────────────────────────────────── + +$classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); +$nameConverter = new MetadataAwareNameConverter($classMetadataFactory); + +// ────────────────────────────────────────────── +// 4. Resource and Property Metadata Factories +// ────────────────────────────────────────────── + +// Point this to the directory containing your #[ApiResource] classes +$resourceNameCollectionFactory = new AttributesResourceNameCollectionFactory([__DIR__ . '/src/']); +$resourceClassResolver = new ResourceClassResolver($resourceNameCollectionFactory); + +// Property metadata: Attribute -> PropertyInfo -> Serializer +$propertyNameCollectionFactory = new PropertyInfoPropertyNameCollectionFactory($propertyInfo); +$propertyMetadataFactory = new AttributePropertyMetadataFactory(); +$propertyMetadataFactory = new PropertyInfoPropertyMetadataFactory($propertyInfo, $propertyMetadataFactory); +$propertyMetadataFactory = new SerializerPropertyMetadataFactory( + new ApiClassMetadataFactory($classMetadataFactory), + $propertyMetadataFactory, + $resourceClassResolver, +); + +$pathSegmentNameGenerator = new UnderscorePathSegmentNameGenerator(); +$linkFactory = new LinkFactory( + $propertyNameCollectionFactory, + $propertyMetadataFactory, + $resourceClassResolver, +); + +// Resource metadata: decorator chain (innermost to outermost) +// Each factory adds or transforms metadata before passing to the next. +$resourceMetadataFactory = new AlternateUriResourceMetadataCollectionFactory( + new FiltersResourceMetadataCollectionFactory( + new FormatsResourceMetadataCollectionFactory( + new InputOutputResourceMetadataCollectionFactory( + new PhpDocResourceMetadataCollectionFactory( + new OperationNameResourceMetadataCollectionFactory( + new LinkResourceMetadataCollectionFactory( + $linkFactory, + new UriTemplateResourceMetadataCollectionFactory( + $linkFactory, + $pathSegmentNameGenerator, + new NotExposedOperationResourceMetadataCollectionFactory( + $linkFactory, + new AttributesResourceMetadataCollectionFactory( + null, + $logger, + [], + false, + ), + ), + ), + ), + ), + ), + ), + $formats, + $patchFormats, + ), + ), +); + +// ────────────────────────────────────────────── +// 5. PSR-11 Container for Filters +// ────────────────────────────────────────────── + +$filterLocator = new class implements ContainerInterface { + public function get(string $id): mixed + { + return null; + } + + public function has(string $id): bool + { + return false; + } +}; + +// ────────────────────────────────────────────── +// 6. State Provider and Processor Locators +// ────────────────────────────────────────────── + +$providerLocator = new class implements ContainerInterface { + /** @var array */ + public array $providers = []; + + public function get(string $id): mixed + { + return $this->providers[$id]; + } + + public function has(string $id): bool + { + return isset($this->providers[$id]); + } +}; +$providerLocator->providers[\App\BookProvider::class] = new BookProvider(); + +$processorLocator = new class implements ContainerInterface { + /** @var array */ + public array $processors = []; + + public function get(string $id): mixed + { + return $this->processors[$id]; + } + + public function has(string $id): bool + { + return isset($this->processors[$id]); + } +}; +$processorLocator->processors[\App\BookProcessor::class] = new BookProcessor(); + +$callableProvider = new CallableProvider($providerLocator); +$callableProcessor = new CallableProcessor($processorLocator); + +// ────────────────────────────────────────────── +// 7. Route Building +// ────────────────────────────────────────────── + +$propertyAccessor = PropertyAccess::createPropertyAccessor(); +$identifiersExtractor = new IdentifiersExtractor( + $resourceMetadataFactory, + $resourceClassResolver, + $propertyNameCollectionFactory, + $propertyMetadataFactory, + $propertyAccessor, +); + +// Build Symfony routes from resource metadata +function buildRoutes( + ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, + ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, +): RouteCollection { + $routeCollection = new RouteCollection(); + + foreach ($resourceNameCollectionFactory->create() as $resourceClass) { + foreach ($resourceMetadataFactory->create($resourceClass) as $resourceMetadata) { + foreach ($resourceMetadata->getOperations() as $operationName => $operation) { + if ($operation->getRouteName()) { + continue; + } + + if (SkolemIriConverter::$skolemUriTemplate === $operation->getUriTemplate()) { + continue; + } + + $path = ($operation->getRoutePrefix() ?? '') . $operation->getUriTemplate(); + + foreach ($operation->getUriVariables() ?? [] as $parameterName => $link) { + if (!$expandedValue = $link->getExpandedValue()) { + continue; + } + + $path = str_replace( + sprintf('{%s}', $parameterName), + $expandedValue, + $path, + ); + } + + $route = new Route( + $path, + [ + '_controller' => $operation->getController() ?? PlaceholderAction::class, + '_format' => null, + '_stateless' => $operation->getStateless(), + '_api_resource_class' => $resourceClass, + '_api_operation_name' => $operationName, + ] + ($operation->getDefaults() ?? []), + $operation->getRequirements() ?? [], + $operation->getOptions() ?? [], + $operation->getHost() ?? '', + $operation->getSchemes() ?? [], + [$operation->getMethod() ?? HttpOperation::METHOD_GET], + $operation->getCondition() ?? '', + ); + + $routeCollection->add($operationName, $route); + } + } + } + + return $routeCollection; +} + +$routes = buildRoutes($resourceNameCollectionFactory, $resourceMetadataFactory); + +// Add the .well-known/genid route for blank nodes +$notExposedAction = new NotExposedAction(); +$routes->add('api_genid', new Route( + '/.well-known/genid/{id}', + ['_controller' => $notExposedAction, '_format' => 'text', '_api_respond' => true], +)); + +// ────────────────────────────────────────────── +// 8. Router and URL Generator +// ────────────────────────────────────────────── + +$requestContext = new RequestContext(); +$matcher = new UrlMatcher($routes, $requestContext); +$generator = new UrlGenerator($routes, $requestContext); + +// Adapter implementing Symfony's RouterInterface +class Router implements RouterInterface +{ + public function __construct( + private RouteCollection $routes, + private UrlMatcher $matcher, + private UrlGeneratorInterface $generator, + private RequestContext $context, + ) { + } + + public function getRouteCollection(): RouteCollection + { + return $this->routes; + } + + public function match(string $pathinfo): array + { + return $this->matcher->match($pathinfo); + } + + public function setContext(RequestContext $context): void + { + $this->context = $context; + } + + public function getContext(): RequestContext + { + return $this->context; + } + + public function generate( + string $name, + array $parameters = [], + int $referenceType = self::ABSOLUTE_PATH, + ): string { + return $this->generator->generate($name, $parameters, $referenceType); + } +} + +// Adapter for API Platform's UrlGeneratorInterface +class ApiUrlGenerator implements ApiUrlGeneratorInterface +{ + public function __construct(private UrlGeneratorInterface $generator) + { + } + + public function generate( + string $name, + array $parameters = [], + int $referenceType = self::ABS_PATH, + ): string { + return $this->generator->generate($name, $parameters, $referenceType ?: self::ABS_PATH); + } +} + +$router = new Router($routes, $matcher, $generator, $requestContext); +$apiUrlGenerator = new ApiUrlGenerator($generator); + +// ────────────────────────────────────────────── +// 9. IRI Converter +// ────────────────────────────────────────────── + +$uriVariablesConverter = new UriVariablesConverter( + $propertyMetadataFactory, + $resourceMetadataFactory, + [new IntegerUriVariableTransformer(), new DateTimeUriVariableTransformer()], +); + +$iriConverter = new IriConverter( + $callableProvider, + $router, + $identifiersExtractor, + $resourceClassResolver, + $resourceMetadataFactory, + $uriVariablesConverter, + new SkolemIriConverter($router), +); + +// ────────────────────────────────────────────── +// 10. Serializer +// ────────────────────────────────────────────── + +$serializerContextBuilder = new SerializerContextBuilder($resourceMetadataFactory); +$objectNormalizer = new ObjectNormalizer(); + +// JSON-LD context builder +$jsonLdContextBuilder = new JsonLdContextBuilder( + $resourceNameCollectionFactory, + $resourceMetadataFactory, + $propertyNameCollectionFactory, + $propertyMetadataFactory, + $apiUrlGenerator, + $iriConverter, + $nameConverter, +); + +// Add the JSON-LD context route (required for @context URLs in responses) +$contextAction = new ContextAction( + $jsonLdContextBuilder, + $resourceNameCollectionFactory, + $resourceMetadataFactory, +); +$routes->add('api_jsonld_context', new Route( + '/contexts/{shortName}.{_format}', + ['_controller' => $contextAction, '_format' => 'jsonld', '_api_respond' => true], + ['shortName' => '.+'], +)); + +// JSON-LD normalizers +$jsonLdItemNormalizer = new JsonLdItemNormalizer( + $resourceMetadataFactory, + $propertyNameCollectionFactory, + $propertyMetadataFactory, + $iriConverter, + $resourceClassResolver, + $jsonLdContextBuilder, + $propertyAccessor, + $nameConverter, + $classMetadataFactory, + $defaultContext, + null, // resourceAccessChecker +); + +$jsonLdObjectNormalizer = new JsonLdObjectNormalizer( + $objectNormalizer, + $iriConverter, + $jsonLdContextBuilder, +); + +// Hydra normalizers (for collections and API metadata) +$hydraCollectionNormalizer = new HydraCollectionNormalizer( + $jsonLdContextBuilder, + $resourceClassResolver, + $iriConverter, + $defaultContext, +); + +$hydraPartialCollectionNormalizer = new PartialCollectionViewNormalizer( + $hydraCollectionNormalizer, + 'page', + 'pagination', + $resourceMetadataFactory, + $propertyAccessor, +); + +$hydraCollectionFiltersNormalizer = new CollectionFiltersNormalizer( + $hydraPartialCollectionNormalizer, + $resourceMetadataFactory, + $resourceClassResolver, + $filterLocator, +); + +// Core normalizer +$itemNormalizer = new ItemNormalizer( + $propertyNameCollectionFactory, + $propertyMetadataFactory, + $iriConverter, + $resourceClassResolver, + $propertyAccessor, + $nameConverter, + $classMetadataFactory, + $logger, + $resourceMetadataFactory, + null, // resourceAccessChecker + $defaultContext, +); + +// Symfony normalizers +$unwrappingDenormalizer = new UnwrappingDenormalizer($propertyAccessor); +$arrayDenormalizer = new ArrayDenormalizer(); +$dateTimeNormalizer = new DateTimeNormalizer($defaultContext); + +// Register normalizers with priorities (same as Symfony bundle) +$list = new \SplPriorityQueue(); +$list->insert($unwrappingDenormalizer, 1000); +$list->insert($hydraCollectionFiltersNormalizer, -800); +$list->insert($jsonLdItemNormalizer, -890); +$list->insert($jsonLdObjectNormalizer, -995); +$list->insert($arrayDenormalizer, -990); +$list->insert($dateTimeNormalizer, -910); +$list->insert($itemNormalizer, -895); +$list->insert($objectNormalizer, -1000); + +$encoders = [new JsonEncoder(), new ApiJsonLdEncoder('jsonld', new JsonEncoder())]; +$serializer = new Serializer(iterator_to_array($list), $encoders); + +// ────────────────────────────────────────────── +// 11. State Providers and Processors +// ────────────────────────────────────────────── + +// Content negotiation provider (standalone: only negotiates format, does not read data) +$contentNegotiationProvider = new ContentNegotiationProvider( + null, + new Negotiator(), + $formats, + $errorFormats, +); + +// Read provider: fetches data from the user's provider +$readProvider = new ReadProvider($callableProvider, $serializerContextBuilder, $logger); + +// Processor chain: writes data, serializes, and creates the HTTP response +$respondProcessor = new RespondProcessor( + $iriConverter, + $resourceClassResolver, + null, + $resourceMetadataFactory, +); +$writeProcessor = new WriteProcessor(null, $callableProcessor); +$serializeProcessor = new SerializeProcessor( + null, + $serializer, + $serializerContextBuilder, +); + +// ────────────────────────────────────────────── +// 12. Event Listeners and HttpKernel +// ────────────────────────────────────────────── + +$formatListener = new AddFormatListener($contentNegotiationProvider, $resourceMetadataFactory); +$readListener = new ReadListener( + $readProvider, + $resourceMetadataFactory, + $uriVariablesConverter, +); +$writeListener = new WriteListener( + $writeProcessor, + $resourceMetadataFactory, + $uriVariablesConverter, +); +$serializeListener = new SerializeListener($serializeProcessor, $resourceMetadataFactory); +$respondListener = new RespondListener($respondProcessor, $resourceMetadataFactory); + +$dispatcher = new EventDispatcher(); +$dispatcher->addSubscriber(new RouterListener($matcher, new RequestStack())); +$dispatcher->addListener('kernel.request', [$formatListener, 'onKernelRequest'], 28); +$dispatcher->addListener('kernel.request', [$readListener, 'onKernelRequest'], 4); +$dispatcher->addListener('kernel.view', [$writeListener, 'onKernelView'], 32); +$dispatcher->addListener('kernel.view', [$serializeListener, 'onKernelView'], 16); +$dispatcher->addListener('kernel.view', [$respondListener, 'onKernelView'], 8); + +$kernel = new HttpKernel( + $dispatcher, + new ControllerResolver(), + new RequestStack(), + new ArgumentResolver(), +); + +$request = Request::createFromGlobals(); +$response = $kernel->handle($request); +$response->send(); +$kernel->terminate($request, $response); +``` + +## Running + +Make sure your `composer.json` includes the PSR-4 autoload for the `src/` directory: + +```json +{ + "autoload": { + "psr-4": { + "App\\": "src/" + } + } +} +``` + +Then run `composer dump-autoload` and start the PHP built-in server: + +```console +php -S localhost:8000 bootstrap.php +``` + +Then access your API: + +```console +curl http://localhost:8000/books +curl http://localhost:8000/books/1 +``` + +## Going Further + +This bootstrap provides a minimal JSON-LD/Hydra API. To add more features, you can: + + +- **Deserialization**: add `DeserializeProvider` and `DeserializeListener` for POST/PUT/PATCH +- **Validation**: add `ValidateProvider` and `ValidateListener` with `symfony/validator` +- **HAL/JSON:API**: register the corresponding normalizers with `api-platform/hal` or `api-platform/jsonapi` +- **OpenAPI**: add `OpenApiFactory` and `OpenApiNormalizer` for API documentation + +Refer to the +[ApiPlatformProvider](https://github.com/api-platform/core/blob/main/src/Laravel/ApiPlatformProvider.php) +for a complete example of all available services and their wiring. diff --git a/laravel/index.md b/laravel/index.md index f439fc2447f..1a5bf9e9b5f 100644 --- a/laravel/index.md +++ b/laravel/index.md @@ -6,6 +6,7 @@ API Platform is **the easiest way** to create **state-of-the-art** web APIs usin With API Platform, you can: + - [expose your Eloquent](#exposing-a-model) models in minutes as: - a REST API implementing the industry-leading standards, formats and best practices: [JSON-LD](https://en.wikipedia.org/wiki/JSON-LD)/[RDF](https://en.wikipedia.org/wiki/Resource_Description_Framework), @@ -25,11 +26,11 @@ With API Platform, you can: ([compatible with Sanctum, Passport, Socialite...](#authentication)) - add [filtering logic](#adding-filters) - push changed data to the clients in real-time using Laravel Broadcast and - [Mercure](https://mercure.rocks) (a popular WebSockets alternative, created by Kévin Dunglas, the - original author of API Platform) and receive them using Laravel Echo--> + [Mercure](https://mercure.rocks) (a popular WebSockets alternative, created by Kévin Dunglas, + the original author of API Platform) and receive them using Laravel Echo--> - benefits from the API Platform JavaScript tools: [admin](../admin/index.md) and - [create client](../create-client/index.md) (supports Next/React, Nuxt/Vue.js, Quasar, Vuetify and - more!) + [create client](../create-client/index.md) (supports Next/React, Nuxt/Vue.js, Quasar, Vuetify + and more!) - benefits from native HTTP cache (with automatic invalidation) - boost your app with [Octane](https://laravel.com/docs/octane) and [FrankenPHP](https://frankenphp.dev) (the default Octane engine, also created by Kévin) @@ -44,8 +45,8 @@ Let's discover how to use API Platform with Laravel! API Platform can be installed easily on new and existing Laravel projects. If you already have an existing project, skip directly to the next section. -API Platform 4.2 supports **Laravel 11 and Laravel 12** (`laravel/framework ^11.0 || ^12.0`). -For Laravel 13 support, use API Platform 4.3. +API Platform 4.2 supports **Laravel 11 and Laravel 12** (`laravel/framework ^11.0 || ^12.0`). For +Laravel 13 support, use API Platform 4.3. If you don't have an existing Laravel project, [create one](https://laravel.com/docs/installation). All Laravel installation methods are supported. For instance, you can use Composer: @@ -191,6 +192,7 @@ corresponding API request in the UI. Try it yourself by browsing to So, if you want to access the raw data, you have two alternatives: + - Add the correct `Accept` header (or don't set any `Accept` header at all if you don't care about security) - preferred when writing API clients - Add the format you want as the extension of the resource - for debug purposes only @@ -757,6 +759,7 @@ API Platform hooks into the native It also natively supports: + - [Laravel Sanctum](https://laravel.com/docs/sanctum), an authentication system for SPAs (single page applications), mobile applications, and simple, token-based APIs - [Laravel Passport](https://laravel.com/docs/passport), a full OAuth 2 server From 449c9d559aaa74a20620aaae762856e104bcfced Mon Sep 17 00:00:00 2001 From: lacatoire Date: Wed, 25 Mar 2026 14:07:26 +0100 Subject: [PATCH 2/2] Address review feedback: add optional packages section and clarify Symfony-specific listeners --- core/bootstrap.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/core/bootstrap.md b/core/bootstrap.md index b360be2fbfa..731fd833e61 100644 --- a/core/bootstrap.md +++ b/core/bootstrap.md @@ -31,6 +31,13 @@ composer require \ willdurand/negotiation ``` +You may also want to install additional packages depending on your needs: + +```console +composer require \ + symfony/validator # validation support +``` + ## Full Bootstrap Example Create the following file structure: @@ -669,6 +676,9 @@ $serializeProcessor = new SerializeProcessor( // ────────────────────────────────────────────── // 12. Event Listeners and HttpKernel // ────────────────────────────────────────────── +// Note: this section is specific to the Symfony HttpKernel. +// When using Laravel, request handling is managed differently +// (see ApiPlatformProvider). $formatListener = new AddFormatListener($contentNegotiationProvider, $resourceMetadataFactory); $readListener = new ReadListener(