diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1802890
--- /dev/null
+++ b/README.md
@@ -0,0 +1,895 @@
+
+ GraphQL Client Code Generator for PHP
+ Transform your GraphQL queries into type-safe, zero-dependency PHP 8.4+ code. Let the generator handle types, validation, and boilerplate—you just write queries.
+
+
+
+
+
+
+
+
+------
+
+## Why This Library?
+
+Ever struggled with GraphQL in PHP? Tired of wrestling with nested arrays, missing autocomplete, and runtime errors from typos?
+Generic GraphQL clients force you to work with untyped arrays—your IDE can't help you, PHPStan can't verify anything, and every
+query response is a mystery until runtime.
+
+**This library changes that.**
+
+Write a GraphQL query, run the generator, and get beautiful, type-safe PHP classes with zero runtime dependencies. Your IDE
+autocompletes field names, PHPStan verifies everything at level 9, and bugs are caught during development—not production.
+
+## The Problem
+
+### ❌ Before: Array Hell
+
+```php
+// Generic GraphQL client - no types, no safety
+$data = $client->query(<<<'GRAPHQL'
+ query {
+ repository(owner: "ruudk", name: "code-generator") {
+ issues(first: 10) {
+ nodes {
+ title
+ number
+ author { login }
+ }
+ }
+ }
+ }
+ GRAPHQL
+);
+
+// What fields exist? Who knows! 🤷
+$title = $data['data']['repository']['issues']['nodes'][0]['title'] ?? null;
+// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+// No autocomplete, no type checking, runtime errors waiting to happen
+
+// Did you make a typo? You'll find out in production! 💥
+$author = $data['data']['repository']['issues']['nodes'][0]['autor']['login'];
+// ^^^^^ typo!
+```
+
+### ✅ After: Type-Safe Bliss
+
+
+```php
+bootEnv(__DIR__ . '/.env.local');
+
+Assert::keyExists($_ENV, 'GITHUB_TOKEN');
+$token = $_ENV['GITHUB_TOKEN'];
+Assert::stringNotEmpty($token);
+
+$client = new GitHubClient(Psr18ClientDiscovery::find(), $token);
+
+dump(new ViewerQuery($client)->execute()->viewer->login);
+
+$data = new SearchQuery($client)->execute();
+
+foreach ($data->search->nodes ?? [] as $node) {
+ if ($node === null) {
+ continue;
+ }
+
+ if ($node->asIssue !== null) {
+ dump(asIssue: $node->asIssue->title);
+ }
+
+ if ($node->pullRequestInfo !== null) {
+ dump(asPullRequest: $node->pullRequestInfo->title . ' is merged: ' . $node->pullRequestInfo->merged);
+ }
+}
+```
+
+## Installation
+
+```bash
+composer require --dev ruudk/graphql-client-code-generator
+```
+
+## Quick Start
+
+**1. Create a config file:**
+
+
+```php
+withIntrospectionClient(function () {
+ $dotenv = new Dotenv();
+ $dotenv->bootEnv(__DIR__ . '/.env.local');
+
+ Assert::keyExists($_ENV, 'GITHUB_TOKEN');
+ $token = $_ENV['GITHUB_TOKEN'];
+ Assert::stringNotEmpty($token);
+
+ return new GitHubClient(Psr18ClientDiscovery::find(), $token);
+ })
+ ->enableDumpDefinition()
+ ->enableUseNodeNameForEdgeNodes()
+ ->enableUseConnectionNameForConnections()
+ ->enableUseEdgeNameForEdges();
+```
+
+**2. Write your GraphQL queries:**
+
+
+```graphql
+query Search {
+ search(query: "repo:twigstan/twigstan", type: ISSUE, first: 10) {
+ nodes {
+ __typename
+ ... on Issue {
+ number
+ title
+ }
+ ...PullRequestInfo
+ }
+ }
+}
+
+fragment PullRequestInfo on PullRequest {
+ number
+ title
+ merged
+}
+```
+
+**3. Generate type-safe PHP code:**
+
+```bash
+vendor/bin/graphql-client-code-generator
+```
+
+**4. Use it in your code:**
+
+
+```php
+bootEnv(__DIR__ . '/.env.local');
+
+Assert::keyExists($_ENV, 'GITHUB_TOKEN');
+$token = $_ENV['GITHUB_TOKEN'];
+Assert::stringNotEmpty($token);
+
+$client = new GitHubClient(Psr18ClientDiscovery::find(), $token);
+
+dump(new ViewerQuery($client)->execute()->viewer->login);
+
+$data = new SearchQuery($client)->execute();
+
+foreach ($data->search->nodes ?? [] as $node) {
+ if ($node === null) {
+ continue;
+ }
+
+ if ($node->asIssue !== null) {
+ dump(asIssue: $node->asIssue->title);
+ }
+
+ if ($node->pullRequestInfo !== null) {
+ dump(asPullRequest: $node->pullRequestInfo->title . ' is merged: ' . $node->pullRequestInfo->merged);
+ }
+}
+```
+
+That's it! Your GraphQL queries are now type-safe PHP classes.
+
+## What Makes This Awesome?
+
+### 🎯 Zero Runtime Dependencies
+The generated code has **no dependencies**. None. Only the generator tool needs libraries—your production code stays lean and lightning fast.
+
+
+```php
+client->graphql(
+ self::OPERATION_DEFINITION,
+ [
+ ],
+ self::OPERATION_NAME,
+ );
+
+ return new Data(
+ $data['data'] ?? [], // @phpstan-ignore argument.type
+ $data['errors'] ?? [] // @phpstan-ignore argument.type
+ );
+ }
+}
+```
+
+### ✨ Beautiful Generated Code
+Uses modern PHP 8.4 features like property hooks for lazy-loading nested objects:
+
+
+```php
+ $this->search ??= new SearchResultItemConnection($this->data['search']);
+ }
+
+ /**
+ * @var list
+ */
+ public readonly array $errors;
+
+ /**
+ * @param array{
+ * 'search': array{
+ * 'nodes': null|list,
+ * },
+ * } $data
+ * @param list $errors
+ */
+ public function __construct(
+ private readonly array $data,
+ array $errors,
+ ) {
+ $this->errors = array_map(fn(array $error) => new Error($error), $errors);
+ }
+}
+```
+
+### 🎭 Full Type Coverage
+- **Object types** → Readonly classes with typed properties
+- **Enums** → PHP 8.1+ backed enums (see example below)
+- **Input types** → Constructor-validated classes
+- **Fragments** → Encapsulated data classes
+- **Unions & interfaces** → Proper type narrowing with inline fragments
+
+
+```php
+
+```php
+twig->render(
+ 'list.html.twig',
+ [
+ 'data' => $this->query->executeOrThrow()->adminProjectList,
+ ],
+ );
+ }
+}
+```
+
+**How it works:**
+
+1. You define the GraphQL query inline with `#[GeneratedGraphQLClient(self::OPERATION)]`
+2. Run the generator—it creates the `ProjectsQuery` class for you
+3. Symfony autowires the query class into your constructor
+4. Commit the generated code—now your CI can verify the query class exists and matches
+
+**No separate `.graphql` files needed**—your GraphQL lives right next to where it's used, but you still get full type safety and validation!
+
+### 🎭 Twig Template Support
+
+Keep your GraphQL fragments next to where they're used in your templates:
+
+
+```twig
+{% types {
+ project: '\\Ruudk\\GraphQLCodeGenerator\\Twig\\Generated\\Fragment\\AdminProjectRow',
+} %}
+
+{% graphql %}
+fragment AdminProjectRow on Project {
+ id
+ name
+ description
+ ...AdminProjectOptions
+}
+{% endgraphql %}
+
+
+ #{{ project.id }} - {{ project.name }}
+ {{ project.description }}
+
+ {{ include('_project_options.html.twig', {project: project.adminProjectOptions}) }}
+
+```
+
+The generator extracts fragments from Twig files and creates type-safe classes. Your templates and GraphQL stay together!
+
+### ⚡ Custom `@indexBy` Directive
+
+Stop searching through arrays—index collections by a field for O(1) lookups:
+
+
+```graphql
+query Test {
+ projects @indexBy(field: "id") {
+ id
+ name
+ }
+ issues @indexBy(field: "id") {
+ id
+ name
+ }
+ customers {
+ edges @indexBy(field: "node.id") {
+ node {
+ id
+ name
+ }
+ }
+ }
+}
+```
+
+**Generated code:**
+
+
+```php
+ $this->customers ??= new CustomerConnection($this->data['customers']);
+ }
+
+ /**
+ * @var array
+ */
+ public array $issues {
+ get => $this->issues ??= array_combine(
+ array_column($this->data['issues'], 'id'),
+ array_map(fn($item) => new Issue($item), $this->data['issues']),
+ );
+ }
+
+ /**
+ * @var array
+ */
+ public array $projects {
+ get => $this->projects ??= array_combine(
+ array_column($this->data['projects'], 'id'),
+ array_map(fn($item) => new Project($item), $this->data['projects']),
+ );
+ }
+
+ /**
+ * @var list
+ */
+ public readonly array $errors;
+
+ /**
+ * @param array{
+ * 'customers': array{
+ * 'edges': list,
+ * },
+ * 'issues': list,
+ * 'projects': list,
+ * } $data
+ * @param list $errors
+ */
+ public function __construct(
+ private readonly array $data,
+ array $errors,
+ ) {
+ $this->errors = array_map(fn(array $error) => new Error($error), $errors);
+ }
+}
+```
+
+## Configuration
+
+The fluent configuration API gives you full control:
+
+```php
+withSchema(__DIR__ . '/schema.graphql') // Local file
+ ->withSchemaIntrospection('https://api.github.com/graphql') // Live endpoint
+
+ // 🔍 Where to find operations
+ ->withOperations(__DIR__ . '/queries') // .graphql files
+ ->withPhpOperations(__DIR__ . '/src') // Inline PHP operations
+ ->withTwigOperations(__DIR__ . '/templates') // Twig templates
+
+ // 📦 Namespace & naming
+ ->withNamespace('App\\Generated\\GitHub')
+
+ // ⚙️ Customization
+ ->withConnectionNaming() // Use Connection/Edge/Node naming
+ ->withAutoFormatting() // Format generated .graphql files
+ ->withCustomScalar('DateTime', 'DateTimeImmutable')
+ ->withCustomScalar('JSON', 'array')
+
+ // 🔌 Client integration
+ ->withClientInterface('App\\GraphQL\\ClientInterface')
+ ->withClientVariable('client');
+```
+
+## Requirements
+
+- **PHP 8.4+** (uses property hooks, readonly classes, and other modern features)
+- **Composer** for installation
+
+## Philosophy
+
+### Why Zero Dependencies?
+
+Most GraphQL clients require runtime libraries to handle deserialization, validation, and type coercion. This adds dependencies, increases bundle size, and creates potential version conflicts.
+
+**This generator takes a different approach:**
+
+1. **Build-time analysis** - Analyzes your GraphQL schema and queries during code generation
+2. **Plain PHP output** - Generates simple classes that work with arrays
+3. **Zero runtime cost** - No libraries to load, no runtime parsing, just direct array access
+
+The result? **Faster code, smaller bundles, and zero external dependencies in production.**
+
+### How Fragments Work
+
+**Named Fragments = Isolated Data Classes**
+
+Named fragments become separate, reusable classes:
+
+
+```graphql
+fragment ProjectView on Project {
+ name
+ description
+ ...ProjectStateView
+}
+```
+
+**Fragment class:**
+
+
+```php
+ $this->description ??= $this->data['description'] !== null ? $this->data['description'] : null;
+ }
+
+ public string $name {
+ get => $this->name ??= $this->data['name'];
+ }
+
+ public ProjectStateView $projectStateView {
+ get => $this->projectStateView ??= new ProjectStateView($this->data);
+ }
+
+ /**
+ * @param array{
+ * 'description': null|string,
+ * 'name': string,
+ * 'state': null|string,
+ * } $data
+ */
+ public function __construct(
+ private readonly array $data,
+ ) {}
+}
+```
+
+**Used in query result:**
+
+
+```php
+ $this->description ??= $this->data['description'] !== null ? $this->data['description'] : null;
+ }
+
+ public string $name {
+ get => $this->name ??= $this->data['name'];
+ }
+
+ public ProjectView $projectView {
+ get => $this->projectView ??= new ProjectView($this->data);
+ }
+
+ /**
+ * @param array{
+ * 'description': null|string,
+ * 'name': string,
+ * 'state': null|string,
+ * } $data
+ */
+ public function __construct(
+ private readonly array $data,
+ ) {}
+}
+```
+
+**Inline Fragments = Type Refinement**
+
+Inline fragments narrow union/interface types:
+
+
+```graphql
+query Search {
+ search(query: "repo:twigstan/twigstan", type: ISSUE, first: 10) {
+ nodes {
+ __typename
+ ... on Issue {
+ number
+ title
+ }
+ ...PullRequestInfo
+ }
+ }
+}
+
+fragment PullRequestInfo on PullRequest {
+ number
+ title
+ merged
+}
+```
+
+Generates type-safe access:
+
+
+```php
+bootEnv(__DIR__ . '/.env.local');
+
+Assert::keyExists($_ENV, 'GITHUB_TOKEN');
+$token = $_ENV['GITHUB_TOKEN'];
+Assert::stringNotEmpty($token);
+
+$client = new GitHubClient(Psr18ClientDiscovery::find(), $token);
+
+dump(new ViewerQuery($client)->execute()->viewer->login);
+
+$data = new SearchQuery($client)->execute();
+
+foreach ($data->search->nodes ?? [] as $node) {
+ if ($node === null) {
+ continue;
+ }
+
+ if ($node->asIssue !== null) {
+ dump(asIssue: $node->asIssue->title);
+ }
+
+ if ($node->pullRequestInfo !== null) {
+ dump(asPullRequest: $node->pullRequestInfo->title . ' is merged: ' . $node->pullRequestInfo->merged);
+ }
+}
+```
+
+### Static Analysis First
+
+**If PHPStan Level 9 can't verify it, we don't generate it.**
+
+Your IDE and static analysis tools catch errors during development—not in production. The generated code is explicit,
+readable, and obvious. No magic, no hidden behavior, just straightforward PHP you can debug and understand.
+
+## Testing & Validation
+
+Run the generator's test suite:
+
+```bash
+vendor/bin/phpunit
+```
+
+### Committing Generated Code
+
+**You should commit the generated code to your repository.** This means your CI/CD pipeline doesn't need to run the generator—the type-safe classes are already there, ready to use.
+
+To ensure the committed code stays in sync with your queries and schema, add this to your CI:
+
+```bash
+vendor/bin/graphql-client-code-generator --ensure-sync
+```
+
+This validates that your committed generated code matches what the generator would produce. If someone updates a query but forgets to regenerate the code, CI catches it immediately.
+
+**The workflow:**
+1. Update your GraphQL queries or schema
+2. Run `vendor/bin/graphql-client-code-generator` to regenerate
+3. Commit both the queries and generated code
+4. CI runs `--ensure-sync` to verify everything matches
+
+## Examples
+
+Check out the `examples/` directory for complete working examples:
+- 🐙 **GitHub API integration** - Real-world queries with fragments
+- 🎯 **Custom scalar handling** - DateTime, JSON, UUID mappings
+- 🧩 **Fragment patterns** - Reusable fragments and composition
+- 🔗 **Connection patterns** - Relay-style pagination
+- ⚠️ **Error handling** - Type-safe GraphQL error handling
+
+## Contributing
+
+Contributions welcome! This project uses:
+- **PHP-CS-Fixer** for code formatting
+- **PHPStan** (level 9!) for static analysis
+- **PHPUnit** for testing
+
+Run the quality checks:
+
+```bash
+vendor/bin/php-cs-fixer fix
+vendor/bin/phpstan
+vendor/bin/phpunit
+```
+
+## 💖 Support This Project
+
+Love this tool? Help me keep building awesome open source software!
+
+[](https://github.com/sponsors/ruudk)
+
+Your sponsorship helps me dedicate more time to maintaining and improving this project. Every contribution, no matter the size, makes a difference!
+
+## 🤝 Contributing
+
+I welcome contributions! Whether it's a bug fix, new feature, or documentation improvement, I'd love to see your PRs.
+
+## 📄 License
+
+MIT License – Free to use in your projects! If you're using this and finding value, please consider [sponsoring](https://github.com/sponsors/ruudk) to support continued development.
+
diff --git a/bin/graphql-client-code-generator b/bin/graphql-client-code-generator
index c531c0c..202a63f 100755
--- a/bin/graphql-client-code-generator
+++ b/bin/graphql-client-code-generator
@@ -32,6 +32,10 @@ if ( ! $autoloaderFound) {
$input = new ArgvInput();
if ($input->getFirstArgument() === null) {
+ /**
+ * @var list $argv
+ */
+ $argv = $_SERVER['argv'] ?? [__FILE__];
$input = new ArgvInput([$argv[0], 'generate', ...array_slice($argv, 1)]);
}
diff --git a/captainhook.json b/captainhook.json
index 0c04b42..874130d 100644
--- a/captainhook.json
+++ b/captainhook.json
@@ -9,6 +9,9 @@
{
"action": "bin/graphql-client-code-generator --config=examples/config.php --config=tests/config.php --ensure-sync"
},
+ {
+ "action": "\\Ruudk\\ReadmeExamplesSyncHook\\SyncReadmeExamples"
+ },
{
"action": "composer validate --strict"
},
diff --git a/composer.json b/composer.json
index 3bc5daa..adfb043 100644
--- a/composer.json
+++ b/composer.json
@@ -34,6 +34,7 @@
"phpunit/phpunit": "^12.3",
"psr/http-client": "^1.0",
"psr/http-message": "^2.0",
+ "ruudk/readme-examples-sync-hook": "^1.0",
"shipmonk/composer-dependency-analyser": "^1.8",
"shipmonk/dead-code-detector": "^0.13.2",
"staabm/phpstan-todo-by": "^0.3.0",
diff --git a/composer.lock b/composer.lock
index c720723..da5d739 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "e357fd1bba34de529f2ac8f9c8f89a9c",
+ "content-hash": "bc708324c8ddb6a5526641cff6068e1e",
"packages": [
{
"name": "nikic/php-parser",
@@ -5516,6 +5516,58 @@
],
"time": "2024-06-11T12:45:25+00:00"
},
+ {
+ "name": "ruudk/readme-examples-sync-hook",
+ "version": "1.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/ruudk/readme-examples-sync-hook.git",
+ "reference": "b2c935a2a6efe37faa96d982207288d345fbfe5e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/ruudk/readme-examples-sync-hook/zipball/b2c935a2a6efe37faa96d982207288d345fbfe5e",
+ "reference": "b2c935a2a6efe37faa96d982207288d345fbfe5e",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.4"
+ },
+ "require-dev": {
+ "captainhook/captainhook": "^5.25",
+ "ergebnis/composer-normalize": "^2.48",
+ "friendsofphp/php-cs-fixer": "^3.85",
+ "phpstan/extension-installer": "^1.4",
+ "phpstan/phpstan": "^2.1",
+ "ticketswap/php-cs-fixer-config": "^1.0",
+ "ticketswap/phpstan-error-formatter": "^1.1"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Ruudk\\ReadmeExamplesSyncHook\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ruud Kamphuis",
+ "email": "ruudk@users.noreply.github.com"
+ }
+ ],
+ "description": "Automatically sync PHP code examples with readme file",
+ "keywords": [
+ "dev"
+ ],
+ "support": {
+ "issues": "https://github.com/ruudk/readme-examples-sync-hook/issues",
+ "source": "https://github.com/ruudk/readme-examples-sync-hook/tree/1.1.1"
+ },
+ "time": "2025-10-17T07:34:33+00:00"
+ },
{
"name": "sebastian/cli-parser",
"version": "4.0.0",