Toutes les actualités

Persister une liste de lecture avec PHPAML Data

Construisez une liste de lecture complète avec SQLite : entités typées, migrations, CRUD, transactions, validation, intégration AML View, états d’interface et tests fiables.

Une couche de données utile n’est pas seulement une collection de méthodes nommées ajouter, modifier et supprimer. Elle doit donner à l’application un modèle clair, protéger ses règles, expliquer les échecs et rester prévisible lorsque plusieurs changements doivent réussir ensemble. Dans ce guide, nous allons construire une petite liste de lecture autour de ces principes. Un lecteur pourra ajouter un livre, choisir son statut, enregistrer sa progression, le modifier et le supprimer. L’interface sera écrite avec AML View, tandis que PHPAML Data sera responsable de la persistance.

Le projet reste assez petit pour être compris en une séance, mais assez complet pour montrer les décisions qui comptent dans une vraie application. Nous commencerons avec SQLite, qui ne demande aucun serveur de base de données. Les mêmes entités et les mêmes principes de requête pourront ensuite cibler MySQL, MariaDB ou PostgreSQL. PHPAML Data est actuellement en version alpha : épinglez sa version et consultez son journal de changements avant toute mise à niveau.

1. Comprendre la frontière avant d’écrire du code

AML View et PHPAML Data résolvent deux problèmes différents. AML View décrit ce que l’utilisateur voit et la manière dont le navigateur réagit. PHPAML Data décrit comment les entités PHP sont enregistrées, interrogées et modifiées. Un contrôleur ou un service applicatif relie les deux. Il reçoit une intention comme « ajouter ce livre », valide la requête, demande au contexte de données d’exécuter l’opération puis renvoie un résultat sûr à l’interface.

Cette séparation est pratique, pas décorative. Une page ne doit pas construire du SQL et une entité ne doit rien savoir des boutons ou des réponses HTTP. Comme PHPAML Data est indépendant de la présentation, la même entité Book peut servir dans une application AML View, une application PHPAML classique, une API ou un simple script PHP.

La structure finale ressemblera à ceci :

phpaml — zsh
src/
  controllers/
    ReadingListController.php
  models/
    Book.php
  Data/
    AppDbContext.php
  views/
    pages/
      reading-list/
        page.php
    components/
      BookCard.php
runtime/
  storage/
    app.sqlite
  database/
    migrations/
      202609020001_create_books_table.php
public/
  index.php
phpaml.json

Le fichier SQLite appartient à runtime : c’est un état généré par l’application, pas du code source. Il ne doit pas être versionné. Les migrations décrivent au contraire l’histoire du schéma et doivent être conservées dans Git.

2. Installer PHPAML Data et vérifier l’environnement

Depuis la racine du projet, installez Data avec le pilote SQLite. L’installateur prépare les dossiers, ajoute le paquet et écrit la configuration normalisée dans phpaml.json.

phpaml — zsh
aml install data --driver sqlite
aml data:doctor

La commande doctor doit confirmer le pilote PDO, la connexion résolue et les capacités disponibles, comme les transactions et les clés étrangères. Les secrets sont masqués volontairement. Exécutez cette commande avant de déboguer le contrôleur : une extension PDO absente ou un chemin invalide ne peut pas être réparé dans la page.

Un phpaml.json moderne contient une section data proche de celle-ci. Préférez un chemin relatif au projet afin que le développement, la CI et le déploiement résolvent le même emplacement.

phpaml — zsh
{
  "data": {
    "default": "main",
    "connections": {
      "main": {
        "driver": "sqlite",
        "database": "runtime/storage/app.sqlite"
      }
    }
  }
}

Pour une base distante, ne placez jamais les identifiants dans phpaml.json. Gardez les secrets dans .env, excluez ce fichier de Git et fournissez un .env.example qui documente uniquement les noms des variables.

3. Modéliser un livre avec une entité typée

Générez le modèle, puis remplacez l’exemple par une entité ciblée. Les propriétés publiques typées rendent la forme enregistrée visible pour le lecteur et pour les outils d’analyse statique. Les attributs décrivent la persistance et la validation sans rendre le modèle dépendant d’une page.

