Skip to content

Commit 82039c7

Browse files
committed
GraphQL Fragments in Twig Templates
GraphQL Fragments in Twig Templates Traditional GraphQL queries often become "monster queries" - massive operations where you're afraid to remove any field because it might be used somewhere deep in your template hierarchy. You end up with bloated queries that fetch unnecessary data, making maintenance a nightmare. This new feature solves that problem by letting you co-locate data requirements with your templates. Each Twig template can define exactly what GraphQL data it needs through fragments, ensuring perfect alignment between your views and their data dependencies. How it works Controller: Keep queries simple - just reference the top-level fragment final readonly class SomeController { private const string OPERATION = <<<'GRAPHQL' query Projects { ...AdminProjectList } GRAPHQL; public function __invoke(): string { return $this->twig->render('list.html.twig', [ 'data' => $this->query->executeOrThrow()->adminProjectList, ]); } } Main template (list.html.twig): Define what data this template needs {% graphql %} fragment AdminProjectList on Query { viewer { name } projects { ...AdminProjectRow } } {% endgraphql %} <h1>Good day, {{ data.viewer.name }}</h1> GraphQL Fragments in Twig Templates Traditional GraphQL queries often become "monster queries" - massive operations where you're afraid to remove any field because it might be used somewhere deep in your template hierarchy. You end up with bloated queries that fetch unnecessary data, making maintenance a nightmare. This new feature solves that problem by letting you co-locate data requirements with your templates. Each Twig template can define exactly what GraphQL data it needs through fragments, ensuring perfect alignment between your views and their data dependencies. ## How it works Controller: Keep queries simple - just reference the top-level fragment ```php final readonly class SomeController { private const string OPERATION = <<<'GRAPHQL' query Projects { ...AdminProjectList } GRAPHQL; public function __invoke(): string { return $this->twig->render('list.html.twig', [ 'data' => $this->query->executeOrThrow()->adminProjectList, ]); } } ``` Main template (list.html.twig): Define what data this template needs ```twig {% graphql %} fragment AdminProjectList on Query { viewer { name } projects { ...AdminProjectRow } } {% endgraphql %} <h1>Good day, {{ data.viewer.name }}</h1> {% for project in data.projects %} {{ include('_project_row.html.twig', {project: project.adminProjectRow}) }} {% endfor %} ``` Partial template (_project_row.html.twig): Each partial declares its own requirements ```twig {% graphql %} fragment AdminProjectRow on Project { id name description ...AdminProjectOptions } {% endgraphql %} <li>#{{ project.id }} - {{ project.name }}</li> ``` ## Setup Enable Twig processing: ```php $config->withTwigProcessingDirectory(__DIR__ . '/templates') ``` For Twig CS Fixer: ```php $config->addTokenParser(new GraphQLTokenParser()); ``` For Symfony: ```php $services->set(\Ruudk\GraphQLCodeGenerator\Twig\GraphQLExtension::class)->tag('twig.extension'); ``` Each template owns its data requirements, making refactoring safe and predictable. No more guessing which fields are actually used - the data dependencies are right there with the template that needs them.
1 parent 33589f7 commit 82039c7

30 files changed

Lines changed: 1006 additions & 4 deletions

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ jobs:
5050
- name: Run PHP CS Fixer
5151
run: vendor/bin/php-cs-fixer check --diff
5252

53+
- name: Run Twig CS Fixer
54+
run: vendor/bin/twig-cs-fixer lint
55+
5356
- name: Run PHPStan
5457
run: vendor/bin/phpstan analyse
5558

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
/vendor/
44
/.php-cs-fixer.cache
55
/.phpunit.cache/
6+
/.twig-cs-fixer.cache

