Skip to content

GraphQL only API #7915

Closed
Closed

Description

@brunobg

Description
I'd like to enable only the graphql endpoint, and completely disable REST. Leaving it open is a potential security issue, as it means maintaining two different protocols.

Perhaps a flag could be added to https://api-platform.com/docs/core/configuration/, such as enable_rest, to control whether REST routes are enabled.

#2796 raised this problem, but it was closed years ago, so I'm opening a new issue. It describes overriding ApiLoader, which still works in version 4.3.3 (just return new RouteCollection(); in load()) but means patching code in vendor.

I'm happy to send a PR if I get a guideline of the implementation.

Example

api_platform:
    enable_rest: false

Activity

  1. soyuka commented on Apr 27, 2026

    @soyuka
    Member

    Hi @brunobg, you don't actually need a new flag for this — the routing system already lets you opt into just the GraphQL endpoints. Here's how.

    The api_platform route type (the ApiLoader) is what generates the REST routes from your resource metadata. If you don't import that loader in your routing, no REST routes will be registered. The GraphQL routes live in standalone files that you can import directly:

    # config/routes.yaml
    
    # Do NOT include this — it's what creates REST routes:
    # api_platform:
    #     resource: .
    #     type: api_platform
    
    api_platform_graphql:
        resource: '@ApiPlatformBundle/Resources/config/routing/graphql/graphql.php'
    
    # Optional, only if you want the GraphiQL playground:
    api_platform_graphiql:
        resource: '@ApiPlatformBundle/Resources/config/routing/graphql/graphiql.php'

    Reference: src/Symfony/Bundle/Resources/config/routing/graphql/graphql.php and graphiql.php.

    Then declare your resources with graphQlOperations only:

    #[ApiResource(
        graphQlOperations: [
            new Query(),
            new QueryCollection(),
            new Mutation(name: 'create'),
            new Mutation(name: 'update'),
            new DeleteMutation(name: 'delete'),
        ],
    )]
    class Book {}

    If you'd rather keep #[ApiResource] minimal but ensure no REST operations are generated by default for any resource, you can also override the defaults:

    api_platform:
        defaults:
            operations: []  # no default HTTP operations

    This combination (no api_platform route loader + GraphQL-only operations) gives you a strictly GraphQL API with zero REST surface area. No vendor patching, no ApiLoader override needed.

    Closing this — feel free to reopen if you hit a case this doesn't cover.

    /edit: generated by claude, supervised and reviewed by myself. Do you think we should add an entry to the documentation?

  2. reopened this on Apr 27, 2026
  3. brunobg commented on Apr 27, 2026

    @brunobg
    Author

    Thank you very much. I sure think this should go into the documentation, it's very useful.

  4. brunobg commented on Apr 27, 2026

    @brunobg
    Author

    @soyuka turns out this solution doesn't work. if I use

    api_platform:
        # resource: .
        # type: api_platform
        defaults:
            operations: [] # no default HTTP operations
    

    I get

      "type": "https://tools.ietf.org/html/rfc2616#section-10",
      "title": "An error occurred",
      "status": 500,
      "detail": "You must define a \"path\" for the route \"api_platform\" in file \"api/config/routes/api_platform.yaml\" in api/config/routes/api_platform.yaml (which is being imported from \"api/src/Kernel.php\").",
      "class": "Symfony\\Component\\Config\\Exception\\LoaderLoadException",
    

    if I fully remove api_platform, I get a different error when fetching graphql queries:

    {
      "errors": [
        {
          "message": "Internal server error",
          "locations": [
            {
              "line": 2,
              "column": 3
            }
          ],
          "path": [
            "regions"
          ],
          "extensions": {
            "debugMessage": "Unable to generate an IRI for the item of type \"App\\Entity\\Region\"",
            "file": "api/vendor/api-platform/symfony/Routing/IriConverter.php",
            "line": 205,
    
  5. soyuka commented on Apr 28, 2026

    @soyuka
    Member

    Damn this is bad indeed, as we don't declare URIs for the resources, routes don't exist anymore. As we're using IRIs for many things (and also in the graphql subsystem) this isn't ideal... I need to think about this.

  6. soyuka commented on Apr 29, 2026

    @soyuka
    Member

    Resolved by #7934 (4.3).

    It adds a new api_platform_iris route loader type registers only the auto-generated NotExposed operations — no REST controllers, no docs/entrypoint routes — so a graphql-only API works without exposing REST while keeping IRIs (Relay global IDs, subscription keys, nested IRI inputs) functional through api_platform.symfony.iri_converter.

    It'll solve the issue by loading:

      # config/routes/api_platform.yaml
      api_platform_iris:
          resource: .
          type: api_platform_iris
    
      api_platform_graphql:
          resource: '@ApiPlatformBundle/Resources/config/routing/graphql/graphql.php'
  7. brunobg commented on Apr 29, 2026

    @brunobg
    Author

    Thank you @soyuka, amazing work :)

  8. brunobg commented on May 1, 2026

    @brunobg
    Author

    @soyuka sorry to reopen this, but I'm still seeing the same error. I updated all api-platform packages to 4.3.4. Perhaps I'm still missing something?

    Here's my app_platform.yaml:

    api_platform_iris:
        resource: .
        type: api_platform_iris
    
    api_platform_graphql:
        resource: "@ApiPlatformBundle/Resources/config/routing/graphql/graphql.php"
    
    # Optional, only if you want the GraphiQL playground:
    api_platform_graphiql:
        resource: "@ApiPlatformBundle/Resources/config/routing/graphql/graphiql.php"

    request returns:

    {
    	"errors": [
    		{
    			"message": "Internal server error",
    			"locations": [
    				{
    					"line": 2,
    					"column": 3
    				}
    			],
    			"path": [
    				"regions"
    			],
    			"extensions": {
    				"debugMessage": "Unable to generate an IRI for the item of type \"App\\Entity\\Region\"",
    				"file": "project_folder/api/vendor/api-platform/symfony/Routing/IriConverter.php",
    				"line": 205,
    				"trace": [
    					{
    						"file": "project_folder/api/vendor/api-platform/symfony/Routing/IriConverter.php",
    						"line": 172,
    						"call": "ApiPlatform\\Symfony\\Routing\\IriConverter::generateSymfonyRoute()"
    					},
    					{
    						"file": "project_folder/api/vendor/api-platform/serializer/AbstractItemNormalizer.php",
    						"line": 173,
    						"call": "ApiPlatform\\Symfony\\Routing\\IriConverter::getIriFromResource()"
    					},
    					{
    						"file": "project_folder/api/vendor/api-platform/graphql/Serializer/ItemNormalizer.php",
    						"line": 100,
    						"call": "ApiPlatform\\Serializer\\AbstractItemNormalizer::normalize()"
    					},
    					{
    						"file": "project_folder/api/src/Serializer/RegionNormalizer.php",
    						"line": 33,
    						"call": "ApiPlatform\\GraphQl\\Serializer\\ItemNormalizer::normalize()"
    					},
    					{
    						"file": "project_folder/api/vendor/symfony/serializer/Debug/TraceableNormalizer.php",
    						"line": 50,
    						"call": "App\\Serializer\\RegionNormalizer::normalize()"
    					},
    					{
    						"file": "project_folder/api/vendor/symfony/serializer/Serializer.php",
    						"line": 152,
    						"call": "Symfony\\Component\\Serializer\\Debug\\TraceableNormalizer::normalize()"
    					},
    					{
    						"file": "project_folder/api/vendor/symfony/serializer/Debug/TraceableSerializer.php",
    						"line": 74,
    						"call": "Symfony\\Component\\Serializer\\Serializer::normalize()"
    					},
    					{
    						"file": "project_folder/api/vendor/api-platform/graphql/State/Processor/NormalizeProcessor.php",
    						"line": 182,
    						"call": "Symfony\\Component\\Serializer\\Debug\\TraceableSerializer::normalize()"
    					},
    					{
    						"file": "project_folder/api/vendor/api-platform/graphql/State/Processor/NormalizeProcessor.php",
    						"line": 101,
    						"call": "ApiPlatform\\GraphQl\\State\\Processor\\NormalizeProcessor::serializeCursorBasedPaginatedCollection()"
    					},
    					{
    						"file": "project_folder/api/vendor/api-platform/graphql/State/Processor/NormalizeProcessor.php",
    						"line": 49,
    						"call": "ApiPlatform\\GraphQl\\State\\Processor\\NormalizeProcessor::getData()"
    					},
    					{
    						"file": "project_folder/api/src/State/GraphQl/PaginationStateProcessor.php",
    						"line": 26,
    						"call": "ApiPlatform\\GraphQl\\State\\Processor\\NormalizeProcessor::process()"
    					},
    					{
    						"file": "project_folder/api/src/State/GraphQl/AppellationFilterStateProcessor.php",
    						"line": 27,
    						"call": "App\\State\\GraphQl\\PaginationStateProcessor::process()"
    					},
    					{
    						"file": "project_folder/api/src/State/GraphQl/BottleFilterStateProcessor.php",
    						"line": 27,
    						"call": "App\\State\\GraphQl\\AppellationFilterStateProcessor::process()"
    					},
    					{
    						"file": "project_folder/api/src/State/GraphQl/BrandFilterStateProcessor.php",
    						"line": 27,
    						"call": "App\\State\\GraphQl\\BottleFilterStateProcessor::process()"
    					},
    					{
    						"file": "project_folder/api/src/State/GraphQl/GrapeFilterStateProcessor.php",
    						"line": 27,
    						"call": "App\\State\\GraphQl\\BrandFilterStateProcessor::process()"
    					},
    					{
    						"file": "project_folder/api/src/State/GraphQl/RegionFilterStateProcessor.php",
    						"line": 27,
    						"call": "App\\State\\GraphQl\\GrapeFilterStateProcessor::process()"
    					},
    					{
    						"file": "project_folder/api/vendor/api-platform/graphql/State/Processor/SubscriptionProcessor.php",
    						"line": 34,
    						"call": "App\\State\\GraphQl\\RegionFilterStateProcessor::process()"
    					},
    					{
    						"file": "project_folder/api/vendor/api-platform/state/Processor/WriteProcessor.php",
    						"line": 54,
    						"call": "ApiPlatform\\GraphQl\\State\\Processor\\SubscriptionProcessor::process()"
    					},
    					{
    						"file": "project_folder/api/vendor/api-platform/graphql/Resolver/Factory/ResolverFactory.php",
    						"line": 114,
    						"call": "ApiPlatform\\State\\Processor\\WriteProcessor::process()"
    					},
    					{
    						"file": "project_folder/api/vendor/api-platform/graphql/Resolver/Factory/ResolverFactory.php",
    						"line": 86,
    						"call": "ApiPlatform\\GraphQl\\Resolver\\Factory\\ResolverFactory::resolve()"
    					},
    					{
    						"file": "project_folder/api/vendor/webonyx/graphql-php/src/Executor/ReferenceExecutor.php",
    						"line": 749,
    						"call": "ApiPlatform\\GraphQl\\Resolver\\Factory\\ResolverFactory::{closure:ApiPlatform\\GraphQl\\Resolver\\Factory\\ResolverFactory::__invoke():46}()"
    					},
    					{
    						"file": "project_folder/api/vendor/webonyx/graphql-php/src/Executor/ReferenceExecutor.php",
    						"line": 656,
    						"call": "GraphQL\\Executor\\ReferenceExecutor::resolveFieldValueOrError()"
    					},
    					{
    						"file": "project_folder/api/vendor/webonyx/graphql-php/src/Executor/ReferenceExecutor.php",
    						"line": 1377,
    						"call": "GraphQL\\Executor\\ReferenceExecutor::resolveField()"
    					},
    					{
    						"file": "project_folder/api/vendor/webonyx/graphql-php/src/Executor/ReferenceExecutor.php",
    						"line": 310,
    						"call": "GraphQL\\Executor\\ReferenceExecutor::executeFields()"
    					},
    					{
    						"file": "project_folder/api/vendor/webonyx/graphql-php/src/Executor/ReferenceExecutor.php",
    						"line": 249,
    						"call": "GraphQL\\Executor\\ReferenceExecutor::executeOperation()"
    					},
    					{
    						"file": "project_folder/api/vendor/webonyx/graphql-php/src/Executor/Executor.php",
    						"line": 184,
    						"call": "GraphQL\\Executor\\ReferenceExecutor::doExecute()"
    					},
    					{
    						"file": "project_folder/api/vendor/webonyx/graphql-php/src/GraphQL.php",
    						"line": 163,
    						"call": "GraphQL\\Executor\\Executor::promiseToExecute()"
    					},
    					{
    						"file": "project_folder/api/vendor/webonyx/graphql-php/src/GraphQL.php",
    						"line": 97,
    						"call": "GraphQL\\GraphQL::promiseToExecute()"
    					},
    					{
    						"file": "project_folder/api/vendor/api-platform/graphql/Executor.php",
    						"line": 51,
    						"call": "GraphQL\\GraphQL::executeQuery()"
    					},
    					{
    						"file": "project_folder/api/vendor/api-platform/graphql/Action/EntrypointAction.php",
    						"line": 73,
    						"call": "ApiPlatform\\GraphQl\\Executor::executeQuery()"
    					},
    					{
    						"file": "project_folder/api/vendor/symfony/http-kernel/HttpKernel.php",
    						"line": 183,
    						"call": "ApiPlatform\\GraphQl\\Action\\EntrypointAction::__invoke()"
    					},
    					{
    						"file": "project_folder/api/vendor/symfony/http-kernel/HttpKernel.php",
    						"line": 76,
    						"call": "Symfony\\Component\\HttpKernel\\HttpKernel::handleRaw()"
    					},
    					{
    						"file": "project_folder/api/vendor/symfony/http-kernel/Kernel.php",
    						"line": 193,
    						"call": "Symfony\\Component\\HttpKernel\\HttpKernel::handle()"
    					},
    					{
    						"file": "project_folder/api/vendor/symfony/runtime/Runner/Symfony/HttpKernelRunner.php",
    						"line": 35,
    						"call": "Symfony\\Component\\HttpKernel\\Kernel::handle()"
    					},
    					{
    						"file": "project_folder/api/vendor/autoload_runtime.php",
    						"line": 32,
    						"call": "Symfony\\Component\\Runtime\\Runner\\Symfony\\HttpKernelRunner::run()"
    					},
    					{
    						"file": "project_folder/api/public/index.php",
    						"line": 5,
    						"function": "require_once('project_folder/api/vendor/autoload_runtime.php')"
    					}
    				]
    			}
    		}
    	]
    }
  9. reopened this on May 1, 2026
  10. soyuka commented on May 4, 2026

    @soyuka
    Member

    would you be able to try my last patch ?

  11. added theissue type on May 29, 2026
  12. brunobg commented on Oct 6, 2026

    @brunobg
    Author

    @soyuka sorry for the long silence, and thanks for #7946. I tested it on 4.3.21 with api_platform_iris + the GraphQL routes and operations: [] on every resource: IRI generation works now, our whole GraphQL suite passes, and the router holds nothing but /graphql, GraphiQL and the not-exposed item routes.

    One thing left: an error on one of those not-exposed routes is now a 500 instead of a 404. GET /bottles/999999999 goes through the error path, and the JSON-LD error normalizer generates @context from api_jsonld_context, which api_platform_iris doesn't load:

    RouteNotFoundException: Unable to generate a URL for the named route "api_jsonld_context" as such route does not exist.
      api-platform/symfony/Routing/Router.php:95
      api-platform/jsonld/ContextBuilder.php:176
      api-platform/jsonld/Serializer/JsonLdContextTrait.php:54
      api-platform/jsonld/Serializer/ErrorNormalizer.php:34
    

    Taking jsonld out of error_formats only moves it: HydraLinkProcessor then fails on api_doc.

    I ran this on 4.3.21 only, but from reading the source the latest releases (4.4.3, 5.0.2) and main have the same problem: in iris mode ApiLoader still loads only genid.php and the item routes, never docs.php/jsonld.php, while HydraLinkProcessor::process() (generate('api_doc')) and ContextBuilder (generate('api_doc') for @vocab, generate('api_jsonld_context')) still generate those routes unguarded.

    I guess the error path either needs those two routes in iris mode, or should skip the links when they don't exist. Happy to send a PR if you tell me which you prefer.

  13. soyuka commented on Oct 19, 2026

    @soyuka
    Member

    the original issue is solved, about the 500: api_platform_iris only loads genid.php, but with jsonld in formats (the default) the Hydra link processor and the JSON-LD context builder still generate api_doc / api_jsonld_context. Removing it from error_formats isn't enough, formats is what registers them.

    For a GraphQL only API don't enable jsonld at all:

    api_platform:
        formats: { json: ['application/json'] }
        error_formats: { jsonproblem: ['application/problem+json'] }

    The proper fix would be to load docs.php/jsonld.php in iris mode when jsonld is enabled, same as the regular loader, PR welcome against 4.4 with a functional test on the 404 path, code is at

    if ($notExposedOnly) {
    $routeCollection->addCollection($this->fileLoader->load('genid.php'));
    } else {
    $this->loadExternalFiles($routeCollection);
    }

  14. brunobg commented on Oct 19, 2026

    @brunobg
    Author

    Thanks @soyuka. PR against 4.4, with the functional test on the 404 path: #8745. In the meantime we turned jsonld off on our side, as you suggested.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions