Back to blog
pimcoregraphqlheadlessapidatahub

Pimcore Headless with GraphQL: a complete guide

January 30, 202614 min read

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