Questo articolo illustra un percorso pratico e ragionato per esporre un singolo model tramite GraphQL in Laravel. L'obiettivo è mantenere il perimetro ridotto e comprensibile: un solo modello, un insieme essenziale di tipi e operazioni (Query e Mutation), paginazione, ordinamento, validazione e autorizzazione. Il risultato è una base solida, estendibile ad altri modelli man mano che il progetto cresce.
Perché GraphQL e perché partire da un singolo model
GraphQL consente ai client di domandare esattamente i campi che servono, riducendo il sovra-trasporto tipico di alcuni endpoint REST. Iniziare con un singolo model come Post
è un esercizio ottimo per chiarire i concetti fondamentali: definizione dei tipi, risoluzione dei campi, filtraggio, paginazione e sicurezza.
Prerequisiti
- Un progetto Laravel funzionante e Composer configurato.
- Un model
Post
con alcune colonne rappresentative (ad esempioid
,title
,body
,published_at
,created_at
,updated_at
). - Il pacchetto GraphQL per Laravel che consenta di mappare tipi, query e mutation su classi PHP ben strutturate.
- Conoscenza dei fondamenti di GraphQL (si consulti questa guida).
Il flusso generale è: installazione del pacchetto, registrazione dei tipi e dello schema, creazione del Type relativo al model, definizione di query (elenco e dettaglio) e mutation (creazione, aggiornamento, eliminazione), e infine rifiniture su paginazione, validazione e autorizzazione.
Installazione e configurazione essenziale
Si parte installando il pacchetto e pubblicando la configurazione. In seguito si registrano schema, types, queries e mutations in config/graphql.php
. Uno schema minimale con soli elementi legati a Post
è spesso sufficiente per partire e mantenere la superficie dell'API chiara e testabile.
Per prima cosa installiamo il pacchetto e pubblichiamo la sua configurazione:
composer require rebing/graphql-laravel
php artisan vendor:publish --provider="Rebing\GraphQL\GraphQLServiceProvider"
Quindi modifichiamo la configurazione come segue:
<?php
// config/graphql.php (estratto esemplificativo)
return [
'route' => [
'prefix' => 'graphql',
'controller' => \Rebing\GraphQL\GraphQLController::class.'@query',
'middleware' => ['api'],
],
'default_schema' => 'default',
'schemas' => [
'default' => [
'query' => [
'posts' => App\GraphQL\Queries\PostsQuery::class,
'post' => App\GraphQL\Queries\PostQuery::class,
],
'mutation' => [
'createPost' => App\GraphQL\Mutations\CreatePostMutation::class,
'updatePost' => App\GraphQL\Mutations\UpdatePostMutation::class,
'deletePost' => App\GraphQL\Mutations\DeletePostMutation::class,
],
'method' => ['GET','POST','OPTIONS'],
],
],
'types' => [
'Post' => App\GraphQL\Types\PostType::class,
'PaginatorInfo' => App\GraphQL\Types\PaginatorInfoType::class,
'PostPaginator' => App\GraphQL\Types\PostPaginatorType::class,
],
];
Opzionale: in config/cors.php
aggiungiamo graphql
in paths
se intendiamo usare l'endpoint /graphql
al di fuori dello dominio principale su cui è installato Laravel.
Definire il Type che espone il model Post
Il Type è la proiezione GraphQL del model. È consigliabile esporre soltanto i campi realmente utili ai consumer, rinviando altri campi a sviluppi successivi per contenere il debito tecnico.
<?php
namespace App\GraphQL\Types;
use App\Models\Post;
use Rebing\GraphQL\Support\Type as GraphQLType;
use GraphQL\Type\Definition\Type;
class PostType extends GraphQLType
{
protected $attributes = [
'name' => 'Post',
'model' => Post::class,
];
public function fields(): array
{
return [
'id' => ['type' => Type::nonNull(Type::id())],
'title' => ['type' => Type::nonNull(Type::string())],
'body' => ['type' => Type::nonNull(Type::string())],
'published_at' => ['type' => Type::string()],
'created_at' => ['type' => Type::nonNull(Type::string())],
'updated_at' => ['type' => Type::nonNull(Type::string())],
];
}
}
Query di elenco con ordinamento e paginazione
Un elenco è utile solo se combinato con capacità di ordinare e sfogliare i risultati. Un approccio comune è ordinare per published_at
e accettare page
e perPage
come argomenti. La paginazione “page-based” è immediata da usare e ben supportata; quando serviranno carichi elevati o infinite scrolling, si potrà valutare la paginazione a cursori.
<?php
namespace App\GraphQL\Queries;
use App\Models\Post;
use Rebing\GraphQL\Support\Query;
use GraphQL\Type\Definition\Type;
use Rebing\GraphQL\Support\Facades\GraphQL;
use Rebing\GraphQL\Support\SelectFields;
use GraphQL\Type\Definition\ResolveInfo;
class PostsQuery extends Query
{
protected $attributes = [
'name' => 'posts',
'description' => 'Elenco paginato dei post',
];
public function type(): Type
{
return GraphQL::type('PostPaginator');
}
public function args(): array
{
return [
'page' => ['type' => Type::int(), 'defaultValue' => 1],
'perPage' => ['type' => Type::int(), 'defaultValue' => 10],
'sort' => ['type' => Type::string(), 'defaultValue' => 'desc'], // asc|desc
];
}
public function resolve($root, $args, $context, ResolveInfo $info, SelectFields $fields)
{
$select = $fields->getSelect() ?: ['*'];
$query = Post::query()
->orderBy('published_at', strtolower($args['sort']) === 'asc' ? 'asc' : 'desc');
$paginator = $query->paginate(
$args['perPage'],
$select,
'page',
$args['page']
);
return [
'data' => $paginator->items(),
'paginatorInfo' => [
'count' => $paginator->count(),
'currentPage' => $paginator->currentPage(),
'lastPage' => $paginator->lastPage(),
'perPage' => $paginator->perPage(),
'total' => $paginator->total(),
'hasMorePages' => $paginator->hasMorePages(),
],
];
}
}
Per rappresentare la risposta paginata servono due tipi d'appoggio: uno per i metadati e uno per l'involucro che contiene lista e metadati.
<?php
// app/GraphQL/Types/PaginatorInfoType.php
namespace App\GraphQL\Types;
use Rebing\GraphQL\Support\Type as GraphQLType;
use GraphQL\Type\Definition\Type;
class PaginatorInfoType extends GraphQLType
{
protected $attributes = ['name' => 'PaginatorInfo'];
public function fields(): array
{
return [
'count' => ['type' => Type::nonNull(Type::int())],
'currentPage' => ['type' => Type::nonNull(Type::int())],
'lastPage' => ['type' => Type::nonNull(Type::int())],
'perPage' => ['type' => Type::nonNull(Type::int())],
'total' => ['type' => Type::nonNull(Type::int())],
'hasMorePages' => ['type' => Type::nonNull(Type::boolean())],
];
}
}
<?php
// app/GraphQL/Types/PostPaginatorType.php
namespace App\GraphQL\Types;
use Rebing\GraphQL\Support\Type as GraphQLType;
use GraphQL\Type\Definition\Type;
use Rebing\GraphQL\Support\Facades\GraphQL;
class PostPaginatorType extends GraphQLType
{
protected $attributes = ['name' => 'PostPaginator'];
public function fields(): array
{
return [
'data' => ['type' => Type::listOf(GraphQL::type('Post'))],
'paginatorInfo' => ['type' => GraphQL::type('PaginatorInfo')],
];
}
}
Query di dettaglio
La query per ottenere un singolo post è diretta: accetta un id
e restituisce il Post
corrispondente o un errore coerente con le eccezioni di Laravel.
<?php
namespace App\GraphQL\Queries;
use App\Models\Post;
use Rebing\GraphQL\Support\Query;
use GraphQL\Type\Definition\Type;
use Rebing\GraphQL\Support\Facades\GraphQL;
class PostQuery extends Query
{
protected $attributes = ['name' => 'post'];
public function type(): Type
{
return GraphQL::type('Post');
}
public function args(): array
{
return ['id' => ['type' => Type::nonNull(Type::id())]];
}
public function resolve($root, $args)
{
return Post::findOrFail($args['id']);
}
}
Mutation: creare, aggiornare, eliminare
Le mutation implementano la logica di scrittura. È buona prassi includere regole di validazione, la verifica dell'autorizzazione e restituire oggetti omogenei (ad esempio il Post
appena creato o aggiornato). L'eliminazione può restituire un boolean semplice oppure un oggetto “payload” più ricco; per iniziare, il boolean è spesso sufficiente.
<?php
// app/GraphQL/Mutations/CreatePostMutation.php
namespace App\GraphQL\Mutations;
use App\Models\Post;
use Rebing\GraphQL\Support\Mutation;
use GraphQL\Type\Definition\Type;
use Rebing\GraphQL\Support\Facades\GraphQL;
use Closure;
use GraphQL\Type\Definition\ResolveInfo;
class CreatePostMutation extends Mutation
{
protected $attributes = ['name' => 'createPost'];
public function type(): Type
{
return GraphQL::type('Post');
}
public function args(): array
{
return [
'title' => ['type' => Type::nonNull(Type::string())],
'body' => ['type' => Type::nonNull(Type::string())],
'published_at' => ['type' => Type::string()],
];
}
public function rules(array $args = []): array
{
return [
'title' => ['required','string','max:255'],
'body' => ['required','string'],
'published_at' => ['nullable','date'],
];
}
public function authorize($root, array $args, $ctx, ?ResolveInfo $info = null, ?Closure $getSelect = null): bool
{
return auth()->check();
}
public function resolve($root, $args)
{
return Post::create([
'title' => $args['title'],
'body' => $args['body'],
'published_at' => $args['published_at'] ?? null,
]);
}
}
<?php
// app/GraphQL/Mutations/UpdatePostMutation.php
namespace App\GraphQL\Mutations;
use App\Models\Post;
use Rebing\GraphQL\Support\Mutation;
use GraphQL\Type\Definition\Type;
use Rebing\GraphQL\Support\Facades\GraphQL;
use Closure;
use GraphQL\Type\Definition\ResolveInfo;
class UpdatePostMutation extends Mutation
{
protected $attributes = ['name' => 'updatePost'];
public function type(): Type
{
return GraphQL::type('Post');
}
public function args(): array
{
return [
'id' => ['type' => Type::nonNull(Type::id())],
'title' => ['type' => Type::string()],
'body' => ['type' => Type::string()],
'published_at' => ['type' => Type::string()],
];
}
public function rules(array $args = []): array
{
return [
'id' => ['required','exists:posts,id'],
'title' => ['sometimes','string','max:255'],
'body' => ['sometimes','string'],
'published_at' => ['sometimes','nullable','date'],
];
}
public function authorize($root, array $args, $ctx, ?ResolveInfo $info = null, ?Closure $getSelect = null): bool
{
return auth()->check();
}
public function resolve($root, $args)
{
$post = Post::findOrFail($args['id']);
$post->fill(array_filter([
'title' => $args['title'] ?? null,
'body' => $args['body'] ?? null,
'published_at' => $args['published_at'] ?? null,
], fn($v) => !is_null($v)));
$post->save();
return $post;
}
}
<?php
// app/GraphQL/Mutations/DeletePostMutation.php
namespace App\GraphQL\Mutations;
use App\Models\Post;
use Rebing\GraphQL\Support\Mutation;
use GraphQL\Type\Definition\Type;
use Closure;
use GraphQL\Type\Definition\ResolveInfo;
class DeletePostMutation extends Mutation
{
protected $attributes = ['name' => 'deletePost'];
public function type(): Type
{
return Type::nonNull(Type::boolean());
}
public function args(): array
{
return ['id' => ['type' => Type::nonNull(Type::id())]];
}
public function rules(array $args = []): array
{
return ['id' => ['required','exists:posts,id']];
}
public function authorize($root, array $args, $ctx, ?ResolveInfo $info = null, ?Closure $getSelect = null): bool
{
return auth()->check();
}
public function resolve($root, $args)
{
$post = Post::findOrFail($args['id']);
return (bool) $post->delete();
}
}
Query e mutation dal punto di vista del client
Un client GraphQL può interrogare l'elenco paginato, chiedendo solo i campi necessari e i metadati indispensabili alla navigazione. Le mutation accettano input espliciti, restituendo l'entità interessata o un flag booleante per le operazioni distruttive.
query {
posts(page: 1, perPage: 5, sort: "desc") {
data { id title published_at }
paginatorInfo { currentPage lastPage perPage total hasMorePages count }
}
}
query {
post(id: 1) { id title body published_at }
}
mutation {
createPost(title: "Titolo", body: "Testo", published_at: "2025-01-01T00:00:00Z") {
id title published_at
}
}
mutation {
updatePost(id: 1, title: "Nuovo titolo") {
id title updated_at
}
}
mutation DeletePost($id: ID!) {
deletePost(id: $id)
}
Autorizzazione, validazione e gestione degli errori
Le classi di query e mutation consentono di esprimere la logica di autorizzazione a livello di campo attraverso un metodo dedicato; così si tengono ben separati i controlli di accesso. Le regole di validazione, espresse con lo stesso linguaggio delle FormRequest
, evitano che dati incompleti o scorretti raggiungano il layer di persistenza. È utile anche definire una politica di eccezioni coerente: errori di validazione devono essere chiari, mentre errori inattesi vanno tracciati e minimizzati verso i client.
Paginazione: considerazioni di design
La paginazione basata su numerazione di pagine è facile da usare e testare. Quando i dataset aumentano e l'esperienza utente richiede flussi continui, è possibile introdurre una paginazione a cursori, stabilendo un ordinamento stabile (ad esempio published_at
combinato con id
) e codificando cursori opachi. La struttura a cursori richiede qualche tipo aggiuntivo, ma migliora la resilienza rispetto a inserimenti o cancellazioni frequenti.
Performance e sicurezza
- Selezione dei campi: lascia che siano i client a decidere i campi, ma assicurati che il resolver selezioni solo le colonne realmente richieste.
- Limiti di complessità: imposta limiti ragionevoli di profondità e complessità per evitare query costose.
- Cache mirata: valuta la cache delle liste più richieste, invalidandola con eventi in seguito a creazioni o aggiornamenti.
- Validazione input: regole chiare nei resolver di mutation riducono vulnerabilità e garantiscono dati uniformi.
Conclusione
Partire da un solo model come Post
è un modo efficace per comprendere come progettare un'API GraphQL in Laravel 12: si definisce un Type pulito, si offrono query essenziali con ordinamento e paginazione intuitivi, e mutation che rispettano validazione e autorizzazione. Da qui, l'estensione ad altri modelli segue naturalmente lo stesso schema: tipi ben definiti, resolver sobri e una superficie GraphQL pensata per richieste espressive e prevedibili.