phpaml — zsh
aml make:model Book
phpaml — zsh
<?php

namespace App\Models;

use AML\Data\Entity;
use AML\Data\Metadata\{Column, Key, Table};
use AML\Data\Validation\{Length, Required};

#[Table('books')]
final class Book extends Entity
{
    #[Key]
    public int $id;

    #[Required]
    #[Length(min: 1, max: 180)]
    public string $title;

    #[Required]
    #[Length(min: 1, max: 120)]
    public string $author;

    #[Column('reading_status')]
    public string $status = 'to_read';

    #[Column('current_page')]
    public int $currentPage = 0;

    #[Column('total_pages')]
    public int $totalPages = 1;

    #[Column('created_at')]
    public string $createdAt;

    #[Column('updated_at')]
    public string $updatedAt;
}

Pour une nouvelle entité, l’identifiant reste non initialisé. SQLite le génère et PHPAML Data répercute cette identité dans le même objet PHP. Cet accord est essentiel : les futures opérations find, update et remove doivent viser exactement la ligne enregistrée. Si votre domaine emploie des identifiants manuels, définissez ce contrat explicitement au lieu de mélanger des clés générées et manuelles.

Le statut initial est une décision métier. Centralisez les valeurs autorisées — par exemple to_read, reading et finished — et refusez toutes les autres dans le contrôleur ou dans un service dédié. Les colonnes conservent le snake_case habituel tandis que les propriétés PHP utilisent le camelCase.

4. Créer le schéma avec une migration réversible

Générez une migration et décrivez ses deux directions. La méthode up crée le schéma ; down l’annule. Une migration doit rester déterministe : elle ne doit pas lire la requête courante ni appeler un service externe.

phpaml — zsh
aml make:migration create_books_table
phpaml — zsh
<?php

use AML\Data\Connection;
use AML\Data\Migrations\Migration;
use AML\Data\Schema\{Schema, Table};

return new class extends Migration {
    public function up(Connection $connection): void
    {
        (new Schema($connection))->create('books', function (Table $table): void {
            $table->id();
            $table->string('title', 180);
            $table->string('author', 120);
            $table->string('reading_status', 24)->default('to_read');
            $table->integer('current_page')->default(0);
            $table->integer('total_pages')->default(1);
            $table->timestamps();
            $table->index(['reading_status', 'updated_at']);
        });
    }

    public function down(Connection $connection): void
    {
        (new Schema($connection))->dropIfExists('books');
    }
};

Appliquez la migration, inspectez son état et exercez au moins une fois le rollback en local avant que l’application contienne des données importantes.

phpaml — zsh
aml data:migrate
aml data:status
aml data:rollback --steps 1
aml data:migrate

L’index composé prépare la requête « afficher les livres de ce statut, les plus récemment modifiés en premier ». N’ajoutez pas des index au hasard : chacun occupe de l’espace et rend les écritures légèrement plus coûteuses. Créez-les pour de vrais parcours de lecture.

5. Donner un seul contexte de données à l’application

Un DbContext représente une session cohérente avec les données. Il expose des ensembles nommés afin d’éviter de répéter les classes d’entité dans toute l’application.

phpaml — zsh
<?php

namespace App\Data;

use AML\Data\DbContext;
use AML\Data\DbSet;
use App\Models\Book;

final class AppDbContext extends DbContext
{
    /** @return DbSet<Book> */
    public function books(): DbSet
    {
        return $this->set(Book::class);
    }
}

Créez le contexte à partir du gestionnaire de connexions configuré par PHPAML, dans le bootstrap ou le conteneur de dépendances, puis injectez-le là où il est utile. Évitez d’ouvrir une nouvelle connexion dans chaque méthode de contrôleur. L’application gère la durée de vie du contexte ; l’entité reste un objet métier simple.

6. Insérer le premier livre en sécurité

Un contrôleur traduit les entrées non fiables en entité valide. Le nettoyage des textes, la conversion numérique et les règles du domaine sont appliqués avant la persistance. Les attributs de validation du paquet offrent une seconde protection ; ils ne dispensent pas de retourner une erreur HTTP compréhensible.

