GraphQL Module

The GraphQL module is a thin gateway that overlays your existing REST + Module API Bus surface. Modules contribute their slice of the schema; the gateway stitches everything into a single endpoint, resolves through the bus, and enforces auth + tenant context at the field level.

It does not replace your REST endpoints. They keep working. GraphQL is an additional transport for clients that want a unified, typed, network-efficient way to talk to the platform.

Why this exists

A typical AutoCom dashboard page makes 5–7 REST round trips. Each endpoint returns a fixed shape. The frontend stitches together. With this module:

  • One endpoint (/api/v1/graphql)
  • One round trip per page
  • Clients ask for exactly the fields they render
  • Subscriptions get real-time updates over Reverb (planned for 1.1)
  • Schema is auto-typed from module.json api.provides, so new module APIs become GraphQL fields with zero glue

Architecture

   Client (web / mobile / partner)
                │
                ▼
       /api/v1/graphql
                │
                ▼
   ┌─────────────────────────┐
   │  GraphQLController      │
   │  GraphQLEngine          │
   │   ├─ depth + complexity │
   │   ├─ persisted queries  │
   │   └─ schema cache       │
   └────────────┬────────────┘
                │
                ▼
   ┌─────────────────────────┐
   │  SchemaRegistry          │
   │   ├─ collect SDL files   │
   │   ├─ collect resolvers   │
   │   └─ BusResolverFactory  │
   │      auto-derives from   │
   │      api.provides        │
   └────────────┬─────────────┘
                │
                ▼
   ┌─────────────────────────┐
   │  ModuleApiBus            │
   │  Bus::call('Orders',     │
   │  'orders.get', ...)      │
   └─────────────────────────┘

Resolvers don't read the database directly. They call the bus. The bus already enforces consumes, applies rate limits, runs in tenant context, and writes to the audit log — GraphQL inherits all of it for free.

Endpoints

Path Purpose
POST /api/v1/graphql Execute a query/mutation. Accepts {query, variables, operationName, extensions}.
GET /api/v1/graphql/playground GraphiQL UI. Gated by graphql.playground.access. Disabled in prod by default.
GET /api/v1/graphql/schema Merged SDL for the current ring. Gated by graphql.schema.view.
GET /api/v1/graphql/persisted List persisted queries for this tenant.
POST /api/v1/graphql/persisted Register a query under its sha256 hash.
DELETE /api/v1/graphql/persisted/{hash} Remove a persisted query.

How a module joins the schema

Three options, in priority order.

Option 1: Ship an SDL file (recommended)

Drop a graphql/schema.graphql in your module's backend/:

# modules/Orders/backend/graphql/schema.graphql
type Order {
  id: UUID!
  orderNumber: String!
  status: String!
  total: Money!
  customer: Customer
  createdAt: DateTime!
}

extend type Query {
  order(id: UUID!): Order
  orders(status: String, limit: Int = 20): [Order!]!
}

Then ship a graphql/resolvers.php returning a resolver map:

<?php
return [
    'Query' => [
        'order' => fn ($_, $args) => app(OrderBusHandler::class)->getOrder($args['id']),
        'orders' => fn ($_, $args) => Order::query()
            ->when($args['status'] ?? null, fn ($q, $s) => $q->where('status', $s))
            ->limit($args['limit'])
            ->get(),
    ],
    'Order' => [
        'customer' => fn (Order $order) => $order->customer,
    ],
];

The schema registry stitches this into the merged schema at boot. No registration code needed in your service provider.

Option 2: Implement GraphQLSchemaProviderContract

For programmatic schema generation:

class OrderSchemaProvider implements GraphQLSchemaProviderContract
{
    public function sdl(): array { return [/* ... */]; }
    public function resolvers(): array { return [/* ... */]; }
    public function moduleAlias(): string { return 'orders'; }
}

Then declare it in module.json:

{
  "graphql": {
    "providers": ["Modules\\Orders\\App\\GraphQL\\OrderSchemaProvider"]
  }
}

Option 3: Auto-derived from api.provides

If your module already declares api.provides (most do), the GraphQL module automatically generates fields for them. No code, no SDL files.

