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.jsonapi.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/v1with the same Stancl tenancy + Passportauth:apimiddleware as REST. Subdomain orX-Tenantheader → tenant context. - Every resolver receives a
GraphQLContextobject containinguser,tenantId, and the request. Use$context->hasPermission('orders.view')for field-level checks. - Future versions ship
@can("orders.view")and@authdirectives 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.zipto the GitLab Generic Package Registry php artisan module:install graphql@1.0.0,module:upgrade,module:rollbackall 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: truein 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,@cacheschema 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
- Module API Bus — the layer GraphQL resolves through
- Module Versioning — how this module gets released
- Deployment Rings — why the schema is per-ring
- RBAC — permissions used by
@can(1.1)