phpaml — zsh
public function store(Request $request): Response
{
    $title = trim((string) $request->input('title', ''));
    $author = trim((string) $request->input('author', ''));
    $totalPages = filter_var(
        $request->input('total_pages'),
        FILTER_VALIDATE_INT,
        ['options' => ['min_range' => 1]],
    );

    if ($title === '' || $author === '' || $totalPages === false) {
        return Response::json([
            'error' => 'Le titre, l’auteur et un nombre de pages positif sont obligatoires.',
        ], 422);
    }

    $book = new Book();
    $book->title = $title;
    $book->author = $author;
    $book->totalPages = $totalPages;
    $book->createdAt = gmdate('Y-m-d H:i:s');
    $book->updatedAt = $book->createdAt;

    $this->db->books()->add($book);

    return Response::json(['book' => $book], 201);
}

Le CRUD de DbSet est immédiat : add insère la ligne à cet instant. Après le succès, book.id contient l’identité générée par la base. Utilisez 201 pour la création et 422 lorsqu’une requête syntaxiquement correcte viole le contrat des champs. Ne considérez jamais l’attribut required du navigateur comme votre seule validation : un client peut appeler l’endpoint directement.

7. Interroger les données sans faire fuiter le SQL dans la page

Les méthodes de requête de DbSet renvoient des clones. Un ensemble réutilisable ne conserve donc pas accidentellement un ancien filtre. Les valeurs passent par des paramètres préparés et les noms de champs sont contrôlés à partir des métadonnées de l’entité.

phpaml — zsh
$status = $request->query('status', 'all');
$page = max(1, (int) $request->query('page', 1));

$query = $this->db->books()->orderBy('updated_at', 'desc');

if (in_array($status, ['to_read', 'reading', 'finished'], true)) {
    $query = $query->where('reading_status', '=', $status);
}

$result = $query->paginate(page: $page, perPage: 12);
return Response::json($result);

La pagination appartient à la frontière des données, pas seulement à l’affichage. Charger dix mille lignes puis les découper en PHP gaspille la mémoire et augmente le temps de réponse avec la taille de la base. Rendez les filtres explicites, autorisez seulement une liste connue de champs de tri et ajoutez un ordre secondaire stable lorsque plusieurs valeurs peuvent être égales.

Pour obtenir un seul livre, utilisez find avec la clé primaire et distinguez l’absence du livre d’une panne du serveur.

phpaml — zsh
$book = $this->db->books()->find($id);
if ($book === null) {
    return Response::json(['error' => 'Livre introuvable.'], 404);
}

8. Mettre à jour la progression en protégeant les règles

La progression de lecture possède des invariants : la page courante ne peut être négative ni dépasser le total, et atteindre la dernière page peut marquer le livre comme terminé. Chargez l’entité suivie, modifiez ses propriétés publiques puis appelez saveChanges. PHPAML Data détecte les changements apportés aux entités chargées.

phpaml — zsh
public function progress(int $id, Request $request): Response
{
    $book = $this->db->books()->find($id);
    if ($book === null) {
        return Response::json(['error' => 'Livre introuvable.'], 404);
    }

    $page = filter_var($request->input('page'), FILTER_VALIDATE_INT);
    if ($page === false || $page < 0 || $page > $book->totalPages) {
        return Response::json(['error' => 'Progression de lecture invalide.'], 422);
    }

    $book->currentPage = $page;
    $book->status = $page === $book->totalPages ? 'finished' : 'reading';
    $book->updatedAt = gmdate('Y-m-d H:i:s');
    $this->db->saveChanges();

    return Response::json(['book' => $book]);
}

Une mise à jour sans propriété modifiée doit être considérée comme une opération sans effet, plutôt que de produire du SQL inutile. Si les modifications concurrentes comptent, ajoutez une version explicite ou une précondition basée sur updated_at, puis renvoyez 409 lorsque le client travaille sur une donnée périmée.

9. Supprimer de manière délibérée

La suppression est facile à coder et difficile à annuler. Chargez l’entité, vérifiez l’autorisation puis supprimez-la. Une réponse 204 ne doit contenir aucun corps.

