Pimcore Headless with GraphQL: a complete guide
Headless architecture has become the standard for modern e-commerce and content platforms. Pimcore's DataHub provides a powerful GraphQL API that makes your PIM and DAM data available to any frontend. In this article, we cover production-grade implementations: from DataHub configuration to type-safe frontend integration with caching strategies.
DataHub configuration and schema design
DataHub is Pimcore's GraphQL engine. Good schema design determines the performance and usability of your API:
pimcore_data_hub:
configurations:
product-api:
general:
active: true
name: 'Product API'
description: 'GraphQL API for product data'
sqlObjectCondition: "o_published = 1"
security:
method: 'datahub_apikey'
apikey:
- name: 'frontend-key'
permissions:
- 'read'
- name: 'admin-key'
permissions:
- 'read'
- 'update'
schema:
queryEntities:
Product:
id: true
name: true
columnConfig:
columns:
- name: 'sku'
label: 'SKU'
- name: 'price'
label: 'Price'
- name: 'categories'
label: 'Categories'
fieldHelper: 'relation'
- name: 'images'
label: 'Images'
fieldHelper: 'asset'namespace Ten50\Bundle\DataHub\GraphQL\Resolver;
use Pimcore\Bundle\DataHubBundle\GraphQL\Resolver\Base;
use Pimcore\Model\DataObject\Product;
class ProductResolver extends Base
{
public function resolveAvailability(Product $product, array $args): array
{
$stock = $product->getStock();
$threshold = $args['lowStockThreshold'] ?? 10;
return [
'inStock' => $stock > 0,
'quantity' => $stock,
'lowStock' => $stock > 0 && $stock <= $threshold,
'availableFrom' => $product->getAvailableFrom()?->format('c'),
];
}
public function resolvePricing(Product $product, array $args): array
{
$currency = $args['currency'] ?? 'EUR';
$customerGroup = $args['customerGroup'] ?? 'default';
return [
'basePrice' => $product->getPrice(),
'finalPrice' => $this->priceCalculator->calculate($product, $customerGroup),
'currency' => $currency,
'vatRate' => $product->getVatRate() ?? 21,
'priceWithVat' => $this->priceCalculator->calculateWithVat($product, $customerGroup),
];
}
}Custom resolvers give you full control over the response structure and business logic.
Complex queries and filtering
GraphQL's power lies in fetching exactly the data you need. Here are patterns for production scenarios:
query GetProductCatalog(
$locale: String!
$categoryId: Int
$minPrice: Float
$maxPrice: Float
$inStock: Boolean
$first: Int = 20
$after: String
) {
getProductListing(
filter: {
categories: { $contains: $categoryId }
price: { $gte: $minPrice, $lte: $maxPrice }
stock: { $gt: 0 } @include(if: $inStock)
}
sortBy: "price"
sortOrder: "ASC"
first: $first
after: $after
) {
pageInfo {
hasNextPage
endCursor
totalCount
}
edges {
cursor
node {
id
sku
name(language: $locale)
description(language: $locale)
price
availability {
inStock
quantity
lowStock
}
images {
id
filename
thumbnail(config: "product_listing")
fullsize: thumbnail(config: "product_detail")
}
categories {
id
name(language: $locale)
path
}
relatedProducts(first: 4) {
edges {
node {
id
sku
name(language: $locale)
thumbnail: images(first: 1) {
thumbnail(config: "product_thumb")
}
}
}
}
}
}
}
}Use fragments for reusable field sets and cursor-based pagination for large datasets.
Authentication and rate limiting
Security is crucial for public APIs. Implement multiple layers of protection:
namespace Ten50\Bundle\DataHub\Security;
use Pimcore\Bundle\DataHubBundle\GraphQL\Service\RequestServiceInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Cache\Adapter\RedisAdapter;
class ApiKeyAuthenticator
{
private const RATE_LIMIT_WINDOW = 60;
private const RATE_LIMIT_MAX_REQUESTS = 100;
public function __construct(
private RedisAdapter $cache,
private RequestServiceInterface $requestService
) {}
public function authenticate(Request $request): AuthResult
{
$apiKey = $request->headers->get('X-API-Key')
?? $request->query->get('apikey');
if (!$apiKey) {
return AuthResult::failed('API key required');
}
$keyConfig = $this->validateApiKey($apiKey);
if (!$keyConfig) {
return AuthResult::failed('Invalid API key');
}
if (!$this->checkRateLimit($apiKey)) {
return AuthResult::rateLimited(
'Rate limit exceeded',
$this->getRetryAfter($apiKey)
);
}
if (!$this->checkQueryComplexity($request, $keyConfig)) {
return AuthResult::failed('Query complexity limit exceeded');
}
return AuthResult::success($keyConfig['permissions']);
}
private function checkRateLimit(string $apiKey): bool
{
$cacheKey = 'rate_limit_' . hash('xxh3', $apiKey);
$item = $this->cache->getItem($cacheKey);
$requests = $item->isHit() ? $item->get() : 0;
if ($requests >= self::RATE_LIMIT_MAX_REQUESTS) {
return false;
}
$item->set($requests + 1);
$item->expiresAfter(self::RATE_LIMIT_WINDOW);
$this->cache->save($item);
return true;
}
private function checkQueryComplexity(Request $request, array $keyConfig): bool
{
$query = $request->getContent();
$maxDepth = $keyConfig['maxQueryDepth'] ?? 10;
$maxComplexity = $keyConfig['maxQueryComplexity'] ?? 500;
$depth = $this->calculateQueryDepth($query);
$complexity = $this->calculateQueryComplexity($query);
return $depth <= $maxDepth && $complexity <= $maxComplexity;
}
}Rate limiting per API key prevents abuse. Query complexity limits protect against denial-of-service via deep nested queries.
Frontend integration with Apollo Client
Type-safe GraphQL integration with automatic code generation ensures maintainable frontends:
import type { CodegenConfig } from '@graphql-codegen/cli';
const config: CodegenConfig = {
schema: 'https://pimcore.example.com/pimcore-datahub-webservices/product-api',
documents: ['src/**/*.graphql'],
generates: {
'./src/generated/graphql.ts': {
plugins: [
'typescript',
'typescript-operations',
'typescript-react-apollo'
],
config: {
withHooks: true,
withHOC: false,
withComponent: false
}
}
}
};
export default config;import { useGetProductCatalogQuery } from '@/generated/graphql';
import { useLocale } from 'next-intl';
export function useProductCatalog(options: {
categoryId?: number;
minPrice?: number;
maxPrice?: number;
inStock?: boolean;
pageSize?: number;
}) {
const locale = useLocale();
const { data, loading, error, fetchMore } = useGetProductCatalogQuery({
variables: {
locale,
categoryId: options.categoryId,
minPrice: options.minPrice,
maxPrice: options.maxPrice,
inStock: options.inStock ?? true,
first: options.pageSize ?? 20,
},
fetchPolicy: 'cache-and-network',
nextFetchPolicy: 'cache-first',
});
const loadMore = () => {
if (!data?.getProductListing?.pageInfo.hasNextPage) return;
fetchMore({
variables: {
after: data.getProductListing.pageInfo.endCursor,
},
});
};
return {
products: data?.getProductListing?.edges.map(e => e.node) ?? [],
pageInfo: data?.getProductListing?.pageInfo,
loading,
error,
loadMore,
};
}With GraphQL Codegen, all types are automatically synchronized with your Pimcore schema. Apollo's normalized cache ensures efficient data management without duplication.
Headless Pimcore implementation?
We build scalable headless architectures with Pimcore as backend for your modern frontends.
Get in touch