Logo

sr. Larry Ettinng

arrow_back Back to Blog
How to Create a GraphQL Endpoint for Magento

How to Create a GraphQL Endpoint for Magento

This article documents how we build a custom GraphQL endpoint in Magento based on real storefront requirements. No demo abstractions. No toy examples. The use case comes from a frontend team building a store locator with map based interaction. REST was rejected early. The payload shape kept changing. GraphQL fit better.

We walk through the backend side only. Schema design, persistence, filtering, and resolver behavior. Everything shown here runs on current Magento 2.4.x installations with GraphQL enabled.

Use case and acceptance criteria

The frontend team asked for one thing. A query that returns nearby pickup locations based on partial user input. That input could be a postal code fragment or a store name. Coordinates matter later, but text search comes first.

Acceptance criteria were simple and strict.

System baseline

bin/magento deploy:mode:set developer

High level structure

Magento GraphQL endpoints do not exist in isolation. They sit on top of the same domain layers used by REST and admin UI. That means repositories, models, collections, and schema declarations still matter.

The work splits into two parts.

  1. Create a standard Magento module with persistence.
  2. Expose that data through GraphQL schema and resolvers.

Part one is not GraphQL specific. Many developers underestimate that and jump straight into schema files. That usually backfires.

1. Creating the base module

Create a new module folder.

app/code/Acme/StoreLocatorGraphQl

Registration file:

app/code/Acme/StoreLocatorGraphQl/registration.php

<?php
declare(strict_types=1);

use Magento\Framework\Component\ComponentRegistrar;

ComponentRegistrar::register(
    ComponentRegistrar::MODULE,
    'Acme_StoreLocatorGraphQl',
    __DIR__
);

Module declaration:

etc/module.xml

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
    <module name="Acme_StoreLocatorGraphQl" setup_version="1.0.0">
        <sequence>
            <module name="Magento_GraphQl"/>
        </sequence>
    </module>
</config>

2. Declarative database schema

Magento 2.4 uses declarative schema by default. You describe the desired state. Magento handles diffs. No install scripts. No version branching.

etc/db_schema.xml

<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">

    <table name="store_locator_point" resource="default" engine="innodb" comment="Store Locator Points">
        <column xsi:type="int" name="entity_id" identity="true" unsigned="true" nullable="false"/>
        <column xsi:type="varchar" name="label" length="128" nullable="false"/>
        <column xsi:type="varchar" name="street" length="128" nullable="true"/>
        <column xsi:type="varchar" name="city" length="64" nullable="true"/>
        <column xsi:type="varchar" name="zip_code" length="12" nullable="true"/>
        <column xsi:type="decimal" name="lat" precision="10" scale="6" default="0.0"/>
        <column xsi:type="decimal" name="lng" precision="10" scale="6" default="0.0"/>

        <constraint xsi:type="primary" referenceId="PRIMARY">
            <column name="entity_id"/>
        </constraint>
    </table>

</schema>

Declarative schema deletes this table automatically when the module is removed. That behavior surprises teams the first time. Be careful in shared environments.

3. Domain interface

Magento expects service contracts. Even for GraphQL. This keeps layers decoupled.

Api/Data/LocationInterface.php

<?php
declare(strict_types=1);

namespace Acme\StoreLocatorGraphQl\Api\Data;

interface LocationInterface
{
    public const LABEL = 'label';
    public const STREET = 'street';
    public const CITY = 'city';
    public const ZIP_CODE = 'zip_code';
    public const LAT = 'lat';
    public const LNG = 'lng';

    public function getLabel(): ?string;
    public function setLabel(?string $value): void;

    public function getStreet(): ?string;
    public function setStreet(?string $value): void;

    public function getCity(): ?string;
    public function setCity(?string $value): void;

    public function getZipCode(): ?string;
    public function setZipCode(?string $value): void;

    public function getLat(): ?float;
    public function setLat(?float $value): void;

    public function getLng(): ?float;
    public function setLng(?float $value): void;
}

4. Model and resource model

Standard Magento pattern. No shortcuts here.

Model/Location.php

<?php
declare(strict_types=1);

namespace Acme\StoreLocatorGraphQl\Model;

use Magento\Framework\Model\AbstractModel;
use Acme\StoreLocatorGraphQl\Api\Data\LocationInterface;
use Acme\StoreLocatorGraphQl\Model\ResourceModel\LocationResource;