.twig-cs-fixer.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
use Ruudk\GraphQLCodeGenerator\Twig\GraphQLTokenParser;
4+
use TwigCsFixer\Ruleset\Ruleset;
5+
use TwigCsFixer\Standard\TwigCsFixer;
6+
use TwigCsFixer\File\Finder;
7+
use TwigCsFixer\Config\Config;
8+
9+
$ruleset = new Ruleset();
10+
11+
$ruleset->addStandard(new TwigCsFixer());
12+
13+
$finder = Finder::create()
14+
->in('tests');
15+
16+
$config = new Config();
17+
$config->allowNonFixableRules();
18+
$config->setCacheFile(__DIR__ . '/.twig-cs-fixer.cache');
19+
$config->setRuleset($ruleset);
20+
$config->setFinder($finder);
21+
$config->addTokenParser(new GraphQLTokenParser());
22+
23+
return $config;
24+

captainhook.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@
2222
"action": "\\CaptainHook\\App\\Hook\\PHP\\Action\\Linting"
2323
},
2424
{
25-
"action": "vendor/bin/php-cs-fixer check --diff"
25+
"action": "vendor/bin/php-cs-fixer fix"
26+
},
27+
{
28+
"action": "vendor/bin/twig-cs-fixer fix"
2629
},
2730
{
2831
"action": "vendor/bin/phpstan"

composer-dependency-analyser.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
declare(strict_types=1);
44

55
use ShipMonk\ComposerDependencyAnalyser\Config\Configuration;
6+
use ShipMonk\ComposerDependencyAnalyser\Config\ErrorType;
67

78
$config = new Configuration();
89

910
$config->addPathToScan(__DIR__ . '/bin', isDev: false);
1011

12+
$config->ignoreErrorsOnPackage('twig/twig', [ErrorType::DEV_DEPENDENCY_IN_PROD]);
13+
1114
return $config;

composer.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"type": "library",
66
"require": {
77
"php": "^8.4",
8+
"composer-runtime-api": "^2.0",
89
"nikic/php-parser": "^5.6",
910
"phpstan/phpstan": "^2.1",
1011
"ruudk/code-generator": "^0.4.3",
@@ -40,7 +41,12 @@
4041
"symfony/dotenv": "^7.3",
4142
"symfony/var-dumper": "^7.3",
4243
"ticketswap/php-cs-fixer-config": "^1.0",
43-
"ticketswap/phpstan-error-formatter": "^1.1"
44+
"ticketswap/phpstan-error-formatter": "^1.1",
45+
"twig/twig": "^3.21",
46+
"vincentlanglet/twig-cs-fixer": "^3.9"
47+
},
48+
"suggest": {
49+
"twig/twig": "For generating GraphQL fragments from Twig templates"
4450
},
4551
"autoload": {
4652
"psr-4": {

composer.lock

Lines changed: 159 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Config/Config.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
* @param list<TypeInitializer\TypeInitializer> $typeInitializers
2222
* @param null|object|(Closure(): object) $introspectionClient
2323
* @param list<string> $inlineProcessingDirectories
24+
* @param list<string> $twigProcessingDirectories
2425
*/
2526
private function __construct(
2627
public Schema | string $schema,
@@ -48,6 +49,7 @@ private function __construct(
4849
public bool $dumpEnumIsMethods = false,
4950
public ?object $introspectionClient = null,
5051
public array $inlineProcessingDirectories = [],
52+
public array $twigProcessingDirectories = [],
5153
public bool $formatOperationFiles = false,
5254
) {}
5355

@@ -187,6 +189,11 @@ public function withInlineProcessingDirectory(string $directory, string ...$dire
187189
return $this->with('inlineProcessingDirectories', [...$this->inlineProcessingDirectories, $directory, ...$directories]);
188190
}
189191

192+
public function withTwigProcessingDirectory(string $directory, string ...$directories) : self
193+
{
194+
return $this->with('twigProcessingDirectories', [...$this->twigProcessingDirectories, $directory, ...$directories]);
195+
}
196+
190197
/**
191198
* Replace with clone with when in PHP 8.5
192199
*/

src/Planner.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
namespace Ruudk\GraphQLCodeGenerator;
66

7+
use Composer\InstalledVersions;
78
use Exception;
89
use GraphQL\Error\InvariantViolation;
910
use GraphQL\Language\AST\DocumentNode;
11+
use GraphQL\Language\AST\FragmentDefinitionNode;
1012
use GraphQL\Language\AST\NodeList;
1113
use GraphQL\Language\AST\OperationDefinitionNode;
1214
use GraphQL\Language\Parser;
@@ -76,6 +78,8 @@
7678
use Ruudk\GraphQLCodeGenerator\Planner\SelectionSetPlanner;
7779
use Ruudk\GraphQLCodeGenerator\Planner\Source\FileSource;
7880
use Ruudk\GraphQLCodeGenerator\Planner\Source\InlineSource;
81+
use Ruudk\GraphQLCodeGenerator\Twig\GraphQLExtension;
82+
use Ruudk\GraphQLCodeGenerator\Twig\GraphQLNodeFinder;
7983
use Ruudk\GraphQLCodeGenerator\Type\TypeHelper;
8084
use Ruudk\GraphQLCodeGenerator\Validator\IndexByValidator;
8185
use Ruudk\GraphQLCodeGenerator\Visitor\DefinedFragmentsVisitor;
@@ -89,6 +93,8 @@
8993
use Symfony\Component\TypeInfo\Type as SymfonyType;
9094
use Symfony\Component\TypeInfo\Type\BackedEnumType;
9195
use Symfony\Component\TypeInfo\TypeIdentifier;
96+
use Twig\Environment;
97+
use Twig\Loader\ArrayLoader;
9298
use Webmozart\Assert\Assert;
9399
use Webmozart\Assert\InvalidArgumentException;
94100

@@ -287,6 +293,8 @@ public function plan() : PlannerResult
287293

288294
Assert::notNull($definition->name, 'Expected operation to have a name');
289295

296+
$usedTypesCollector->analyze($document);
297+
290298
$operationType = $definition->operation;
291299
$operationName = $definition->name->value;
292300

@@ -312,6 +320,36 @@ public function plan() : PlannerResult
312320
}
313321
}
314322

323+
if ($this->config->twigProcessingDirectories !== []) {
324+
Assert::true(InstalledVersions::isInstalled('twig/twig'), 'Twig is required to use twigProcessingDirectories');
325+
326+
$twig = new Environment(new ArrayLoader([]));
327+
$twig->addExtension(new GraphQLExtension());
328+
$nodeFinder = new GraphQLNodeFinder($twig);
329+
330+
$finder = Finder::create()
331+
->files()
332+
->in($this->config->inlineProcessingDirectories)
333+
->name('*.twig')
334+
->contains('{% graphql %}');
335+
336+
foreach ($finder as $file) {
337+
foreach ($nodeFinder->find($file->getContents()) as $operation) {
338+
$document = Parser::parse($operation);
339+
340+
Assert::minCount($document->definitions, 1);
341+
Assert::allIsInstanceOf($document->definitions, FragmentDefinitionNode::class, 'Only fragment definitions are supported in Twig templates');
342+
343+
$usedTypesCollector->analyze($document);
344+
345+
$operations[$file->getPathname()][] = DocumentNodeWithSource::create(
346+
$document,
347+
new FileSource(Path::makeRelative($file->getPathname(), $this->config->projectDir)),
348+
);
349+
}
350+
}
351+
}
352+
315353
$usedTypes = $usedTypesCollector->usedTypes;
316354

317355
// Initialize enum and input types based on usage

src/Twig/GraphQLExtension.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Ruudk\GraphQLCodeGenerator\Twig;
6+
7+
use Override;
8+
use Twig\Extension\AbstractExtension;
9+
10+
final class GraphQLExtension extends AbstractExtension
11+
{
12+
#[Override]
13+
public function getTokenParsers() : array
14+
{
15+
return [
16+
new GraphQLTokenParser(),
17+
];
18+
}
19+
}

0 commit comments

Comments
 (0)