Navigation

Laravel

GraphQL APIs with Laravel and Lighthouse

Master GraphQL APIs with Laravel Lighthouse. Learn schema definition, query optimization, custom directives, real-time subscriptions, authentication, caching strategies, and testing. Build flexible, type-safe APIs with Laravel's elegant syntax.
GraphQL APIs with Laravel and Lighthouse

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

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

Share this article

Add Comment

No comments yet. Be the first to comment!

More from Laravel