API REST in Laravel: Eloquent e persistenza

API REST in Laravel: Eloquent e persistenza

Finora i nostri libri vivevano in un array in memoria, destinato a svanire al termine di ogni richiesta. In questa puntata diamo loro una casa stabile: il database. Definiremo la struttura della tabella con una migrazione, creeremo il modello Eloquent Book e riscriveremo il controller sfruttando l'ORM. Attiveremo inoltre il route model binding, che elimina il codice ripetitivo di ricerca, e completeremo finalmente tutte le operazioni CRUD, aggiornamento ed eliminazione comprese. Alla fine popoleremo il database con dati di prova tramite factory e seeder.

Configurare la connessione al database

Un'installazione recente di Laravel usa SQLite come database predefinito, il che è comodissimo per svilupppare senza dover installare un server. La configurazione vive nel file .env alla radice del progetto.

DB_CONNECTION=sqlite

Con SQLite non serve altro: il file del database, database/database.sqlite, viene creato automaticamente. Se preferisci MySQL o PostgreSQL, imposta invece le relative variabili di connessione.

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=catalogo_libri
DB_USERNAME=root
DB_PASSWORD=segreto

Il resto dell'articolo è indipendente dal motore scelto: è proprio uno dei vantaggi di Eloquent, che astrae le differenze fra i database relazionali.

Creare la migrazione e il modello

Una migrazione è una descrizione versionata della struttura del database, scritta in PHP. Possiamo generare il modello e la relativa migrazione con un unico comando, usando l'opzione -m.

php artisan make:model Book -m

Il comando crea due file: il modello app/Models/Book.php e una migrazione in database/migrations/. Apriamo la migrazione e definiamo le colonne della tabella books.

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('books', function (Blueprint $table) {
            $table->id();                          // chiave primaria auto-incrementante
            $table->string('title');
            $table->string('author');
            $table->unsignedSmallInteger('year')->nullable();
            $table->string('isbn')->unique()->nullable();
            $table->timestamps();                  // colonne created_at e updated_at
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('books');
    }
};

Il metodo id() crea una colonna intera auto-incrementante come chiave primaria. Il metodo timestamps() aggiunge le due colonne created_at e updated_at, che Eloquent aggiorna in automatico. Il vincolo unique sull'ISBN garantisce a livello di database che non esistano due libri con lo stesso codice.

Applichiamo la migrazione al database.

php artisan migrate

La tabella books è ora creata. Se in futuro modificheremo la struttura, genereremo una nuova migrazione anziché modificare quella già eseguita: la storia delle modifiche resta così tracciabile e riproducibile su ogni ambiente.

Il modello Eloquent

Apriamo app/Models/Book.php e configuriamolo. Due aspetti sono cruciali per un'API: quali attributi possono essere assegnati in massa e come vanno convertiti i tipi.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Book extends Model
{
    // Attributi assegnabili in massa tramite create() e update().
    // Tutto ciò che non è elencato qui è protetto dal mass assignment.
    protected $fillable = [
        'title',
        'author',
        'year',
        'isbn',
    ];

    // Conversione automatica dei tipi in lettura e scrittura
    protected function casts(): array
    {
        return [
            'year' => 'integer',
        ];
    }
}

La proprietà $fillable è una difesa di sicurezza fondamentale. Quando creiamo un libro passando direttamente i dati della richiesta, Eloquent accetta soltanto gli attributi elencati qui, ignorando qualsiasi campo non previsto che un client malevolo potrebbe iniettare. Questo meccanismo, detto mass assignment protection, evita che, per esempio, qualcuno riesca a impostare a piacere un campo sensibile inviandolo nel corpo della richiesta.

Riscrivere il controller con Eloquent

Ora possiamo abbandonare l'archivio in memoria e usare il modello. Riscriviamo l'intero BookController, completando anche i metodi lasciati in sospeso nella puntata precedente. La differenza più vistosa è nella firma dei metodi che operano su un singolo libro: dichiariamo un parametro di tipo Book e lasciamo che sia Laravel a caricarlo.

<?php

namespace App\Http\Controllers;

use App\Models\Book;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class BookController extends Controller
{
    // Elenco dei libri, dal più recente
    public function index(): JsonResponse
    {
        $books = Book::query()->latest()->get();

        return response()->json($books);
    }

    // Creazione di un nuovo libro
    public function store(Request $request): JsonResponse
    {
        $book = Book::create($request->only(['title', 'author', 'year', 'isbn']));

        return response()->json($book, 201);
    }

    // Dettaglio: il libro è già caricato grazie al route model binding
    public function show(Book $book): JsonResponse
    {
        return response()->json($book);
    }

    // Aggiornamento di un libro esistente
    public function update(Request $request, Book $book): JsonResponse
    {
        $book->update($request->only(['title', 'author', 'year', 'isbn']));

        return response()->json($book);
    }

    // Eliminazione di un libro
    public function destroy(Book $book): JsonResponse
    {
        $book->delete();

        // 204 No Content: operazione riuscita, nessun corpo nella risposta
        return response()->json(status: 204);
    }
}