class Location extends AbstractModel implements LocationInterface
{
    protected function _construct()
    {
        $this->_init(LocationResource::class);
    }

    public function getLabel(): ?string
    {
        return $this->getData(self::LABEL);
    }

    public function setLabel(?string $value): void
    {
        $this->setData(self::LABEL, $value);
    }

    public function getStreet(): ?string
    {
        return $this->getData(self::STREET);
    }

    public function setStreet(?string $value): void
    {
        $this->setData(self::STREET, $value);
    }

    public function getCity(): ?string
    {
        return $this->getData(self::CITY);
    }

    public function setCity(?string $value): void
    {
        $this->setData(self::CITY, $value);
    }

    public function getZipCode(): ?string
    {
        return $this->getData(self::ZIP_CODE);
    }

    public function setZipCode(?string $value): void
    {
        $this->setData(self::ZIP_CODE, $value);
    }

    public function getLat(): ?float
    {
        return (float)$this->getData(self::LAT);
    }

    public function setLat(?float $value): void
    {
        $this->setData(self::LAT, $value);
    }

    public function getLng(): ?float
    {
        return (float)$this->getData(self::LNG);
    }

    public function setLng(?float $value): void
    {
        $this->setData(self::LNG, $value);
    }
}

Resource model:

Model/ResourceModel/LocationResource.php

<?php
declare(strict_types=1);

namespace Acme\StoreLocatorGraphQl\Model\ResourceModel;

use Magento\Framework\Model\ResourceModel\Db\AbstractDb;

class LocationResource extends AbstractDb
{
    protected function _construct()
    {
        $this->_init('store_locator_point', 'entity_id');
    }
}

Collection:

Model/ResourceModel/LocationCollection.php

<?php
declare(strict_types=1);

namespace Acme\StoreLocatorGraphQl\Model\ResourceModel;

use Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection;
use Acme\StoreLocatorGraphQl\Model\Location;
use Acme\StoreLocatorGraphQl\Model\ResourceModel\LocationResource;

class LocationCollection extends AbstractCollection
{
    protected function _construct()
    {
        $this->_init(Location::class, LocationResource::class);
    }
}

5. Repository

GraphQL resolvers should not talk to collections directly. Repositories enforce consistency.

Api/LocationRepositoryInterface.php

<?php
declare(strict_types=1);

namespace Acme\StoreLocatorGraphQl\Api;

use Magento\Framework\Api\SearchCriteriaInterface;
use Magento\Framework\Api\SearchResultsInterface;

interface LocationRepositoryInterface
{
    public function getList(SearchCriteriaInterface $criteria): SearchResultsInterface;
}

Implementation:

Model/LocationRepository.php

<?php
declare(strict_types=1);

namespace Acme\StoreLocatorGraphQl\Model;

use Acme\StoreLocatorGraphQl\Api\LocationRepositoryInterface;
use Acme\StoreLocatorGraphQl\Model\ResourceModel\LocationCollectionFactory;
use Magento\Framework\Api\Search\SearchCriteriaBuilder;
use Magento\Framework\Api\SearchResultsInterfaceFactory;
use Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface;

class LocationRepository implements LocationRepositoryInterface
{
    private LocationCollectionFactory $collectionFactory;
    private CollectionProcessorInterface $collectionProcessor;
    private SearchResultsInterfaceFactory $resultsFactory;

    public function __construct(
        LocationCollectionFactory $collectionFactory,
        CollectionProcessorInterface $collectionProcessor,
        SearchResultsInterfaceFactory $resultsFactory
    ) {
        $this->collectionFactory = $collectionFactory;
        $this->collectionProcessor = $collectionProcessor;
        $this->resultsFactory = $resultsFactory;
    }

    public function getList($criteria)
    {
        $collection = $this->collectionFactory->create();
        $this->collectionProcessor->process($criteria, $collection);

        $result = $this->resultsFactory->create();
        $result->setItems($collection->getItems());
        $result->setTotalCount($collection->getSize());
        $result->setSearchCriteria($criteria);

        return $result;
    }
}

DI configuration:

etc/di.xml

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">

    <preference for="Acme\StoreLocatorGraphQl\Api\LocationRepositoryInterface"
                type="Acme\StoreLocatorGraphQl\Model\LocationRepository"/>

