yakovenko/laravel-lighthouse-graphql-multi-schema
Add multiple GraphQL schemas to Laravel Lighthouse with separate schema files, routes, namespaces, and middleware. Build distinct API surfaces in one Laravel app while sharing the same codebase and core services.
The package extends Lighthouse's schema configuration so a single Laravel application can expose several GraphQL endpoints — typically a public API and an admin/internal API, or one schema per tenant. Install it alongside Lighthouse:
composer require nuwave/lighthouse yakovenko/laravel-lighthouse-graphql-multi-schema
php artisan vendor:publish --tag=lighthouse-config
php artisan vendor:publish --provider="Yakovenko\\LighthouseMultiSchema\\LighthouseMultiSchemaServiceProvider"
Look at three files first, in this order:
config/lighthouse.php → the schemas array. Lighthouse already supports named schemas here; this package layers registration, routing, and per-schema middleware on top. Confirm the exact shape the package expects (uri, schema_path, middleware, guard) against its README before copying snippets.config/lighthouse-multi-schema.php or a schemas key published by the provider) — this is where you declare schema names, URIs, SDL paths, and per-schema middleware/guards.First use case — split public and admin:
// config/lighthouse-multi-schema.php (shape may differ; verify with README)
'schemas' => [
'public' => [
'uri' => '/graphql',
'schema_path' => base_path('graphql/public.graphql'),
'middleware' => ['api', 'throttle:60,1'],
'guard' => 'api',
],
'admin' => [
'uri' => '/admin/graphql',
'schema_path' => base_path('graphql/admin.graphql'),
'middleware' => ['api', 'auth:sanctum', 'can:access-admin-api'],
'guard' => 'sanctum',
],
],
Then define graphql/public.graphql and graphql/admin.graphql, add a trivial type Query { ping: String! @field(resolver: "App\\GraphQL\\Queries\\Ping") }, and verify with an introspection POST to each URI (or php artisan lighthouse:print-schema --schema=admin where supported). Ship the split before you add business types — the auth boundary is the part you want proven early.
Shared base types, divergent roots. Keep reusable scalars, interfaces, and enums in graphql/base/*.graphql and #import them from each schema. Duplicating type User across schemas is the fastest way to drift; a single @interface-free shared file imported by both keeps resolvers identical while each schema controls which fields and mutations are reachable.
# graphql/public.graphql
#import base/scalars.graphql
#import base/user.graphql
#import public/queries.graphql
# graphql/admin.graphql
#import base/scalars.graphql
#import base/user.graphql
#import admin/queries.graphql
#import admin/mutations.graphql
Auth boundaries via schema middleware, not directives alone. @guard(with: "sanctum") protects a field, but schema-level middleware rejects unauthenticated traffic before parsing — cheaper and less error-prone. Pair the route middleware with AttemptAuthentication in the schema's middleware list so auth() is populated for resolvers.
Schema-per-tenant at request time. If the package supports runtime registration, resolve the tenant in middleware, validate the name against an allow-list, and swap the schema for that request:
final class ResolveTenantSchema
{
public function handle($request, Closure $next)
{
$tenant = $request->route('tenant');
abort_unless(Tenant::whereKey($tenant)->exists(), 404);
config(["lighthouse.schemas.{$tenant}" => [
...config('lighthouse.schemas.tenant_template'),
'uri' => "/graphql/{$tenant}",
'schema_path' => base_path("graphql/tenants/{$tenant}.graphql"),
]]);
return $next($request);
}
}
Register a single parameterised route for this instead of one route per tenant — otherwise route caching and tenant provisioning both become painful. Never let the schema name come straight from the URL into a file path without an existence check.
Resolver reuse with policy-level divergence. Put one PostResolver behind both schemas; let @can(ability: "view", find: "id") and per-schema policy registration do the difference. This keeps the admin/public field contracts aligned and makes permission bugs visible in one place.
Programmatic execution for internal calls. For server-side composition (SSR preloading, webhook fan-out), dispatch through Lighthouse's schema builder rather than issuing an HTTP request:
$result = GraphQL::executeQuery(
Lighthouse::schema('admin')->buildSchema(),
$query,
rootValue: null,
contextValue: ['request' => $request],
);
Testing. Each endpoint is a distinct $this->postJson('/admin/graphql', ...); assert on errors[].extensions.category (Lighthouse emits validation, authentication, authorization) rather than message strings, since the schema split means the same resolver surfaces under different auth contexts.
boot(), php artisan route:cache and config:cache will freeze the schema list. Tenant-driven setups must use one catch-all route plus request middleware; static setups should register everything in config so caching stays valid.middleware array. Leaving out AcceptJson/AddHeaders on a new schema produces content-type and CORS surprises that look like GraphQL bugs.@auth defaults to the app's default guard regardless of the schema's guard key unless you use @guard(with: ...) or AttemptAuthentication per schema. An admin schema on the sanctum guard will silently accept a cookie-authenticated web session otherwise.lighthouse.php are available to every schema. Per-schema directive restriction is not automatic — if you need it, restrict by wrapping the directive's handleField in a schema-name check, or accept that exposure and secure via field-level @can.lighthouse.security.max_query_complexity, max_query_depth, and disable_introspection are read once for the app. A stricter budget for the public schema needs schema-specific middleware or a custom QueryComplexity invocation rather than config alone.@cache keys derive from field args, not the schema name. If both schemas resolve the same Post id with different visibility rules, they will share a cache entry. Namespace per schema, either via a custom @cacheKey or by prefixing args.php artisan lighthouse:validate-schema validates one schema at a time in most versions; wire it into CI once per schema (a loop over your config keys) or you will only ever validate the default.Lighthouse\Execution\ErrorHandler is a single binding. If you want public schemas to hide internal messages while admin schemas expose them, override the handler and branch on the resolved schema name from the container — not on the URI string.schemas config is the upstream contract and the likely safe fallback if the package's abstraction drifts.How can I help you explore Laravel packages today?