ehsanquddusi / graphql-client
A modern, Laravel-friendly GraphQL client with immutable multi-endpoint clients, fluent builders, and full HTTP/GraphQL response access.
Requires
- php: ^8.2
- guzzlehttp/psr7: ^2.0
- illuminate/cache: ^10.0|^11.0|^12.0
- illuminate/contracts: ^10.0|^11.0|^12.0
- illuminate/http: ^10.0|^11.0|^12.0
- illuminate/support: ^10.0|^11.0|^12.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0
- phpunit/phpunit: ^10.5|^11.0
This package is auto-updated.
Last update: 2026-08-29 07:30:20 UTC
README
A modern GraphQL client that feels like Laravel's Http facade: immutable multi-endpoint clients, fluent query builders, and a response object that exposes both GraphQL data and the underlying HTTP response.
GraphQL:
query { products { id name price } }
Client:
use EhsanQ\GraphQL\GraphQLFacade as GraphQL; $response = GraphQL::query('products') ->select('id', 'name', 'price') ->get(); $products = $response->data('products');
Then inspect the HTTP layer when you need it:
$response->status(); $response->headers(); $response->errors(); $response->http();
Requirements
- PHP 8.2+
- Laravel 10, 11, or 12 (or
illuminate/http+illuminate/supportoutside a full Laravel app)
Installation
composer require ehsanquddusi/graphql-client
Publish the config file if you want to customize defaults:
php artisan vendor:publish --tag=graphql-config
Configuration
GRAPHQL_ENDPOINT=https://api.example.com/graphql GRAPHQL_AUTH_TYPE=bearer GRAPHQL_TOKEN=your-token GRAPHQL_TIMEOUT=30 GRAPHQL_CONNECT_TIMEOUT=10
config/graphql.php also covers default headers, basic auth, retries, and query caching. Every setting is overridable at runtime.
Authentication
GraphQL over HTTP typically sends a Bearer token:
GraphQL::endpoint($endpoint) ->withToken($token) ->query('products') ->select('id', 'name') ->get();
Helpers:
GraphQL::withToken($token); // Bearer (Laravel convention) GraphQL::bearerToken($token); // explicit alias GraphQL::withBasicAuth($user, $password); GraphQL::withApiKey('X-API-Key', $key); GraphQL::withHeaders(['Authorization' => '...']);
Config auth.type can be none, bearer, or basic.
Multiple APIs
Clients are immutable. One application's Orchardly, Shopify, and GitHub clients never leak endpoint, token, or headers into each other:
$orchardly = GraphQL::endpoint($orchardlyUrl)->withToken($orchardlyToken); $shopify = GraphQL::endpoint($shopifyUrl)->withToken($shopifyToken); $orchardly->query('products')->select('id')->get(); $shopify->query('products')->select('id')->get();
Query builder
where() is argument sugar — GraphQL does not get SQL semantics.
Simple selection
GraphQL:
query { countries { id name code } }
Client:
GraphQL::query('countries') ->select('id', 'name', 'code') ->get();
Arguments
GraphQL:
query ($code: String) { country(code: $code) { name capital } }
Client:
GraphQL::query('country') ->where('code', 'IN') ->select('name', 'capital') ->get();
Multiple arguments
GraphQL:
query ($category: String, $limit: Int) { products(category: $category, limit: $limit) { id name price } }
Client:
GraphQL::query('products') ->where('category', 'apple') ->where('limit', 20) ->select('id', 'name', 'price') ->get();
Use args([...]) when you want a full argument map.
Nested fields
GraphQL:
query { products { id name category { name } supplier { id name } } }
Client:
GraphQL::query('products') ->select('id', 'name', 'category.name') ->expand('supplier', fn ($q) => $q->select('id', 'name')) ->get();
expand('supplier') without a callback also works, then select() on the nested builder.
Dynamic field helpers
GraphQL:
query { countries { code name continent { name } } }
Client:
GraphQL::countries() ->code() ->name() ->continent(fn ($q) => $q->name()) ->get();
The foundation remains GraphQL::query('products'). Magic methods are convenience, not the only API.
Mutations
Same fluent interface. get(), send(), and execute() all run the operation.
GraphQL:
mutation ($name: String) { createProduct(name: $name) { id name } }
Client:
GraphQL::mutation('createProduct') ->args(['name' => 'Apple']) ->select('id', 'name') ->send();
Variables
Values are sent as GraphQL variables, not interpolated into the document.
GraphQL:
query ($category: String, $limit: Int) { products(category: $category, limit: $limit) { id name } }
Client:
GraphQL::query('products') ->variables([ 'category' => 'apple', 'limit' => 20, ]) ->select('id', 'name') ->get();
Override inferred types when the schema needs an input object:
GraphQL:
mutation ($input: ProductInput!) { createProduct(input: $input) { id } }
Client:
GraphQL::mutation('createProduct') ->args(['input' => $input]) ->variableTypes(['input' => 'ProductInput!']) ->select('id') ->send();
Raw GraphQL and files
When a query is too specific for the builder, send the document as-is:
query ($id: ID!) { product(id: $id) { id name } }
GraphQL::raw($query); GraphQL::raw($query, ['id' => '42']); GraphQL::file('queries/products.graphql') ->variables(['limit' => 20]) ->get();
Absolute paths work. Relative paths are resolved against GRAPHQL_QUERY_PATH, resource_path(), resource_path('graphql/'), base_path(), then the current working directory.
HTTP options
These map through to Laravel's HTTP client:
GraphQL::retry(3, 100) ->timeout(30) ->connectTimeout(10) ->withoutVerifying() ->query('products') ->select('id') ->get();
Caching
Opt-in for queries. Mutations are never cached.
GraphQL::cache(300) ->query('countries') ->select('id', 'name') ->get(); GraphQL::cache(300)->cacheKey('countries')->cacheStore('redis'); GraphQL::noCache();
Cache keys include the endpoint, document, variables, and a hash of the auth identity — never the raw Authorization header. Hits still return GraphQLResponse.
Response contract
HTTP 200 can still contain GraphQL errors. That is not treated as a transport failure.
| Method | Meaning |
|---|---|
successful() / failed() / status() |
HTTP |
hasErrors() / errors() |
GraphQL payload |
data() / data('products') |
GraphQL data |
json() / body() / headers() / header() |
payload / HTTP |
http() |
Illuminate\Http\Client\Response |
throw() |
HTTP failure, then GraphQL errors |
throwIfGraphQLErrors() |
GraphQL errors only |
$response->throw(); $response->throwIfGraphQLErrors();
Testing
The transport is Laravel's HTTP client, so fakes work:
Http::fake([ 'https://api.example.com/graphql' => Http::response([ 'data' => ['products' => []], ], 200), ]); $response = GraphQL::query('products')->select('id')->get(); Http::assertSent(fn ($request) => $request->url() === 'https://api.example.com/graphql');
Standalone PHP
The core talks to a Transport interface. Construct a client with Laravel's HTTP factory (no full Laravel app required):
use EhsanQ\GraphQL\GraphQL; use EhsanQ\GraphQL\GraphQLClient; use EhsanQ\GraphQL\Transport\LaravelHttpTransport; use Illuminate\Http\Client\Factory; $graphql = new GraphQL(fn () => GraphQLClient::fromConfig( require 'config/graphql.php', new LaravelHttpTransport(new Factory), ));
You can also pass any Transport implementation of your own.
Contributing
Bug reports, tests, and pull requests are welcome.
- Fork the repository and create a feature branch.
- Install dependencies with
composer install. - Run the suite with
composer test(orvendor/bin/phpunit). - Keep changes focused. Match the existing code style and add tests for new behavior — especially client isolation, GraphQL vs HTTP errors, and
Http::fake()coverage. - Open a pull request that explains the problem and how you verified the fix.
Please do not include vendor/ or secrets. If the change affects the public API, update this README with a GraphQL document and the matching client example.
License
MIT