Creare un'API GraphQL con Laravel

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 esempio id, 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.

Torna su