PHPackages                             spawnia/sailor - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [API Development](/categories/api)
4. /
5. spawnia/sailor

ActiveLibrary[API Development](/categories/api)

spawnia/sailor
==============

A typesafe GraphQL client for PHP

v1.2.1(3mo ago)92505.0k↓20.9%20[3 PRs](https://github.com/spawnia/sailor/pulls)2MITPHPPHP ^7.4 || ^8CI passing

Since Feb 15Pushed 2mo ago5 watchersCompare

[ Source](https://github.com/spawnia/sailor)[ Packagist](https://packagist.org/packages/spawnia/sailor)[ Docs](https://github.com/spawnia/sailor)[ GitHub Sponsors](https://github.com/spawnia)[ RSS](/packages/spawnia-sailor/feed)WikiDiscussions master Synced 1mo ago

READMEChangelog (10)Dependencies (29)Versions (71)Used By (2)

 [![sailor-logo"](sailor.png)](sailor.png)

[![CI Status](https://github.com/spawnia/sailor/workflows/Validate/badge.svg)](https://github.com/spawnia/sailor/actions)[![codecov](https://camo.githubusercontent.com/9d4fc6a79b1a3752130b484ca585422c238fbee7add58e0890fe17e6b7788e06/68747470733a2f2f636f6465636f762e696f2f67682f737061776e69612f7361696c6f722f6272616e63682f6d61737465722f67726170682f62616467652e737667)](https://codecov.io/gh/spawnia/sailor)

[![Latest Stable Version](https://camo.githubusercontent.com/89b47d09ed395d3ea41550e67e2295519e6640e41ad6f0e7aeb779c71fba3a45/68747470733a2f2f706f7365722e707567782e6f72672f737061776e69612f7361696c6f722f762f737461626c65)](https://packagist.org/packages/spawnia/sailor)[![Total Downloads](https://camo.githubusercontent.com/bc7d0b6fb25b82c96f17aa942f18672ab6e16af0bfcfa8471f141822eeece93c/68747470733a2f2f706f7365722e707567782e6f72672f737061776e69612f7361696c6f722f646f776e6c6f616473)](https://packagist.org/packages/spawnia/sailor)

A typesafe GraphQL client for PHP

Motivation
----------

[](#motivation)

GraphQL provides typesafe API access through the schema definition each server provides through introspection. Sailor leverages that information to enable an ergonomic workflow and reduce type-related bugs in your code.

The native GraphQL query language is the most universally used tool to formulate GraphQL queries and works natively with the entire ecosystem of GraphQL tools. Sailor takes the plain queries you write and generates executable PHP code, using the server schema to generate typesafe operations and results.

Installation
------------

[](#installation)

Install Sailor through composer by running:

```
composer require spawnia/sailor
```

If you want to use the built-in default Client (see [Client implementations](#client-implementations)):

```
composer require guzzlehttp/guzzle
```

If you want to use the PSR-18 Client and don't have PSR-17 Request and Stream factory implementations (see [Client implementations](#client-implementations)):

```
composer require nyholm/psr7
```

Configuration
-------------

[](#configuration)

Run `vendor/bin/sailor` to set up the configuration. A file called `sailor.php` will be created in your project root.

A Sailor configuration file is expected to return an associative array where the keys are endpoint names and the values are instances of `Spawnia\Sailor\EndpointConfig`.

You can take a look at the example configuration to see what options are available for configuration: [`sailor.php`](sailor.php).

If you would like to use multiple configuration files, specify which file to use through the `-c/--config` option.

It is quite useful to include dynamic values in your configuration. You might use [PHP dotenv](https://github.com/vlucas/phpdotenv) to load environment variables (run `composer require vlucas/phpdotenv` if you do not have it installed already.).

```
# sailor.php
+$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
+$dotenv->load();

...
        public function makeClient(): Client
        {
            return new \Spawnia\Sailor\Client\Guzzle(
-               'https://hardcoded.url',
+               getenv('EXAMPLE_API_URL'),
                [
                    'headers' => [
-                       'Authorization' => 'hardcoded-api-token',
+                       'Authorization' => getenv('EXAMPLE_API_TOKEN'),
                    ],
                ]
            );
        }
```

### Client implementations

[](#client-implementations)

Sailor provides a few built-in clients:

- `Spawnia\Sailor\Client\Guzzle`: Default HTTP client
- `Spawnia\Sailor\Client\Psr18`: PSR-18 HTTP client
- `Spawnia\Sailor\Client\Log`: Used for testing

You can bring your own by implementing the interface `Spawnia\Sailor\Client`.

### Dynamic clients

[](#dynamic-clients)

You can configure clients dynamically for specific operations or per request:

```
use Example\Api\Operations\HelloSailor;

/** @var \Spawnia\Sailor\Client $client Somehow instantiated dynamically */

HelloSailor::setClient($client);

// Will use $client over the client from EndpointConfig
$result = HelloSailor::execute();

// Reverts to using the client from EndpointConfig
HelloSailor::setClient(null);
```

### Custom types

[](#custom-types)

Custom scalars are commonly serialized as strings, but may also use other representations. Without knowing about the contents of the type, Sailor can not do any conversions or provide more accurate type hints, so it uses `mixed`.

Since enums are only supported from PHP 8.1 and this library still supports PHP 7.4, it generates enums as a class with string constants and handles values as `string`. You may leverage native PHP enums by overriding `EndpointConfig::enumTypeConfig()`and return an instance of `Spawnia\Sailor\Type\NativeEnumTypeConfig`.

Overwrite `EndpointConfig::configureTypes()` to specialize how Sailor deals with the types within your schema. See [examples/custom-types](examples/custom-types).

### Error conversion

[](#error-conversion)

Errors sent within the GraphQL response must follow the [response errors specification](http://spec.graphql.org/October2021/#sec-Errors). Sailor converts the plain `stdClass` obtained from decoding the JSON response into instances of `\Spawnia\Sailor\Error\Error` by default.

If one of your endpoints returns structured data in `extensions`, you can customize how the plain errors are decoded into class instances by overwriting `EndpointConfig::parseError()`.

Usage
-----

[](#usage)

### Introspection

[](#introspection)

Run `vendor/bin/sailor introspect` to update your schema with the latest changes from the server by running an introspection query. As an example, a very simple server might result in the following file being placed in your project:

```
# schema.graphql
type Query {
  hello(name: String): String
}
```

### Define operations

[](#define-operations)

Put your queries and mutations into `.graphql` files and place them anywhere within your configured project directory. You are free to name and structure the files in any way. Let's query the example schema from above:

```
# src/example.graphql
query HelloSailor {
  hello(name: "Sailor")
}
```

You must give all your operations unique `PascalCase` names, the following example is invalid:

```
# Invalid, operations have to be named
query {
  anonymous
}

# Invalid, names must be unique across all operations
query Foo { ... }
mutation Foo { ... }

# Invalid, names must be PascalCase
query camelCase { ... }
```

### Generate code

[](#generate-code)

Run `vendor/bin/sailor` to generate PHP code for your operations. For the example above, Sailor will generate a class called `HelloSailor`, place it in the configured namespace and write it to the configured location.

```
namespace Example\Api\Operations;

class HelloSailor extends \Spawnia\Sailor\Operation { ... }
```

There are additional generated classes that represent the results of calling the operations. The plain data from the server is wrapped up and contained within those value classes, so you can access them in a typesafe way.

### Execute queries

[](#execute-queries)

You are now set up to run a query against the server, just call the `execute()` function of the new query class:

```
$result = \Example\Api\Operations\HelloSailor::execute();
```

The returned `$result` is going to be a class that extends `\Spawnia\Sailor\Result` and holds the decoded response returned from the server. You can just grab the `$data`, `$errors` or `$extensions` off of it:

```
$result->data       // `null` or a generated subclass of `\Spawnia\Sailor\ObjectLike`
$result->errors     // `null` or a list of `\Spawnia\Sailor\Error\Error`
$result->extensions // `null` or an arbitrary map
```

### Error handling

[](#error-handling)

You can ensure an operation returned the proper data and contained no errors:

```
$errorFreeResult = \Example\Api\Operations\HelloSailor::execute()
    ->errorFree(); // Throws if there are errors or returns an error free result
```

The `$errorFreeResult` is going to be a class that extends `\Spawnia\Sailor\ErrorFreeResult`. Given it can only be obtained by going through validation, it is guaranteed to have non-null `$data` and does not have `$errors`:

```
$errorFreeResult->data       // a generated subclass of `\Spawnia\Sailor\ObjectLike`
$errorFreeResult->extensions // `null` or an arbitrary map
```

If you do not need to access the data and just want to ensure a mutation was successful, the following is more efficient as it does not instantiate a new object:

```
\Example\Api\Operations\SomeMutation::execute()
    ->assertErrorFree(); // Throws if there are errors
```

### Queries with arguments

[](#queries-with-arguments)

Your generated operation classes will be annotated with the arguments your query defines.

```
class HelloSailor extends \Spawnia\Sailor\Operation
{
    public static function execute(string $required, ?\Example\Api\Types\SomeInput $input = null): HelloSailor\HelloSailorResult { ... }
}
```

Inputs can be built up incrementally:

```
$input = new \Example\Api\Types\SomeInput;
$input->foo = 'bar';
```

If you are using PHP 8, instantiation with named arguments can be quite useful to ensure your input is completely filled:

```
\Example\Api\Types\SomeInput::make(foo: 'bar')
```

### Partial inputs

[](#partial-inputs)

GraphQL often uses a pattern of partial inputs - the equivalent of an HTTP `PATCH`. Consider the following input:

```
input SomeInput {
  requiredID: Int!
  firstOptional: Int
  secondOptional: Int
}
```

Suppose we allow instantiation in PHP with the following implementation:

```
class SomeInput extends \Spawnia\Sailor\ObjectLike
{
    public static function make(
        int $requiredID,
        ?int $firstOptional = null,
        ?int $secondOptional = null,
    ): self {
        $instance = new self;

        $instance->requiredID = $required;
        $instance->firstOptional = $firstOptional;
        $instance->secondOptional = $secondOptional;

        return $instance;
    }
}
```

Given that implementation, the following call will produce the following JSON payload:

```
SomeInput::make(requiredID: 1, secondOptional: 2);
```

```
{ "requiredID": 1, "firstOptional": null, "secondOptional": 2 }
```

However, we would like to produce the following JSON payload:

```
{ "requiredID": 1, "secondOptional": 2 }
```

This is because from within `make()`, there is no way to differentiate between an explicitly passed optional named argument and one that has been assigned the default value. Thus, the resulting JSON payload will unintentionally modify `firstOptional` too, erasing whatever value it previously held.

A naive solution to this would be to filter out any argument that is `null`. However, we would also like to be able to explicitly set the first optional value to `null`. The following call *should* result in a JSON payload that contains `"firstOptional": null`.

```
SomeInput::make(requiredID: 1, firstOptional: null, secondOptional: 2);
```

In order to generate partial inputs by default, optional named arguments have a special default value:

```
Spawnia\Sailor\ObjectLike::UNDEFINED = 'Special default value that allows Sailor to differentiate between explicitly passing null and not passing a value at all.';
```

```
class SomeInput extends \Spawnia\Sailor\ObjectLike
{
    /**
     * @param int $requiredID
     * @param int|null $firstOptional
     * @param int|null $secondOptional
     */
    public static function make(
        $requiredID,
        $firstOptional = 'Special default value that allows Sailor to differentiate between explicitly passing null and not passing a value at all.',
        $secondOptional = 'Special default value that allows Sailor to differentiate between explicitly passing null and not passing a value at all.',
    ): self {
        $instance = new self;

        if ($requiredID !== self::UNDEFINED) {
            $instance->requiredID = $requiredID;
        }
        if ($firstOptional !== self::UNDEFINED) {
            $instance->firstOptional = $firstOptional;
        }
        if ($secondOptional !== self::UNDEFINED) {
            $instance->secondOptional = $secondOptional;
        }

        return $instance;
    }
}
```

You may use `Spawnia\Sailor\ObjectLike::UNDEFINED` to omit nullable arguments completely:

```
SomeInput::make(
    requiredID: 1,
    firstOptional: $maybeNull ?? Spawnia\Sailor\ObjectLike::UNDEFINED,
);
```

If `$maybeNull` is `null`, this will result in the following JSON payload:

```
{ "requiredID": 1 }
```

In the very unlikely case where you need to pass exactly the value of `Spawnia\Sailor\ObjectLike::UNDEFINED`, you can bypass the logic in `make()` and assign it directly:

```
$input = SomeInput::make(requiredID: 1);
$input->secondOptional = Spawnia\Sailor\ObjectLike::UNDEFINED;
```

### Events

[](#events)

Sailor calls `EndpointConfig::handleEvent()` with the following events during the execution lifecycle:

1. [StartRequest](src/Events/StartRequest.php): Fired after calling `execute()` on an `Operation`, before invoking the client.
2. [ReceiveResponse](src/Events/ReceiveResponse.php): Fired after receiving a GraphQL response from the client.

### PHP keyword collisions

[](#php-keyword-collisions)

Since GraphQL uses a different set of reserved keywords, names of fields or types may collide with PHP keywords. Sailor prevents illegal usages of those names in generated code by prefixing them with a single underscore `_`.

Testing
-------

[](#testing)

Sailor provides first class support for testing by allowing you to mock operations.

### Setup

[](#setup)

It is assumed you are using [PHPUnit](https://phpunit.de) and [Mockery](https://docs.mockery.io/en/latest).

```
composer require --dev phpunit/phpunit mockery/mockery
```

Make sure your test class - or one of its parents - uses the following traits:

```
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use PHPUnit\Framework\TestCase as PHPUnitTestCase;
use Spawnia\Sailor\Testing\RequiresSailorMocks;

abstract class TestCase extends PHPUnitTestCase
{
    use MockeryPHPUnitIntegration; // Makes Mockery assertions work
    use RequiresSailorMocks; // Prevents stray requests and resets mocks between tests
}
```

If you want to perform some kind of integration test where mocks are not required, you may replace `RequiresSailorMocks` with `UsesSailorMocks`.

### Mock results

[](#mock-results)

Mocks are registered per operation class:

```
use Example\Api\Operations\HelloSailor;

/** @var \Mockery\MockInterface&HelloSailor */
$mock = HelloSailor::mock();
```

When registered, the mock captures all calls to `HelloSailor::execute()`. Use it to build up expectations for what calls it should receive and mock returned results:

```
$name = 'Sailor';
$hello = "Hello, {$name}!";

$mock
    ->expects('execute')
    ->once()
    ->with($name)
    ->andReturn(HelloSailor\HelloSailorResult::fromData(
        data: HelloSailor\HelloSailor::make(
            hello: $hello,
        ),
    ));

$result = HelloSailor::execute(name: $name)
    ->errorFree();

self::assertSame($hello, $result->data->hello);
```

Subsequent calls to `::mock()` will return the initially registered mock instance.

```
$mock1 = HelloSailor::mock();
$mock2 = HelloSailor::mock();
assert($mock1 === $mock2); // true
```

You can also simulate a result with errors:

```
HelloSailor\HelloSailorResult::fromErrors([
    (object) [
        'message' => 'Something went wrong',
    ],
]);
```

For PHP 8 users, it is recommended to use named arguments to build complex mocked results:

```
HelloSailor\HelloSailorResult::fromData(
    data: HelloSailor\HelloSailor::make(
        hello: 'Hello, Sailor!',
        nested: HelloSailor\HelloSailor\Nested::make(
            hello: 'Hello again!',
        ),
    ),
))
```

### Integration

[](#integration)

If you want to perform integration testing for a service that uses Sailor without actually hitting an external API, you can swap out your client with the `Log` client. It writes all requests made through Sailor to a file of your choice.

> The `Log` client can not know what constitutes a valid response for a given request, so it always responds with an error.

```
# sailor.php
public function makeClient(): Client
{
    return new \Spawnia\Sailor\Client\Log(__DIR__ . '/sailor-requests.log');
}
```

Each request goes on a new line and contains a JSON string that holds the `query` and `variables`:

```
{"query":"{ foo }","variables":{"bar":42}}
{"query":"mutation { baz }","variables":null}
```

This allows you to perform assertions on the calls that were made. The `Log` client offers a convenient method of reading the requests as structured data:

```
$log = new \Spawnia\Sailor\Client\Log(__DIR__ . '/sailor-requests.log');
foreach ($log->requests() as $request) {
    var_dump($request);
}

array(2) {
  ["query"]=>
  string(7) "{ foo }"
  ["variables"]=>
  array(1) {
    ["bar"]=>
    int(42)
  }
}
array(2) {
  ["query"]=>
  string(7) "mutation { baz }"
  ["variables"]=>
  NULL
}
```

To clean up the log after performing tests, use `Log::clear()`.

Examples
--------

[](#examples)

You can find examples of how a project would use Sailor within [examples](examples).

Changelog
---------

[](#changelog)

See [`CHANGELOG.md`](CHANGELOG.md).

Contributing
------------

[](#contributing)

See [`CONTRIBUTING.md`](CONTRIBUTING.md).

Sponsors
--------

[](#sponsors)

If Sailor saves you time, consider [sponsoring its development](https://github.com/sponsors/spawnia).

License
-------

[](#license)

This package is licensed using the MIT License.

###  Health Score

61

—

FairBetter than 99% of packages

Maintenance85

Actively maintained with recent releases

Popularity53

Moderate usage in the ecosystem

Community25

Small or concentrated contributor base

Maturity68

Established project with proven stability

 Bus Factor1

Top contributor holds 93.4% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~28 days

Recently: every ~78 days

Total

66

Last Release

92d ago

Major Versions

v0.35.0 → v1.0.02025-03-27

PHP version history (2 changes)v0.1.0PHP ^7.2

v0.2.0PHP ^7.4 || ^8

### Community

Maintainers

![](https://www.gravatar.com/avatar/2ff5d1af2c0f601f7ed7e75e15be0aa4c062149b57fd5aace4e44cc37b8b7a40?d=identicon)[spawnia](/maintainers/spawnia)

---

Top Contributors

[![spawnia](https://avatars.githubusercontent.com/u/12158000?v=4)](https://github.com/spawnia "spawnia (299 commits)")[![simPod](https://avatars.githubusercontent.com/u/327717?v=4)](https://github.com/simPod "simPod (14 commits)")[![Ralf77](https://avatars.githubusercontent.com/u/35593596?v=4)](https://github.com/Ralf77 "Ralf77 (2 commits)")[![simbig](https://avatars.githubusercontent.com/u/26680884?v=4)](https://github.com/simbig "simbig (1 commits)")[![CRC-Mismatch](https://avatars.githubusercontent.com/u/7253323?v=4)](https://github.com/CRC-Mismatch "CRC-Mismatch (1 commits)")[![vuongxuongminh](https://avatars.githubusercontent.com/u/38932626?v=4)](https://github.com/vuongxuongminh "vuongxuongminh (1 commits)")[![morticue](https://avatars.githubusercontent.com/u/165028?v=4)](https://github.com/morticue "morticue (1 commits)")[![refo](https://avatars.githubusercontent.com/u/1114116?v=4)](https://github.com/refo "refo (1 commits)")

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/spawnia-sailor/health.svg)

```
[![Health](https://phpackages.com/badges/spawnia-sailor/health.svg)](https://phpackages.com/packages/spawnia-sailor)
```

###  Alternatives

[sylius/sylius

E-Commerce platform for PHP, based on Symfony framework.

8.4k5.6M651](/packages/sylius-sylius)[wheelpros/fitment-platform-api

Magento 2 (Open Source)

12.1k1.2k](/packages/wheelpros-fitment-platform-api)[drupal/core-recommended

Locked core dependencies; require this project INSTEAD OF drupal/core.

6939.5M343](/packages/drupal-core-recommended)[tempest/framework

The PHP framework that gets out of your way.

2.1k23.1k9](/packages/tempest-framework)[worksome/graphlint

A static analysis tool for GraphQL

13189.4k](/packages/worksome-graphlint)[toshy/bunnynet-php

BunnyNet API client for PHP

61172.1k6](/packages/toshy-bunnynet-php)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
