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. +

+

+ Latest Stable Version + PHP Version Require + Total Downloads + License +

+ +------ + +## 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! + +[![Sponsor](https://img.shields.io/badge/Sponsor-%E2%9D%A4-pink)](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",