</config>

6. GraphQL schema

Now GraphQL enters the picture.

etc/schema.graphqls

type Query {
  storeLocations(
    filter: StoreLocationFilterInput
    pageSize: Int = 10
    currentPage: Int = 1
  ): StoreLocationResult
    @resolver(class: "Acme\\StoreLocatorGraphQl\\Model\\Resolver\\StoreLocations")
}

input StoreLocationFilterInput {
  label: FilterTypeInput
  zip_code: FilterTypeInput
  city: FilterTypeInput
  or: StoreLocationFilterInput
}

type StoreLocationResult {
  total_count: Int
  items: [StoreLocation]
}

type StoreLocation {
  label: String
  street: String
  city: String
  zip_code: String
  lat: Float
  lng: Float
}

7. Resolver

Resolvers translate GraphQL arguments into search criteria.

Model/Resolver/StoreLocations.php

<?php
declare(strict_types=1);

namespace Acme\StoreLocatorGraphQl\Model\Resolver;

use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Query\Resolver\Argument\SearchCriteria\Builder;
use Magento\Framework\GraphQl\Exception\GraphQlInputException;
use Acme\StoreLocatorGraphQl\Api\LocationRepositoryInterface;

class StoreLocations implements ResolverInterface
{
    private LocationRepositoryInterface $repository;
    private Builder $criteriaBuilder;

    public function __construct(
        LocationRepositoryInterface $repository,
        Builder $criteriaBuilder
    ) {
        $this->repository = $repository;
        $this->criteriaBuilder = $criteriaBuilder;
    }

    public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null)
    {
        if (($args['pageSize'] ?? 1) < 1) {
            throw new GraphQlInputException(__('pageSize must be greater than zero'));
        }

        $criteria = $this->criteriaBuilder->build('store_locator_point', $args);
        $criteria->setCurrentPage($args['currentPage']);
        $criteria->setPageSize($args['pageSize']);

        $result = $this->repository->getList($criteria);

        return [
            'total_count' => $result->getTotalCount(),
            'items' => $result->getItems()
        ];
    }
}

8. Register filterable fields

Magento does not automatically expose custom entity attributes for GraphQL filtering. You must register them.

etc/di.xml add:

<type name="Magento\Framework\GraphQl\Query\Resolver\Argument\FieldEntityAttributesPool">
    <arguments>
        <argument name="attributesInstances" xsi:type="array">
            <item name="store_locator_point" xsi:type="object">
                Acme\StoreLocatorGraphQl\Model\Resolver\LocationFilterAttributes
            </item>
        </argument>
    </arguments>
</type>

Resolver class:

Model/Resolver/LocationFilterAttributes.php

<?php
declare(strict_types=1);

namespace Acme\StoreLocatorGraphQl\Model\Resolver;

use Magento\Framework\GraphQl\Query\Resolver\Argument\FieldEntityAttributesInterface;

class LocationFilterAttributes implements FieldEntityAttributesInterface
{
    public function getEntityAttributes(): array
    {
        return [
            'label' => ['type' => 'String'],
            'zip_code' => ['type' => 'String'],
            'city' => ['type' => 'String'],
            'lat' => ['type' => 'Float'],
            'lng' => ['type' => 'Float']
        ];
    }
}

9. Installation

Enable the module and apply schema changes.

bin/magento module:enable Acme_StoreLocatorGraphQl
bin/magento setup:upgrade

10. Query examples

Using Altair, GraphiQL, or Postman with a valid GraphQL body.

Basic query:

{
  storeLocations {
    total_count
    items {
      label
      city
      zip_code
    }
  }
}

Filtered query:

{
  storeLocations(
    filter: { zip_code: { like: "10%" } }
    pageSize: 5
    currentPage: 1
  ) {
    total_count
    items {
      label
      street
      city
    }
  }
}

Closing thoughts

Adding a GraphQL endpoint to Magento is not hard. The complexity hides in discipline, not syntax. Respect service contracts. Keep resolvers thin. Push logic into repositories. Performance still lags behind expectations in some cases. Magento core keeps improving this area, but careless schema design can slow things down fast.

From our experience, a clean GraphQL endpoint like this can be built in under an hour once the patterns stick. The first time always takes longer.

sr. Larry Ettinng

sr. Larry Ettinng

Senior Magento Developer