GraphQL is the API Magento uses for headless storefronts, mobile apps and PWA frontends, and Hyvä Checkout and many extensions use it too. Adding your own query takes two pieces: a schema declaration and a resolver class. We will expose active FAQ entries with pagination and caching.
Step 1: Declare Dependencies
<?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="MageServices_FaqGraphQl">
<sequence>
<module name="Magento_GraphQl"/>
<module name="MageServices_Faq"/>
</sequence>
</module>
</config>
Magento's convention is to keep GraphQL in a separate module (ModuleNameGraphQl) so stores that do not use GraphQL can disable it.
Step 2: Define the Schema
type Query {
mageservicesFaqs(
pageSize: Int = 20 @doc(description: "Number of FAQs per page. Maximum 100.")
currentPage: Int = 1 @doc(description: "Page number, starting at 1.")
): MageServicesFaqs
@resolver(class: "MageServices\\FaqGraphQl\\Model\\Resolver\\Faqs")
@doc(description: "Returns active FAQ entries for the current store.")
@cache(cacheIdentity: "MageServices\\FaqGraphQl\\Model\\Resolver\\Faqs\\Identity")
}
type MageServicesFaqs @doc(description: "A page of FAQ entries.") {
items: [MageServicesFaq] @doc(description: "FAQ entries on this page.")
total_count: Int @doc(description: "Total number of active FAQs.")
}
type MageServicesFaq @doc(description: "A single FAQ entry.") {
id: Int
question: String
answer: String
}
Prefix your types with your vendor name to avoid clashes with core and third-party types.
Step 3: The Resolver
<?php
declare(strict_types=1);
namespace MageServices\FaqGraphQl\Model\Resolver;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Exception\GraphQlInputException;
use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
use MageServices\Faq\Model\ResourceModel\Faq\CollectionFactory;
class Faqs implements ResolverInterface
{
private const MAX_PAGE_SIZE = 100;
public function __construct(
private readonly CollectionFactory $collectionFactory
) {
}
public function resolve(
Field $field,
$context,
ResolveInfo $info,
?array $value = null,
?array $args = null
): array {
$pageSize = (int) ($args['pageSize'] ?? 20);
$currentPage = (int) ($args['currentPage'] ?? 1);
if ($pageSize < 1 || $pageSize > self::MAX_PAGE_SIZE) {
throw new GraphQlInputException(__('pageSize must be between 1 and %1.', self::MAX_PAGE_SIZE));
}
if ($currentPage < 1) {
throw new GraphQlInputException(__('currentPage must be 1 or greater.'));
}
$storeId = (int) $context->getExtensionAttributes()->getStore()->getId();
$collection = $this->collectionFactory->create()
->addFieldToFilter('is_active', 1)
->addFieldToFilter('store_id', ['in' => [0, $storeId]])
->setOrder('sort_order', 'ASC')
->setPageSize($pageSize)
->setCurPage($currentPage);
$items = [];
foreach ($collection as $faq) {
$items[] = [
'id' => (int) $faq->getId(),
'question' => (string) $faq->getData('question'),
'answer' => (string) $faq->getData('answer'),
];
}
return [
'items' => $items,
'total_count' => $collection->getSize(),
];
}
}
The store comes from the GraphQL context, which Magento builds from the Store request header. That way the same query returns store-specific content for each store view.
Step 4: Cache Identities
Magento caches GraphQL GET requests in the full-page cache (Varnish or Fastly). The identity class returns cache tags for the data in the response, so the cache is purged when an FAQ changes.
<?php
declare(strict_types=1);
namespace MageServices\FaqGraphQl\Model\Resolver\Faqs;
use Magento\Framework\GraphQl\Query\Resolver\IdentityInterface;
class Identity implements IdentityInterface
{
private const CACHE_TAG = 'mageservices_faq';
public function getIdentities(array $resolvedData): array
{
$ids = [self::CACHE_TAG];
foreach ($resolvedData['items'] ?? [] as $item) {
$ids[] = self::CACHE_TAG . '_' . $item['id'];
}
return $ids;
}
}
For purging to work, the FAQ model must return matching tags. Implement Magento\Framework\DataObject\IdentityInterface on the model and return ['mageservices_faq', 'mageservices_faq_' . $this->getId()] from getIdentities(); Magento cleans those tags when the model is saved.
Step 5: Query It
bin/magento setup:upgrade && bin/magento cache:flush
curl -s -g 'https://your-store.test/graphql?query={mageservicesFaqs(pageSize:5){total_count items{id question answer}}}' \
-H 'Store: default'
{
"data": {
"mageservicesFaqs": {
"total_count": 2,
"items": [
{ "id": 1, "question": "How long does delivery take?", "answer": "Most orders arrive within 2-3 working days." },
{ "id": 2, "question": "Can I return an item?", "answer": "Yes, within 30 days of delivery." }
]
}
}
}
Mutations
Mutations follow the same pattern under type Mutation. They are never cached, and must check authorisation themselves — for example, verify $context->getUserId() and the user type for customer-only operations, and throw GraphQlAuthorizationException otherwise.
Performance Checklist
- Limit page sizes and validate all arguments.
- Avoid loading data per item in loops (the N+1 problem). Load in batches or use
BatchResolverInterfacefor nested fields. - Return only the fields the schema defines; avoid loading full models when a few columns are enough.
- Add cache identities to every cacheable query and use GET requests from the frontend.
- Test with the
X-Magento-Cache-Debugheader in developer mode to confirm cache hits.
Testing Your Query
Use a GraphQL client such as Altair or the GraphiQL tool in your browser to explore the schema; your new query appears in the documentation explorer with the @doc descriptions. For automated testing, Magento's API-functional test framework can send GraphQL queries and assert on responses, which is worth setting up for queries your frontend depends on.
Frequently Asked Questions
Why is my new GraphQL query not found?
Flush the cache after changing schema.graphqls; the merged schema is cached. Also check the module is enabled and depends on Magento_GraphQl.
Are GraphQL responses cached by Varnish?
Queries sent with HTTP GET can be cached when resolvers declare cache identities. POST requests and mutations are not cached.
Should I use REST or GraphQL for a headless storefront?
GraphQL. It is designed for storefront use and lets the frontend request exactly the fields it needs.
Need help with this on your store?
Our Magento engineers can implement it for you, review your code or take on the whole project.
Written by the Magento Services engineering team — Magento 2, Adobe Commerce and Hyvä specialists since 2014. We write about problems we solve on real client stores.