phpaml — zsh
public function destroy(int $id): Response
{
    $book = $this->db->books()->find($id);
    if ($book === null) {
        return Response::json(['error' => 'Livre introuvable.'], 404);
    }

    $this->db->books()->remove($book);
    return new Response('', 204);
}

Pour du contenu créé par les utilisateurs, envisagez une suppression logique ou une période d’annulation. Quelle que soit la politique retenue, contrôlez la propriété de la ressource sur le serveur. Masquer un bouton ne constitue jamais une autorisation.

10. Utiliser une transaction pour une seule opération métier

Une transaction devient nécessaire lorsqu’un succès partiel serait incorrect. Imaginons que terminer un livre crée également une entrée dans un journal. Les deux changements doivent être confirmés ensemble, ou aucun ne doit apparaître. PHPAML Data annule automatiquement la transaction qui échoue. Les transactions SQL imbriquées utilisent des points de sauvegarde lorsque le pilote les prend en charge.

phpaml — zsh
$this->db->transaction(function (AppDbContext $db) use ($book, $journal): void {
    $book->status = 'finished';
    $book->currentPage = $book->totalPages;
    $book->updatedAt = gmdate('Y-m-d H:i:s');

    $db->add($journal);
    $db->saveChanges();
});

Gardez les transactions courtes. N’appelez pas un service d’IA, n’envoyez pas d’e-mail et n’attendez pas un téléversement pendant qu’une transaction reste ouverte. Les effets externes ne peuvent pas être annulés avec SQL ; utilisez une boîte d’envoi ou une tâche asynchrone lorsqu’ils doivent suivre une validation en base.

11. Relier l’API à AML View

Le navigateur doit consommer une API étroite au lieu de connaître SQLite. La page gère l’état visuel et réactif : livres, filtres, actions en cours et erreurs destinées à l’utilisateur. Le serveur demeure la source de vérité.

phpaml — zsh
<?php

namespace App\Views\Pages\ReadingList;

use AML\Engine\{Api, StateRef};
use AML\View\{CollectionItem, Page, State, View};
use function AML\View\{Button, Each, Form, Heading, Input, Text, VStack};

final class ReadingListPage extends Page
{
    #[State] public array $books = [];
    #[State] public string $title = '';
    #[State] public string $author = '';
    #[State] public bool $loading = false;
    #[State] public string $error = '';

    public function body(): View
    {
        return VStack(
            Heading('Ma liste de lecture'),
            Form(
                Input('title')->bindClient('title')->required('Le titre est obligatoire.'),
                Input('author')->bindClient('author')->required('L’auteur est obligatoire.'),
                Button('Ajouter le livre')->onClick(
                    Api::post('/api/books', [
                        'title' => StateRef::to('title'),
                        'author' => StateRef::to('author'),
                    ])
                        ->storeIn('books', 'books')
                        ->loadingIn('loading')
                        ->errorIn('error')
                ),
            ),
            Text(StateRef::to('error', $this->error))->class('form-error'),
            Each(
                StateRef::to('books', $this->books),
                key: 'id',
                render: static fn (CollectionItem $book): View =>
                    Element('article', $book->text('title'))->class('book-card'),
            ),
        )->class('reading-list');
    }
}

Les noms peuvent varier légèrement selon les helpers AML View présents dans votre projet, mais la règle architecturale reste la même : les éléments déclaratifs décrivent l’interface, les actions clientes appellent l’API et PHPAML Data ne s’exécute jamais dans le navigateur. Utilisez l’identifiant stable de l’entité comme clé de collection afin qu’Engine mette à jour la bonne carte sans reconstruire toute la liste.

12. Concevoir les états de chargement, vide, erreur et optimiste

Une interface de production ne se résume pas à « données » ou « aucune donnée ». Pendant la première requête, affichez un squelette de chargement. Si la réponse réussit avec zéro livre, montrez un état vide qui explique la suite et propose une action claire. En cas d’échec, gardez la page utilisable, affichez un bouton Réessayer et conservez le formulaire lorsque cela est pertinent.