"api": {
  "provides": {
    "orders.get":           { "handler": "...", "method": "...", "description": "Get order by ID" },
    "orders.create":        { "handler": "...", "method": "...", "description": "Create order" },
    "orders.updateStatus":  { "handler": "...", "method": "...", "description": "Change order status" }
  }
}

becomes:

extend type Query {
  """Get order by ID"""
  orders_get(input: JSON): JSON
}

extend type Mutation {
  """Create order"""
  orders_create(input: JSON): JSON
  """Change order status"""
  orders_updateStatus(input: JSON): JSON
}

Verb prefix decides Query vs Mutation: get, list, find, search, count, show, fetch are queries; everything else is a mutation.

The auto-derived fields use JSON for input and output, so they're untyped at the schema level — useful for getting started, but typed schemas (Option 1) give better DX. To opt out per-method:

"orders.get": { "handler": "...", "graphql_expose": false }

Or wholesale per-module via module.json's graphql.auto_resolve: false.

Multi-tenancy + auth

  • The endpoint is mounted under /api/v1 with the same Stancl tenancy + Passport auth:api middleware as REST. Subdomain or X-Tenant header → tenant context.
  • Every resolver receives a GraphQLContext object containing user, tenantId, and the request. Use $context->hasPermission('orders.view') for field-level checks.
  • Future versions ship @can("orders.view") and @auth directives so you can declare auth in SDL instead of resolver code.

Versioning + Rings

This module follows the platform's standard versioning system:

  • compatibility.platform: "^2.0.0" — refuses to load on incompatible cores
  • Released as Modules\GraphQL-1.0.0.zip to the GitLab Generic Package Registry
  • php artisan module:install graphql@1.0.0, module:upgrade, module:rollback all work as documented in Module Versioning.

The schema is per-ring, which is non-obvious but important. Different rings run different module versions, so the schema each ring exposes is different. The SchemaRegistry cache key is graphql:schema:<ring>:<modules-fingerprint> where the fingerprint is a hash of every enabled module's (alias, version). Two rings on the same cluster never share a compiled schema.

This means a typical promotion flow is:

# Ship Orders 1.5.0 with new GraphQL fields
php artisan ring:promote orders --to=canary --with-version=1.5.0
# Canary's schema picks up the new fields automatically; stable's doesn't

Persisted queries

Send a sha256 hash of the operation; server resolves to the stored query. Two modes:

  • Optional (default): clients may use persisted hashes for cache wins, but ad-hoc queries also work.
  • Lockdown (graphql.persisted_queries_only: true in tenant settings): server refuses any operation without a registered hash. Recommended for public-facing endpoints (storefront, partner API) so the schema surface is closed.
# Register a query
curl -X POST /api/v1/graphql/persisted \
  -H "Authorization: Bearer ..." \
  -d '{"query": "query Orders { orders { id total } }", "name": "OrdersList"}'
# → { "hash": "abcd...", "name": "OrdersList" }

# Execute it
curl -X POST /api/v1/graphql \
  -d '{"extensions": {"persistedQuery": {"sha256Hash": "abcd...", "version": 1}}}'

Limits + safety

Limit Default Config key
Query depth 7 graphql.limits.depth
Query complexity score 1000 graphql.limits.complexity
Aliases per request 30 graphql.limits.aliases
Schema cache TTL 86400s graphql.schema_cache.ttl

Introspection is off in production by default. Turn it on per-tenant via the settings_schema toggle, or per-environment via GRAPHQL_INTROSPECTION=true.

Mutations are written to module_bus_audit_log when audit_mutations is on, with the same shape as bus calls. One audit log to rule them all.

What's NOT in v1

  • Subscriptions over Reverb (1.1)
  • DataLoader / N+1 batching (1.2)
  • @can, @auth, @bus, @cache schema directives (1.1)
  • Frontend codegen pipeline for typed hooks (1.2)
  • Schema-diff CI gate for breaking changes (1.2)

These are tracked in the module's CHANGELOG.md.

Operator commands

The module piggy-backs on existing module commands:

php artisan module:install graphql@1.0.0
php artisan module:upgrade graphql --to=1.0.1
php artisan module:rollback graphql

# Force schema rebuild after an issue
php artisan tinker --execute='app(\Modules\GraphQL\App\Services\SchemaRegistry::class)->rebuild();'

Related