Confrontando con la versione della puntata precedente, la quantità di codice è calata e la leggibilità è aumentata. Nessuna ricerca manuale, nessun controllo esplicito sull'esistenza del libro: tutto questo è ora responsabilità del framework.

Come funziona il route model binding

Nella puntata precedente il parametro dell'URL si chiamava {book}. Ora che il metodo show dichiara un parametro Book $book con lo stesso nome, Laravel collega i due elementi: prende il valore presente nell'URL, cerca il libro la cui chiave primaria corrisponde e inietta l'istanza nel metodo. Se nessun libro corrisponde, il framework interrompe la richiesta e restituisce automaticamente una risposta 404. Questo comportamento, che nella prossima puntata renderemo coerente con il resto delle risposte JSON, elimina un'intera categoria di codice ripetitivo.

Per impostazione predefinita la ricerca avviene sulla chiave primaria. Se volessimo che i libri fossero raggiungibili tramite l'ISBN anziché l'ID, potremmo indicarlo nella rotta.

// Il segnaposto :isbn indica la colonna da usare per la ricerca
Route::apiResource('books', BookController::class);
Route::get('books/{book:isbn}', [BookController::class, 'show']);

Popolare il database con factory e seeder

Per sviluppare e testare un'API servono dati realistici. Laravel offre le factory, classi che generano modelli con valori casuali plausibili grazie alla libreria Faker. Generiamo la factory per il libro.

php artisan make:factory BookFactory

Definiamo la struttura di un libro generato in database/factories/BookFactory.php.

<?php

namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;

class BookFactory extends Factory
{
    // Valori di default per un libro generato casualmente
    public function definition(): array
    {
        return [
            'title'  => fake()->sentence(3),
            'author' => fake()->name(),
            'year'   => fake()->numberBetween(1950, 2025),
            'isbn'   => fake()->unique()->isbn13(),
        ];
    }
}

Perché la factory sia collegata al modello, aggiungiamo il trait HasFactory alla classe Book.

use Illuminate\Database\Eloquent\Factories\HasFactory;

class Book extends Model
{
    use HasFactory;

    // ... resto della classe invariato
}

Ora usiamo la factory in un seeder per inserire una manciata di libri. Apriamo database/seeders/DatabaseSeeder.php.

<?php

namespace Database\Seeders;

use App\Models\Book;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        // Inserisce venti libri di prova nel database
        Book::factory()->count(20)->create();
    }
}

Eseguiamo il seeder.

php artisan db:seed

Interrogando ora GET /api/books otterremo l'elenco dei venti libri appena creati. Durante lo sviluppo, il comando combinato php artisan migrate:fresh --seed ricrea da zero l'intero schema e lo ripopola, un modo rapidissimo per ripartire da uno stato pulito.

Un problema che risolveremo presto

Restituendo direttamente il modello con response()->json($book) stiamo esponendo la struttura interna della tabella così com'è, comprese le colonne created_at e updated_at nel loro formato grezzo. In un'API pubblica raramente vogliamo questo: preferiamo controllare con precisione quali campi mostrare e in quale forma. Inoltre stiamo passando a create e update i dati della richiesta senza alcuna validazione, il che è pericoloso.

Entrambi i problemi hanno una soluzione elegante in Laravel. Il primo lo affronteremo nella prossima puntata con le API Resource, che fungono da strato di trasformazione fra il modello e il JSON restituito. Il secondo lo affronteremo nella puntata sulla validazione, con le Form Request.

Conclusione

Il nostro catalogo ha finalmente una memoria persistente. Abbiamo definito la tabella con una migrazione, configurato il modello Eloquent con la protezione dal mass assignment e la conversione dei tipi, e riscritto il controller sfruttando l'ORM e il route model binding, completando tutte le operazioni CRUD. Con factory e seeder abbiamo inoltre popolato il database di dati realistici su cui lavorare.

Nella prossima puntata ci concentreremo su come i dati escono dalla nostra API. Introdurremo le API Resource per trasformare i modelli in JSON pulito e controllato, gestiremo l'inclusione condizionale dei campi e la paginazione delle collezioni, dando alle risposte una forma professionale e stabile nel tempo.