Pendant une création, désactivez le bouton d’envoi et refusez un titre vide aussi bien côté client que côté serveur. Pour la progression, une interface optimiste peut déplacer immédiatement la barre, mais elle doit restaurer l’ancienne valeur si le serveur refuse la modification. Pour une suppression, demandez confirmation et retirez la carte seulement après le succès, ou fournissez une annulation fiable.

Un modèle d’état utile est :

phpaml — zsh
repos -> chargement -> prêt
                    -> vide
                    -> erreur -> réessayer -> chargement
prêt -> enregistrement -> prêt
                       -> erreur avec les anciennes données conservées

Ne remplacez pas une liste existante par un écran blanc pendant une actualisation en arrière-plan. Conservez le contenu utile et indiquez la synchronisation. Les messages doivent expliquer ce que le lecteur peut faire ensuite, tandis que les détails techniques restent dans les journaux du serveur.

13. Tester le comportement et pas seulement le cas idéal

Utilisez une base SQLite temporaire dans les tests automatisés. Appliquez la vraie migration, créez un nouveau contexte pour chaque test puis supprimez le fichier temporaire. Le test doit prouver la synchronisation de l’identité, la recherche, les mises à jour suivies, la suppression et le rollback.

phpaml — zsh
$path = sys_get_temp_dir() . '/phpaml-reading-' . bin2hex(random_bytes(6)) . '.sqlite';
$connection = Connection::sqlite($path);
$db = new AppDbContext($connection);

$book = new Book();
$book->title = 'La Main gauche de la nuit';
$book->author = 'Ursula K. Le Guin';
$book->totalPages = 304;
$book->createdAt = $book->updatedAt = gmdate('Y-m-d H:i:s');
$db->books()->add($book);

assert(isset($book->id));
assert($db->books()->find($book->id)?->title === $book->title);

$loaded = $db->books()->find($book->id);
$loaded->currentPage = 42;
$loaded->status = 'reading';
$db->saveChanges();
assert($db->books()->find($book->id)?->currentPage === 42);

Ajoutez un test de rollback en insérant dans une transaction puis en lançant une exception. Après l’avoir interceptée, le nombre de lignes doit rester inchangé. Testez également les chaînes vides, un statut invalide, une page supérieure au total, un identifiant inconnu et une requête répétée. Les tests navigateur doivent confirmer que les formulaires vides sont bloqués, que les états de chargement et d’erreur sont visibles, que la navigation ne recharge pas toute la page et que la progression survit à une actualisation.

14. Préparer la même conception pour une autre base

SQLite est un excellent choix pour l’apprentissage, les prototypes et de nombreuses applications sur un seul nœud. Passer à MySQL, MariaDB ou PostgreSQL modifie la connexion, pas le rôle de l’entité ou du contrôleur. Exécutez les vrais tests d’intégration du serveur choisi et examinez les différences concernant les identifiants générés, les dates, les index et les verrous. Relancez aml data:doctor après chaque changement d’environnement.

MongoDB est disponible avec l’adaptateur séparé phpaml/data-mongodb. Il suit la sémantique documentaire et les règles ObjectId ; il ne faut pas prétendre que toutes les relations ou migrations SQL sont portables. Choisissez le stockage d’après les parcours réels de l’application, pas uniquement parce qu’une ligne de configuration semble facile à changer.

En résumé

Vous disposez maintenant du parcours complet, depuis l’entité Book typée jusqu’à une liste de lecture réactive. Le modèle nomme les données, la migration versionne le schéma, AppDbContext crée une frontière de persistance claire, le contrôleur protège les entrées et les règles, puis AML View présente le résultat sans connaître son stockage. Les transactions protègent les opérations en plusieurs étapes ; les états de chargement, vide et erreur rendent l’interface honnête.

Avant le déploiement, exécutez les migrations dans un processus tenant compte des sauvegardes, gardez le fichier SQLite hors de public, vérifiez les droits d’écriture, lancez data:doctor et exécutez les tests de persistance et de navigateur. PHPAML Data reste en alpha : épinglez la version 0.2.0-alpha.1, lisez le changelog et testez toute mise à niveau sur staging. Une petite application devient fiable non parce qu’elle possède beaucoup de couches, mais parce que chaque frontière a une responsabilité claire et que chaque échec a été envisagé.