thecodingmachine/graphql-controllers
A PHP package for defining GraphQL APIs with controller classes: annotate methods to resolve queries, mutations, and fields, then wire them into a webonyx/graphql-php schema with dependency injection.
This package is the annotation/attribute-driven controller layer on top of webonyx/graphql-php: instead of hand-writing ResolveFns and a schema array, you write plain PHP classes whose methods are tagged @Query / @Mutation, whose return types are tagged @Type, and let a SchemaFactory reflect over them to produce a GraphQL\Schema.
Minimal setup:
composer require thecodingmachine/graphql-controllers webonyx/graphql-php doctrine/annotations
use TheCodingMachine\GraphQL\Controllers\Annotations\{Query, Mutation, Type, Field};
/** @Type */
class Product
{
/** @Field */
public string $name;
/** @Field */
public float $price;
}
class ProductController
{
public function __construct(private ProductRepository $repo) {}
/** @Query */
public function product(string $id): ?Product
{
return $this->repo->find($id);
}
/** @Mutation */
public function createProduct(string $name, float $price): Product
{
return $this->repo->create($name, $price);
}
}
$reader = new \Doctrine\Common\Annotations\AnnotationReader();
$factory = new \TheCodingMachine\GraphQL\Controllers\SchemaFactory($container, $reader);
$schema = $factory->createSchema([ProductController::class]);
$result = \GraphQL\GraphQL::executeQuery($schema, $query, null, null, $variables);
Where to look first
src/Annotations/ — the whole DSL (@Query, @Mutation, @Type, @Field, @InputType, @Inject, @SourceField, @UseInputType). Read these before anything else.SchemaFactory — the wiring point; understand its constructor dependencies (PSR‑11 container + Doctrine reader + optional type mapper).tests/ directory — the fastest reference for edge cases (unions, interfaces, enums, inheritance) because the README is thinner than you'd like.RecursiveTypeMapper — the component that decides how PHP types become GraphQL types. Most surprises live here.First real use case: wrap one existing repository/service in a controller, expose a single product(id: ID!): Product query plus one mutation, and hit it with GraphiQL or an integration test. That forces you through all four concepts (controller, output type, input mapping, container wiring) in ~30 lines.
Controllers are the unit of organization, not resolvers. Group methods by domain (ProductController, OrderController), not by GraphQL root type. All methods on all registered controllers are merged into the single root Query/Mutation type, so collisions are a real risk — namespace method names (product, order) and use @Query(name="adminProduct") when you need a different public name.
Services via @Inject, arguments via parameters. Every public parameter becomes a GraphQL argument unless tagged @Inject, in which case it is resolved from the PSR‑11 container and hidden from the schema:
/**
* @Query
* @Inject({"currentUser"})
*/
public function myOrders(User $currentUser, int $limit = 20): array { ... }
This is the single most useful pattern day-to-day: keep auth/context objects out of the public API surface.
Output types: prefer @Type classes over arrays. Arrays-of-arrays work, but you lose field selection, nullability, and IDE support. Use docblocks for collections — @return Product[] or array<int, Product> — because reflection alone cannot infer element types. ?Product gives a nullable field; Product gives non-null and turns a null return into a GraphQL error.
Input types for mutations. Create a DTO annotated @InputType, give it public @Field properties, and type-hint it in the mutation. This beats long parameter lists and keeps mutations refactorable. Input DTOs are validated only by GraphQL's own type checks — business validation still belongs in your domain layer.
Enums and value objects. PHP enums (or myclabs/php-enum in older versions) map to GraphQL enums via EnumType. Custom scalars need an explicit ScalarType registration through the type mapper; don't expect magic.
Interfaces, unions, inheritance. Annotate the concrete classes with @Type, implement the GraphQL interface in PHP, and make sure resolveType can distinguish them. In this package that usually means your interface maps by class name — if you need discriminator fields, that logic is on you. Test this explicitly; it's the weakest-documented area.
Root fields beyond controllers. For schema generated from config, or fields backed by closures, use the QueryProvider interface. It's the escape hatch when controllers don't fit — use it sparingly, because you lose the "one obvious place to look" benefit.
Testing pattern. Build the schema once per test class in setUp, then run string queries through GraphQL::executeQuery and assert on toArray(). This catches annotation typos, wrong nullability, and argument-name drift far earlier than clicking around GraphiQL. Add one "schema snapshot" test using SchemaPrinter to make accidental public-API changes show up in diffs.
Production wiring. Construct SchemaFactory once in your DI container and reuse the resulting Schema for the whole process. Do not rebuild per request.
You are probably looking at a predecessor. thecodingmachine/graphql-controllers was superseded by thecodingmachine/graphqlite, which has the framework bundles, guards, pagination, and active maintenance. The annotation names and semantics are largely compatible but not identical, and namespace paths (TheCodingMachine\GraphQLite\Annotations\…) differ. Pin your version and treat the README as authoritative for that tag; upgrading later is a real migration, not a composer update. For greenfield work, seriously consider graphqlite instead.
Annotation reader cost. Reflection plus Doctrine annotations is slow. Use Doctrine\Common\Annotations\PsrCachedReader (or the attributes path) in non-dev environments, and pre-generate/cache the Schema. Cold-start schema build is the number-one performance complaint.
PHP 8 attributes vs docblocks. Later releases support #[Query], #[Field], etc. Mixing the two styles in one codebase leads to fields that silently don't exist. Pick one and lint for it.
Nullability bites both ways. A non-null field returning null produces a runtime GraphQL error that bubbles up and can null out the parent — very confusing the first time. Conversely, ?Product is nullable even if your database says otherwise. Be deliberate, and use @Field(name="…", for=…) rather than relying on inference when it matters.
Arrays without docblocks become generic lists or scalars. public function products(): array will not infer Product. Always annotate @return Product[]. Same for nested arrays: array<string, Product[]>.
Only GraphQLException-derived exceptions expose messages. By default, other throwables become "Internal server error". Wrap domain failures in the package's bad-user-input exception, or you'll spend an afternoon wondering why validation messages vanish. Enable DebugFlag::INCLUDE_DEBUG_MESSAGE in dev only.
@Inject requires a working container for controllers too. Controllers themselves are resolved from the container; if a controller isn't registered (or autowirable), you get an opaque "cannot instantiate" during schema build. Register each controller class explicitly in Symfony/Laravel service config.
No DataLoader or batching. Resolving order.customer for a list of 100 orders is 100 queries. Use webonyx/graphql-php's Deferred/promise support or preload in the controller. This is the classic N+1 trap for anyone coming from Eloquent-style eager loading.
Name collisions are silent-ish. Two controllers with a product() method produce an error at schema build, not at runtime — good — but the message is about the root type, not your controller. Grep for duplicate method names before debugging the factory.
Extension points if you outgrow the DSL: the TypeMapperInterface (custom PHP→GraphQL mapping) and RootTypeGenerator/InputTypeGenerator (custom root construction). Also, if annotations feel too magic, you can subclass SchemaFactory and inject your own mapper rather than forking.
Debugging aid that pays for itself: dump SchemaPrinter::doPrint($schema) into a file in CI. Half the "why is this field missing" questions are answered by reading the actual generated SDL.
How can I help you explore Laravel packages today?