GraphQL#
๐ฅ Hot Tips
- Auto-generate a full GraphQL API from your ORM classes with a single
fromORM()call - Zero external dependencies: Tina4 ships its own recursive-descent parser and executor
- Register the
/graphqlendpoint in one line - Supports queries, mutations, variables, fragments, and aliases out of the box
Installation#
Require the package via Composer:
composer require tina4stack/tina4php-graphqlThe module requires PHP 8.1 or higher.
Quick Start#
The fastest way to get a working GraphQL API is to point it at an existing ORM class.
// index.php or a bootstrap fileglobal $DBA;$DBA = new \Tina4\DataSQLite3("database/app.db");โ$schema = new \Tina4\GraphQLSchema();$schema->fromORM(User::class);โ\Tina4\GraphQLRoute::register($schema);That single fromORM() call creates:
| Generated | Name | Description |
|---|---|---|
| Type | User | Object type with fields from public properties |
| Query | user(id: ID) | Fetch a single record by ID |
| Query | users(limit: Int, offset: Int) | Paginated list (default limit 10) |
| Mutation | createUser(input: UserInput) | Insert a new record |
| Mutation | updateUser(id: ID!, input: UserInput) | Update an existing record |
| Mutation | deleteUser(id: ID!) | Delete a record, returns Boolean |
You can now POST to /graphql and start querying.
Setting Up the Endpoint {#endpoint}#
GraphQLRoute::register() creates a POST /graphql route that reads php://input, executes the query, and returns JSON.
// Default path\Tina4\GraphQLRoute::register($schema);โ// Custom path\Tina4\GraphQLRoute::register($schema, "/api/graphql");If you need more control (authentication, custom context, etc.), wire the route yourself:
\Tina4\Post::add("/graphql", function (\Tina4\Response $response, \Tina4\Request $request) use ($schema) { $requestBody = file_get_contents('php://input');โ // Pass a context object to all resolvers $context = ['user' => $request->session['user'] ?? null];โ $graphql = new \Tina4\GraphQL($schema); $result = $graphql->handleRequest($requestBody, $context);โ return $response($result, HTTP_OK, APPLICATION_JSON);});Defining a Schema Manually {#manual-schema}#
When auto-generation does not fit your needs, build the schema by hand.
Register a type#
$schema = new \Tina4\GraphQLSchema();โ// Define an OBJECT type with named fields$productType = new \Tina4\GraphQLType('Product', 'OBJECT', [ 'id' => ['type' => 'ID'], 'name' => ['type' => 'String'], 'price' => ['type' => 'Float'], 'inStock' => ['type' => 'Boolean'],]);โ$schema->addType($productType);Register a query#
$schema->addQuery('product', [ 'type' => 'Product', 'args' => [ 'id' => ['type' => 'ID'], ], 'resolve' => function ($root, $args, $context) { // Return an associative array matching the type fields return [ 'id' => $args['id'], 'name' => 'Widget', 'price' => 9.99, 'inStock' => true, ]; },]);Register a mutation#
$schema->addMutation('updateProductPrice', [ 'type' => 'Product', 'args' => [ 'id' => ['type' => 'ID!'], 'price' => ['type' => 'Float!'], ], 'resolve' => function ($root, $args, $context) { // your update logic here return [ 'id' => $args['id'], 'name' => 'Widget', 'price' => $args['price'], 'inStock' => true, ]; },]);Auto-Generating Schema from ORM {#from-orm}#
This is the killer feature. Given a Tina4 ORM class, fromORM() introspects its public properties, maps PHP types to GraphQL scalars, and registers a full set of CRUD queries and mutations automatically.
How it works#
- Public properties on the ORM class become fields on the GraphQL type.
- PHP type hints (
int,float,bool,string) are mapped toInt,Float,Boolean,String. Untyped properties default toString. - ORM meta-properties (
$tableName,$primaryKey,$fieldMapping, etc.) are excluded automatically. - The class short name is used as the type name. The lower-camelCase form is used for query names.
Example ORM class#
class Product extends \Tina4\ORM{ public $tableName = 'product'; public int $id; public string $name; public float $price; public int $categoryId;}Register it#
$schema = new \Tina4\GraphQLSchema();$schema->fromORM(Product::class);$schema->fromORM(Category::class);โ\Tina4\GraphQLRoute::register($schema);You can call fromORM() multiple times to register as many ORM classes as you like on the same schema.
Type mapping reference#
| PHP Type | GraphQL Type |
|---|---|
int, integer | Int |
float, double | Float |
bool, boolean | Boolean |
string | String |
| (no type hint) | String |
Writing Queries {#queries}#
Send a JSON POST request to your endpoint:
{ "query": "{ users(limit: 5) { id name price } }"}Named query#
query GetProduct($productId: ID) { product(id: $productId) { id name price }}With variables:
{ "query": "query GetProduct($productId: ID) { product(id: $productId) { id name price } }", "variables": { "productId": "42" }}Aliases#
Request the same field multiple times with different arguments using aliases:
{ first: product(id: "1") { name price } second: product(id: "2") { name price }}Writing Mutations {#mutations}#
Mutations follow the same POST format. Use the mutation keyword:
mutation { createProduct(input: { name: "Gadget", price: 19.99, categoryId: 3 }) { id name }}Update:
mutation { updateProduct(id: "42", input: { price: 24.99 }) { id price }}Delete:
mutation { deleteProduct(id: "42")}Resolvers {#resolvers}#
Every query and mutation field needs a resolve callable. The signature is:
function ($root, array $args, mixed $context): mixed| Parameter | Description |
|---|---|
$root | The parent value. null for root-level fields. |
$args | Arguments passed in the query, with variables already resolved. |
$context | Whatever you passed as context when calling execute() or handleRequest(). |
For nested fields, the executor resolves automatically by reading array keys or object properties from the parent value. You only need explicit resolvers for root-level queries and mutations.
$schema->addQuery('topProducts', [ 'type' => '[Product]', 'args' => ['limit' => ['type' => 'Int']], 'resolve' => function ($root, $args, $context) { $limit = $args['limit'] ?? 5; return (new Product()) ->select("*") ->orderBy("sales desc") ->asArray(); },]);Variables and Fragments {#variables-fragments}#
Variables#
Declare variables in the operation definition and pass their values in the variables JSON field:
query ListProducts($limit: Int, $offset: Int) { products(limit: $limit, offset: $offset) { id name }}{ "query": "query ListProducts($limit: Int, $offset: Int) { products(limit: $limit, offset: $offset) { id name } }", "variables": { "limit": 10, "offset": 20 }}Fragments#
Fragments let you reuse field selections across queries:
fragment ProductFields on Product { id name price}โquery { product(id: "1") { ...ProductFields } products(limit: 3) { ...ProductFields }}GraphQL Type System {#types}#
The GraphQLType class supports scalars, objects, lists, and non-null wrappers.
Built-in scalars#
String, Int, Float, Boolean, ID
Creating types programmatically#
// Scalar$idType = \Tina4\GraphQLType::scalar('ID');โ// Object$type = new \Tina4\GraphQLType('Category', 'OBJECT', [ 'id' => ['type' => 'ID'], 'name' => ['type' => 'String'],]);โ// List wrapper$listType = \Tina4\GraphQLType::listOf($type);โ// Non-null wrapper$required = \Tina4\GraphQLType::nonNull($idType);Using type notation in definitions#
When registering queries and mutations, types are referenced by string notation:
| Notation | Meaning |
|---|---|
String | Nullable string |
String! | Non-null string |
[Product] | Nullable list of products |
[Product!]! | Non-null list of non-null products |
Programmatic Usage {#programmatic}#
You do not have to use the HTTP endpoint. The GraphQL class can be called directly in your code:
$schema = new \Tina4\GraphQLSchema();// ... register types, queries, mutations ...โ$graphql = new \Tina4\GraphQL($schema);$result = $graphql->execute('{ users(limit: 3) { id name } }', [], $context);โ// $result = ['data' => ['users' => [...]]]Pass variables as the second argument:
$result = $graphql->execute( 'query ($id: ID) { user(id: $id) { id name } }', ['id' => '42']);Error Handling {#errors}#
Errors are returned in the standard GraphQL errors array alongside data:
{ "data": { "user": null }, "errors": [ { "message": "Field 'user' not found in query", "path": ["user"] } ]}If the query itself fails to parse, the response will have data: null and the parser error in errors.
Full Working Example {#full-example}#
// ORM classesclass Category extends \Tina4\ORM{ public $tableName = 'category'; public int $id; public string $name;}โclass Product extends \Tina4\ORM{ public $tableName = 'product'; public int $id; public string $name; public float $price; public int $categoryId;}โ// Bootstrapglobal $DBA;$DBA = new \Tina4\DataSQLite3("database/shop.db");โ$schema = new \Tina4\GraphQLSchema();$schema->fromORM(Category::class);$schema->fromORM(Product::class);โ// Add a custom query alongside the auto-generated ones$schema->addQuery('cheapProducts', [ 'type' => '[Product]', 'args' => ['maxPrice' => ['type' => 'Float']], 'resolve' => function ($root, $args) { $max = $args['maxPrice'] ?? 10.0; return (new Product()) ->select("*") ->where("price <= ?", [$max]) ->asArray(); },]);โ\Tina4\GraphQLRoute::register($schema);Now you can query:
{ # Auto-generated categories(limit: 100) { id name }โ # Auto-generated product(id: "7") { id name price }โ # Custom cheapProducts(maxPrice: 5.00) { id name price }}Further Reading#
- ORM - the ORM classes that power
fromORM() - Basic Routing - how Tina4 routing works under the hood
- REST API - for traditional REST-style endpoints