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.
- As a frontend developer, I need an endpoint to search pickup locations by postcode or name.
- The endpoint must support pagination.
- Data must come from Magento, not mocked JSON.
- The API must expose name, address, city, postal code, latitude, longitude.
System baseline
- Magento Open Source or Adobe Commerce 2.4.x
- PHP 8.1
- MySQL 8
- Developer mode enabled for local work
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.
- Create a standard Magento module with persistence.
- 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.