GraphQL represents a paradigm shift in API development, offering clients the power to request exactly the data they need while providing developers with a type-safe, introspectable schema. Laravel Lighthouse brings GraphQL's elegance to Laravel applications, enabling developers to build flexible, efficient APIs that scale from simple CRUD operations to complex, interconnected data relationships.
If you're interested in how Laravel handles real-time features, check out Laravel Broadcasting: Real-Time Features with Websockets.
Table Of Contents
- Understanding GraphQL's Advantages
- Setting Up Laravel Lighthouse
- Advanced Query Resolution
- Custom Directives and Middleware
- Real-time Subscriptions
- Performance Optimization
- Testing GraphQL APIs
- Conclusion
- The combination of GraphQL's flexibility and Laravel's developer experience creates an API development platform that's both powerful and enjoyable to use. Whether building mobile backends, real-time dashboards, or complex data integrations, Laravel Lighthouse provides the tools to create APIs that adapt to changing requirements while maintaining performance and type safety.
Understanding GraphQL's Advantages
Traditional REST APIs require multiple endpoints and often lead to over-fetching or under-fetching of data. GraphQL solves these problems with a single endpoint that allows clients to specify exactly what data they need. This approach reduces network overhead, improves performance, and provides better developer experience through strong typing and built-in documentation.
Laravel's event system is another powerful tool for building decoupled applications. Learn more in Laravel Events and Listeners: Building Decoupled Applications.
Laravel Lighthouse transforms Laravel's Eloquent models and relationships into a powerful GraphQL schema, maintaining Laravel's elegant conventions while embracing GraphQL's flexibility. The result is an API that's both developer-friendly and performant at scale.
For advanced data manipulation, see Laravel Collections: Beyond Basic Array Operations.
Setting Up Laravel Lighthouse
Installation and initial configuration establish the GraphQL foundation:
If you're building SaaS or multi-tenant applications, don't miss Building Multi-Tenant Applications with Laravel: A Comprehensive Guide.
# Install Laravel Lighthouse
composer require nuwave/lighthouse pusher/pusher-php-server
# Publish configuration and schema
php artisan vendor:publish --tag=lighthouse-config
php artisan vendor:publish --tag=lighthouse-schema
# Install GraphQL Playground (development)
composer require --dev mll-lab/laravel-graphql-playground
Basic schema setup in graphql/schema.graphql
:
# GraphQL Schema Definition
"A date string with format `Y-m-d`, e.g. `2011-05-23`."
scalar Date @scalar(class: "Nuwave\\Lighthouse\\Schema\\Types\\Scalars\\Date")
"A datetime string with format `Y-m-d H:i:s`, e.g. `2018-05-23 13:43:32`."
scalar DateTime @scalar(class: "Nuwave\\Lighthouse\\Schema\\Types\\Scalars\\DateTime")
type Query {
# User queries
users(
"Filter by name"
name: String @where(operator: "like")
"Order by field"
orderBy: _ @orderBy(columns: ["id", "name", "email", "created_at"])
): [User!]! @paginate(defaultCount: 10)
user(id: ID! @eq): User @find
me: User @auth
# Post queries
posts(
"Filter by status"
status: PostStatus @where
"Filter by author"
author_id: ID @where
"Order posts"
orderBy: _ @orderBy(columns: ["id", "title", "created_at"])
): [Post!]! @paginate(defaultCount: 15)
post(id: ID! @eq): Post @find
# Search functionality
search(
"Search term"
query: String!
"Content types to search"
types: [SearchableType!]
): SearchResult @field(resolver: "SearchResolver@search")
}
type Mutation {
# Authentication
login(email: String!, password: String!): AuthPayload
register(input: RegisterInput! @spread): AuthPayload
refreshToken(token: String!): AuthPayload
logout: LogoutResponse @guard
# User management
updateProfile(input: UpdateProfileInput! @spread): User @guard @update
changePassword(input: ChangePasswordInput! @spread): PasswordChangeResult @guard
# Post management
createPost(input: CreatePostInput! @spread): Post @guard @create
updatePost(id: ID!, input: UpdatePostInput! @spread): Post @guard @update
deletePost(id: ID!): DeleteResult @guard @delete
# Comment system
addComment(input: AddCommentInput! @spread): Comment @guard @create
updateComment(id: ID!, input: UpdateCommentInput! @spread): Comment @guard @update @can(ability: "update", find: "id")
deleteComment(id: ID!): DeleteResult @guard @delete @can(ability: "delete", find: "id")
}
type Subscription {
# Real-time updates
postUpdated(id: ID!): Post
@subscription(class: "App\\GraphQL\\Subscriptions\\PostUpdated")
commentAdded(postId: ID!): Comment
@subscription(class: "App\\GraphQL\\Subscriptions\\CommentAdded")
userOnline: User
@subscription(class: "App\\GraphQL\\Subscriptions\\UserOnline")
}
# Type definitions
type User {
id: ID!
name: String!
email: String!
email_verified_at: DateTime
avatar: String
bio: String
posts: [Post!]! @hasMany
comments: [Comment!]! @hasMany
followers: [User!]! @belongsToMany(relation: "followers")
following: [User!]! @belongsToMany(relation: "following")
follower_count: Int! @count(relation: "followers")
following_count: Int! @count(relation: "following")
created_at: DateTime!
updated_at: DateTime!
}
type Post {
id: ID!
title: String!
slug: String!
content: String!
excerpt: String
status: PostStatus!
featured_image: String
author: User! @belongsTo
comments: [Comment!]! @hasMany
tags: [Tag!]! @belongsToMany
comment_count: Int! @count(relation: "comments")
reading_time: Int! @field(resolver: "PostResolver@readingTime")
is_liked: Boolean! @field(resolver: "PostResolver@isLiked") @guard
like_count: Int! @count(relation: "likes")
created_at: DateTime!
updated_at: DateTime!
}
type Comment {
id: ID!
content: String!
author: User! @belongsTo
post: Post! @belongsTo
parent: Comment @belongsTo
replies: [Comment!]! @hasMany(relation: "replies")
reply_count: Int! @count(relation: "replies")
created_at: DateTime!
updated_at: DateTime!
}
type Tag {
id: ID!
name: String!
slug: String!
posts: [Post!]! @belongsToMany
post_count: Int! @count(relation: "posts")
}
# Enums
enum PostStatus {
DRAFT @enum(value: "draft")
PUBLISHED @enum(value: "published")
ARCHIVED @enum(value: "archived")
}
enum SearchableType {
POST @enum(value: "post")
USER @enum(value: "user")
TAG @enum(value: "tag")
}
# Input types
input RegisterInput {
name: String! @rules(apply: ["required", "string", "max:255"])
email: String! @rules(apply: ["required", "email", "unique:users,email"])
password: String! @rules(apply: ["required", "string", "min:8", "confirmed"])
password_confirmation: String!
}
input UpdateProfileInput {
name: String @rules(apply: ["string", "max:255"])
bio: String @rules(apply: ["string", "max:500"])
avatar: Upload
}
input CreatePostInput {
title: String! @rules(apply: ["required", "string", "max:255"])
content: String! @rules(apply: ["required", "string"])
excerpt: String @rules(apply: ["string", "max:300"])
status: PostStatus! = DRAFT
featured_image: Upload
tag_ids: [ID!] @rules(apply: ["array", "exists:tags,id"])
}
input UpdatePostInput {
title: String @rules(apply: ["string", "max:255"])
content: String @rules(apply: ["string"])
excerpt: String @rules(apply: ["string", "max:300"])
status: PostStatus
featured_image: Upload
tag_ids: [ID!] @rules(apply: ["array", "exists:tags,id"])
}
input AddCommentInput {
content: String! @rules(apply: ["required", "string", "max:1000"])
post_id: ID! @rules(apply: ["required", "exists:posts,id"])
parent_id: ID @rules(apply: ["exists:comments,id"])
}
# Response types
type AuthPayload {
access_token: String
refresh_token: String
expires_in: Int
token_type: String
user: User
}
type DeleteResult {
success: Boolean!
message: String
}
union SearchResult = Post | User | Tag
Advanced Query Resolution
Implement sophisticated resolvers for complex business logic:
<?php
namespace App\GraphQL\Resolvers;
use App\Models\Post;
use App\Models\User;
use App\Models\Tag;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache;
use GraphQL\Type\Definition\ResolveInfo;
use Nuwave\Lighthouse\Support\Contracts\GraphQLContext;
class SearchResolver
{
public function search($rootValue, array $args, GraphQLContext $context, ResolveInfo $resolveInfo): array
{
$query = $args['query'];
$types = $args['types'] ?? ['POST', 'USER', 'TAG'];
$results = [];
if (in_array('POST', $types)) {
$results = array_merge($results, $this->searchPosts($query));
}
if (in_array('USER', $types)) {
$results = array_merge($results, $this->searchUsers($query));
}
if (in_array('TAG', $types)) {
$results = array_merge($results, $this->searchTags($query));
}
// Sort by relevance score
usort($results, fn($a, $b) => $b['score'] <=> $a['score']);
return array_map(fn($item) => $item['model'], $results);
}
private function searchPosts(string $query): array
{
$posts = Post::where('status', 'published')
->where(function ($q) use ($query) {
$q->where('title', 'like', "%{$query}%")
->orWhere('content', 'like', "%{$query}%")
->orWhere('excerpt', 'like', "%{$query}%");
})
->with(['author', 'tags'])
->get();
return $posts->map(function ($post) use ($query) {
$score = $this->calculatePostRelevance($post, $query);
return ['model' => $post, 'score' => $score];
})->toArray();
}
private function searchUsers(string $query): array
{
$users = User::where('name', 'like', "%{$query}%")
->orWhere('bio', 'like', "%{$query}%")
->get();
return $users->map(function ($user) use ($query) {
$score = $this->calculateUserRelevance($user, $query);
return ['model' => $user, 'score' => $score];
})->toArray();
}
private function searchTags(string $query): array
{
$tags = Tag::where('name', 'like', "%{$query}%")
->withCount('posts')
->get();
return $tags->map(function ($tag) use ($query) {
$score = $this->calculateTagRelevance($tag, $query);
return ['model' => $tag, 'score' => $score];
})->toArray();
}
private function calculatePostRelevance(Post $post, string $query): float
{
$score = 0;
$query = strtolower($query);
// Title match (highest weight)
if (stripos($post->title, $query) !== false) {
$score += 10;
}
// Exact title match
if (strtolower($post->title) === $query) {
$score += 20;
}
// Content match
if (stripos($post->content, $query) !== false) {
$score += 5;
}
// Tag match
foreach ($post->tags as $tag) {
if (stripos($tag->name, $query) !== false) {
$score += 7;
}
}
// Boost recent posts
$daysSinceCreated = $post->created_at->diffInDays(now());
$recencyBoost = max(0, 30 - $daysSinceCreated) / 30;
$score += $recencyBoost * 2;
return $score;
}
private function calculateUserRelevance(User $user, string $query): float
{
$score = 0;
if (stripos($user->name, $query) !== false) {
$score += 10;
}
if (stripos($user->bio, $query) !== false) {
$score += 5;
}
// Boost users with more followers
$score += min($user->follower_count / 100, 5);
return $score;
}
private function calculateTagRelevance(Tag $tag, string $query): float
{
$score = 0;
if (stripos($tag->name, $query) !== false) {
$score += 10;
}
// Exact match
if (strtolower($tag->name) === strtolower($query)) {
$score += 15;
}
// Boost popular tags
$score += min($tag->posts_count / 10, 5);
return $score;
}
}
class PostResolver
{
public function readingTime($rootValue, array $args, GraphQLContext $context, ResolveInfo $resolveInfo): int
{
$post = $rootValue;
$wordCount = str_word_count(strip_tags($post->content));
// Average reading speed: 200 words per minute
return max(1, ceil($wordCount / 200));
}
public function isLiked($rootValue, array $args, GraphQLContext $context, ResolveInfo $resolveInfo): bool
{
$post = $rootValue;
$user = $context->user();
if (!$user) {
return false;
}
return Cache::remember(
"post_{$post->id}_liked_by_{$user->id}",
300,
fn() => $post->likes()->where('user_id', $user->id)->exists()
);
}
}
class UserResolver
{
public function posts($rootValue, array $args, GraphQLContext $context, ResolveInfo $resolveInfo)
{
$user = $rootValue;
$currentUser = $context->user();
$query = $user->posts()->with(['author', 'tags']);
// Show drafts only to the author
if (!$currentUser || $currentUser->id !== $user->id) {
$query->where('status', 'published');
}
return $query;
}
}
Custom Directives and Middleware
Create reusable GraphQL directives for common patterns:
<?php
namespace App\GraphQL\Directives;
use Closure;
use GraphQL\Type\Definition\ResolveInfo;
use Nuwave\Lighthouse\Schema\Values\FieldValue;
use Nuwave\Lighthouse\Schema\Directives\BaseDirective;
use Nuwave\Lighthouse\Support\Contracts\FieldResolver;
use Nuwave\Lighthouse\Support\Contracts\GraphQLContext;
class CacheDirective extends BaseDirective implements FieldResolver
{
public static function definition(): string
{
return /** @lang GraphQL */ '
"""
Cache the result of a field for a specified time.
"""
directive @cache(
"""
Cache key prefix.
"""
key: String
"""
Cache duration in seconds.
"""
ttl: Int = 300
"""
Whether to include user context in cache key.
"""
private: Boolean = false
) on FIELD_DEFINITION
';
}
public function resolveField(FieldValue $fieldValue, Closure $next): FieldValue
{
$fieldValue->setResolver(function ($root, array $args, GraphQLContext $context, ResolveInfo $resolveInfo) use ($next) {
$cacheKey = $this->generateCacheKey($root, $args, $context, $resolveInfo);
$ttl = $this->directiveArgValue('ttl', 300);
return cache()->remember($cacheKey, $ttl, function () use ($root, $args, $context, $resolveInfo, $next) {
$fieldValue = $next(new FieldValue($resolveInfo->fieldDefinition));
return $fieldValue->getResolver()($root, $args, $context, $resolveInfo);
});
});
return $fieldValue;
}
private function generateCacheKey($root, array $args, GraphQLContext $context, ResolveInfo $resolveInfo): string
{
$keyPrefix = $this->directiveArgValue('key', $resolveInfo->fieldName);
$private = $this->directiveArgValue('private', false);
$keyParts = [$keyPrefix];
// Include root model ID if available
if (is_object($root) && method_exists($root, 'getKey')) {
$keyParts[] = get_class($root) . ':' . $root->getKey();
}
// Include arguments
if (!empty($args)) {
$keyParts[] = md5(serialize($args));
}
// Include user context for private caching
if ($private && $context->user()) {
$keyParts[] = 'user:' . $context->user()->id;
}
return 'graphql:' . implode(':', $keyParts);
}
}
class RateLimitDirective extends BaseDirective implements FieldResolver
{
public static function definition(): string
{
return /** @lang GraphQL */ '
"""
Rate limit field access.
"""
directive @rateLimit(
"""
Maximum requests per window.
"""
max: Int = 10
"""
Time window in minutes.
"""
window: Int = 1
"""
Custom rate limiter key.
"""
key: String
) on FIELD_DEFINITION
';
}
public function resolveField(FieldValue $fieldValue, Closure $next): FieldValue
{
$fieldValue->setResolver(function ($root, array $args, GraphQLContext $context, ResolveInfo $resolveInfo) use ($next) {
$rateLimitKey = $this->generateRateLimitKey($context, $resolveInfo);
$max = $this->directiveArgValue('max', 10);
$window = $this->directiveArgValue('window', 1);
$limiter = app('cache.store');
$key = "rate_limit:{$rateLimitKey}";
$attempts = $limiter->increment($key);
if ($attempts === 1) {
$limiter->expire($key, $window * 60);
}
if ($attempts > $max) {
throw new \Exception("Rate limit exceeded. Max {$max} requests per {$window} minute(s).");
}
$fieldValue = $next(new FieldValue($resolveInfo->fieldDefinition));
return $fieldValue->getResolver()($root, $args, $context, $resolveInfo);
});
return $fieldValue;
}
private function generateRateLimitKey(GraphQLContext $context, ResolveInfo $resolveInfo): string
{
$customKey = $this->directiveArgValue('key');
if ($customKey) {
return $customKey;
}
$keyParts = [$resolveInfo->fieldName];
if ($context->user()) {
$keyParts[] = 'user:' . $context->user()->id;
} else {
$keyParts[] = 'ip:' . request()->ip();
}
return implode(':', $keyParts);
}
}
class TransformDirective extends BaseDirective implements FieldResolver
{
public static function definition(): string
{
return /** @lang GraphQL */ '
"""
Transform field value using a specified transformer.
"""
directive @transform(
"""
Transformer class name.
"""
class: String!
"""
Transformer method name.
"""
method: String = "transform"
) on FIELD_DEFINITION
';
}
public function resolveField(FieldValue $fieldValue, Closure $next): FieldValue
{
$fieldValue->setResolver(function ($root, array $args, GraphQLContext $context, ResolveInfo $resolveInfo) use ($next) {
$fieldValue = $next(new FieldValue($resolveInfo->fieldDefinition));
$originalValue = $fieldValue->getResolver()($root, $args, $context, $resolveInfo);
$transformerClass = $this->directiveArgValue('class');
$method = $this->directiveArgValue('method', 'transform');
if (!class_exists($transformerClass)) {
throw new \Exception("Transformer class {$transformerClass} not found.");
}
$transformer = app($transformerClass);
if (!method_exists($transformer, $method)) {
throw new \Exception("Method {$method} not found in {$transformerClass}.");
}
return $transformer->$method($originalValue, $args, $context);
});
return $fieldValue;
}
}
// Example transformer
namespace App\GraphQL\Transformers;
class TextTransformer
{
public function transform($value, array $args, $context)
{
if (!is_string($value)) {
return $value;
}
// Apply transformations
$value = $this->sanitizeHtml($value);
$value = $this->highlightMentions($value);
$value = $this->formatMarkdown($value);
return $value;
}
private function sanitizeHtml(string $text): string
{
return strip_tags($text, '<p><br><strong><em><a><ul><ol><li><blockquote><code><pre>');
}
private function highlightMentions(string $text): string
{
return preg_replace('/@(\w+)/', '<span class="mention">@$1</span>', $text);
}
private function formatMarkdown(string $text): string
{
// Simple markdown formatting
$text = preg_replace('/\*\*(.*?)\*\*/', '<strong>$1</strong>', $text);
$text = preg_replace('/\*(.*?)\*/', '<em>$1</em>', $text);
return $text;
}
}
Real-time Subscriptions
Implement GraphQL subscriptions for real-time features:
<?php
namespace App\GraphQL\Subscriptions;
use Pusher\Pusher;
use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Nuwave\Lighthouse\Schema\Types\GraphQLSubscription;
class PostUpdated extends GraphQLSubscription
{
public function authorize($subscriber, $request): bool
{
// Allow all authenticated users to subscribe
return $subscriber !== null;
}
public function filter($subscriber, $request): bool
{
$postId = $request->id;
$subscriberPostId = $subscriber['args']['id'] ?? null;
return $postId === $subscriberPostId;
}
public function encodeTopic($subscriber, $fieldName, $root): string
{
return Str::snake($fieldName) . ':' . $subscriber['args']['id'];
}
public function decodeTopic(string $fieldName, $root): string
{
return Str::snake($fieldName) . ':' . $root->id;
}
}
class CommentAdded extends GraphQLSubscription
{
public function authorize($subscriber, $request): bool
{
return true; // Allow all users to subscribe to public posts
}
public function filter($subscriber, $request): bool
{
$postId = $request->postId;
$subscriberPostId = $subscriber['args']['postId'] ?? null;
return $postId === $subscriberPostId;
}
public function encodeTopic($subscriber, $fieldName, $root): string
{
return Str::snake($fieldName) . ':' . $subscriber['args']['postId'];
}
public function decodeTopic(string $fieldName, $root): string
{
return Str::snake($fieldName) . ':' . $root->post_id;
}
}
// Subscription trigger service
namespace App\Services;
use App\Models\Post;
use App\Models\Comment;
use Nuwave\Lighthouse\Subscriptions\Subscriber;
class GraphQLSubscriptionService
{
public function broadcastPostUpdated(Post $post): void
{
broadcast(new \App\Events\PostUpdated($post));
}
public function broadcastCommentAdded(Comment $comment): void
{
broadcast(new \App\Events\CommentAdded($comment));
}
public function broadcastUserOnline(\App\Models\User $user): void
{
broadcast(new \App\Events\UserOnline($user));
}
}
// Event listeners
namespace App\Listeners;
use App\Events\PostUpdated;
use App\Services\GraphQLSubscriptionService;
class BroadcastPostUpdate
{
public function __construct(
private GraphQLSubscriptionService $subscriptionService
) {}
public function handle(PostUpdated $event): void
{
$this->subscriptionService->broadcastPostUpdated($event->post);
}
}
// Model observers for automatic broadcasting
namespace App\Observers;
use App\Models\Comment;
use App\Services\GraphQLSubscriptionService;
class CommentObserver
{
public function __construct(
private GraphQLSubscriptionService $subscriptionService
) {}
public function created(Comment $comment): void
{
$this->subscriptionService->broadcastCommentAdded($comment);
}
}
Performance Optimization
Optimize GraphQL queries for production applications:
<?php
namespace App\GraphQL\Queries;
use App\Models\Post;
use GraphQL\Type\Definition\ResolveInfo;
use Nuwave\Lighthouse\Support\Contracts\GraphQLContext;
class OptimizedPostQueries
{
public function posts($rootValue, array $args, GraphQLContext $context, ResolveInfo $resolveInfo)
{
$query = Post::query();
// Eager load relationships based on requested fields
$requestedFields = $this->getRequestedFields($resolveInfo);
$eagerLoads = $this->determineEagerLoads($requestedFields);
if (!empty($eagerLoads)) {
$query->with($eagerLoads);
}
// Apply selective field loading
$selectFields = $this->determineSelectFields($requestedFields);
if (!empty($selectFields)) {
$query->select($selectFields);
}
// Apply filters
$this->applyFilters($query, $args);
// Apply sorting
$this->applySorting($query, $args);
return $query;
}
private function getRequestedFields(ResolveInfo $resolveInfo): array
{
$requestedFields = [];
$selectionSet = $resolveInfo->getFieldSelection();
foreach ($selectionSet as $field => $subFields) {
$requestedFields[$field] = is_array($subFields) ? array_keys($subFields) : true;
}
return $requestedFields;
}
private function determineEagerLoads(array $requestedFields): array
{
$eagerLoads = [];
$relationshipMap = [
'author' => 'author',
'comments' => 'comments.author',
'tags' => 'tags',
'likes' => 'likes',
];
foreach ($relationshipMap as $field => $relation) {
if (isset($requestedFields[$field])) {
$eagerLoads[] = $relation;
}
}
// Conditional eager loading
if (isset($requestedFields['comment_count'])) {
$eagerLoads[] = 'comments:id,post_id';
}
if (isset($requestedFields['like_count'])) {
$eagerLoads[] = 'likes:id,post_id,user_id';
}
return array_unique($eagerLoads);
}
private function determineSelectFields(array $requestedFields): array
{
$baseFields = ['id']; // Always include primary key
$fieldMap = [
'title' => 'title',
'slug' => 'slug',
'content' => 'content',
'excerpt' => 'excerpt',
'status' => 'status',
'featured_image' => 'featured_image',
'created_at' => 'created_at',
'updated_at' => 'updated_at',
'author' => 'author_id',
];
$selectFields = $baseFields;
foreach ($fieldMap as $graphqlField => $dbField) {
if (isset($requestedFields[$graphqlField])) {
$selectFields[] = $dbField;
}
}
return array_unique($selectFields);
}
private function applyFilters($query, array $args): void
{
if (isset($args['status'])) {
$query->where('status', $args['status']);
}
if (isset($args['author_id'])) {
$query->where('author_id', $args['author_id']);
}
if (isset($args['name'])) {
$query->where('title', 'like', "%{$args['name']}%");
}
// Date range filtering
if (isset($args['created_after'])) {
$query->where('created_at', '>=', $args['created_after']);
}
if (isset($args['created_before'])) {
$query->where('created_at', '<=', $args['created_before']);
}
}
private function applySorting($query, array $args): void
{
if (isset($args['orderBy'])) {
foreach ($args['orderBy'] as $order) {
$query->orderBy($order['column'], $order['order']);
}
} else {
// Default sorting
$query->latest();
}
}
}
// Query complexity analysis
namespace App\GraphQL\Security;
use GraphQL\Validator\Rules\QueryComplexity;
use GraphQL\Validator\Rules\QueryDepth;
class SecurityMiddleware
{
public function handle($request, \Closure $next)
{
// Set query complexity limits
app('graphql.config')->set('security.query_max_complexity', 1000);
app('graphql.config')->set('security.query_max_depth', 15);
app('graphql.config')->set('security.disable_introspection', app()->isProduction());
// Custom validation rules
$validationRules = [
new QueryComplexity(1000),
new QueryDepth(15),
];
app('graphql.config')->set('validation_rules', $validationRules);
return $next($request);
}
}
// Caching resolver
namespace App\GraphQL\Resolvers;
use Illuminate\Support\Facades\Cache;
class CachedResolver
{
public function cachedPosts($rootValue, array $args, $context, $resolveInfo)
{
$cacheKey = $this->generateCacheKey('posts', $args, $context);
return Cache::tags(['posts', 'graphql'])
->remember($cacheKey, 300, function () use ($args, $context, $resolveInfo) {
return $this->resolvePosts($args, $context, $resolveInfo);
});
}
private function generateCacheKey(string $prefix, array $args, $context): string
{
$keyParts = [$prefix];
// Include relevant arguments
if (!empty($args)) {
$keyParts[] = md5(serialize($args));
}
// Include user context if authenticated
if ($context->user()) {
$keyParts[] = 'user:' . $context->user()->id;
}
return implode(':', $keyParts);
}
public function invalidatePostCache(): void
{
Cache::tags(['posts', 'graphql'])->flush();
}
}
Testing GraphQL APIs
Comprehensive testing strategies for GraphQL endpoints:
<?php
namespace Tests\Feature\GraphQL;
use Tests\TestCase;
use App\Models\User;
use App\Models\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Nuwave\Lighthouse\Testing\MakesGraphQLRequests;
class PostQueriesTest extends TestCase
{
use RefreshDatabase, MakesGraphQLRequests;
public function test_can_query_published_posts(): void
{
$user = User::factory()->create();
$posts = Post::factory()->count(3)->create([
'author_id' => $user->id,
'status' => 'published'
]);
$response = $this->graphQL('
query {
posts {
data {
id
title
status
author {
id
name
}
}
}
}
');
$response->assertJson([
'data' => [
'posts' => [
'data' => [
[
'id' => (string) $posts[0]->id,
'title' => $posts[0]->title,
'status' => 'PUBLISHED',
'author' => [
'id' => (string) $user->id,
'name' => $user->name,
]
]
]
]
]
]);
}
public function test_can_filter_posts_by_status(): void
{
$user = User::factory()->create();
Post::factory()->count(2)->create([
'author_id' => $user->id,
'status' => 'published'
]);
Post::factory()->count(1)->create([
'author_id' => $user->id,
'status' => 'draft'
]);
$response = $this->graphQL('
query {
posts(status: PUBLISHED) {
data {
id
status
}
}
}
');
$response->assertJsonCount(2, 'data.posts.data');
$response->assertJsonPath('data.posts.data.0.status', 'PUBLISHED');
}
public function test_can_search_posts(): void
{
$user = User::factory()->create();
$matchingPost = Post::factory()->create([
'author_id' => $user->id,
'title' => 'Laravel GraphQL Tutorial',
'status' => 'published'
]);
$nonMatchingPost = Post::factory()->create([
'author_id' => $user->id,
'title' => 'Vue.js Components',
'status' => 'published'
]);
$response = $this->graphQL('
query {
search(query: "Laravel") {
... on Post {
id
title
}
}
}
');
$response->assertJsonFragment(['title' => 'Laravel GraphQL Tutorial']);
$response->assertJsonMissing(['title' => 'Vue.js Components']);
}
public function test_requires_authentication_for_private_fields(): void
{
$user = User::factory()->create();
$post = Post::factory()->create(['author_id' => $user->id]);
// Test without authentication
$response = $this->graphQL("
query {
post(id: {$post->id}) {
id
title
is_liked
}
}
");
$response->assertGraphQLErrorMessage('Unauthenticated.');
// Test with authentication
$response = $this->actingAs($user)->graphQL("
query {
post(id: {$post->id}) {
id
title
is_liked
}
}
");
$response->assertJson([
'data' => [
'post' => [
'id' => (string) $post->id,
'is_liked' => false
]
]
]);
}
}
class PostMutationsTest extends TestCase
{
use RefreshDatabase, MakesGraphQLRequests;
public function test_can_create_post(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->graphQL('
mutation {
createPost(input: {
title: "Test Post"
content: "This is a test post content."
status: DRAFT
}) {
id
title
content
status
author {
id
}
}
}
');
$response->assertJson([
'data' => [
'createPost' => [
'title' => 'Test Post',
'content' => 'This is a test post content.',
'status' => 'DRAFT',
'author' => [
'id' => (string) $user->id
]
]
]
]);
$this->assertDatabaseHas('posts', [
'title' => 'Test Post',
'author_id' => $user->id,
]);
}
public function test_validates_input_when_creating_post(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->graphQL('
mutation {
createPost(input: {
title: ""
content: "Content"
}) {
id
}
}
');
$response->assertGraphQLValidationError('input.title', 'The input.title field is required.');
}
public function test_can_update_own_post(): void
{
$user = User::factory()->create();
$post = Post::factory()->create(['author_id' => $user->id]);
$response = $this->actingAs($user)->graphQL("
mutation {
updatePost(id: {$post->id}, input: {
title: \"Updated Title\"
}) {
id
title
}
}
");
$response->assertJson([
'data' => [
'updatePost' => [
'id' => (string) $post->id,
'title' => 'Updated Title'
]
]
]);
}
public function test_cannot_update_others_post(): void
{
$author = User::factory()->create();
$otherUser = User::factory()->create();
$post = Post::factory()->create(['author_id' => $author->id]);
$response = $this->actingAs($otherUser)->graphQL("
mutation {
updatePost(id: {$post->id}, input: {
title: \"Hacked Title\"
}) {
id
}
}
");
$response->assertGraphQLErrorMessage('This action is unauthorized.');
}
}
class SubscriptionTest extends TestCase
{
use RefreshDatabase;
public function test_can_subscribe_to_post_updates(): void
{
$user = User::factory()->create();
$post = Post::factory()->create(['author_id' => $user->id]);
// Mock the subscription
$this->mock(\Nuwave\Lighthouse\Subscriptions\SubscriptionRegistry::class)
->shouldReceive('register')
->once();
$response = $this->actingAs($user)->graphQL("
subscription {
postUpdated(id: {$post->id}) {
id
title
updated_at
}
}
");
// Subscriptions return null in testing
$response->assertJson(['data' => ['postUpdated' => null]]);
}
}
Conclusion
Laravel Lighthouse transforms GraphQL from a complex specification into an elegant, Laravel-native solution. By leveraging Laravel's conventions and ecosystem, Lighthouse enables developers to build powerful, flexible APIs that scale from simple applications to complex, real-time systems.
The combination of GraphQL's flexibility and Laravel's developer experience creates an API development platform that's both powerful and enjoyable to use. Whether building mobile backends, real-time dashboards, or complex data integrations, Laravel Lighthouse provides the tools to create APIs that adapt to changing requirements while maintaining performance and type safety.
Related Posts
Add Comment
No comments yet. Be the first to comment!