Tutoriel MVC · Chapitre 09

Créer le modèle
Task.

Donnez un langage métier aux données et rendez chaque opération sûre et testable.

ROWMODELBEHAVIORSTATE

La table existe. Il faut maintenant lui donner un langage métier.

Le chapitre précédent a construit le stockage, mais le contrôleur ne devrait pas connaître les colonnes, écrire du SQL ou gérer lui-même les transactions. Une classe Task va devenir le point de rencontre entre les concepts de l’application et les lignes de SQLite.

Nous allons créer une API de modèle suffisamment simple pour lire et enregistrer, mais assez rigoureuse pour conserver l’identité, empêcher l’affectation de champs inattendus, traiter les mises à jour vides et garantir les opérations composées.

Projet du chapitre

Implémentez Task et ses opérations CRUD, ajoutez des filtres QueryBuilder, protégez une création avec historique par transaction et testez chaque contrat sur une base isolée.

09.1

Comprendre le modèle

Le modèle représente une notion du métier et les opérations cohérentes qui la concernent. Task n’est pas seulement une ligne SQL : c’est une tâche avec un titre, un état et des règles d’évolution.

Dans une petite application, le modèle peut regrouper accès aux données et comportements simples. Lorsque le domaine grandit, repository et services peuvent séparer persistance et cas d’usage sans changer le rôle du contrôleur.

Le modèle ne connaît ni HTML, ni message flash, ni redirection. Il peut être utilisé par un contrôleur Web, une API, une commande ou un test sans dépendre de la présentation.

À retenir

Le modèle porte le sens et les règles durables de la donnée.

09.2

Définir Task et son identité

La classe Task reflète les propriétés utiles de la table : id, title, description, completed et dates. Les types PHP rendent les attentes visibles avant même une requête.

L’identifiant relie l’objet à une ligne précise. Après insertion, l’objet et la base doivent conserver la même identité; un identifiant manuel ne doit jamais être silencieusement remplacé par une autre valeur.

Décidez quelles propriétés sont modifiables depuis une entrée externe. Une liste fillable ou un constructeur explicite empêche l’affectation massive de champs sensibles comme user_id ou created_at.

À retenir

L’identité et les champs modifiables forment un contrat explicite du modèle.

phpaml — zsh
final class Task extends Model
{
    protected static string $table = 'tasks';
    protected array $fillable = ['title', 'description'];

    public ?int $id = null;
    public string $title;
    public ?string $description = null;
    public bool $completed = false;

    public function complete(): void
    {
        $this->completed = true;
        $this->save();
    }
}
09.3

Lire une collection

Task::query() démarre une requête sans l’exécuter immédiatement. Vous pouvez ajouter ordre, filtres et limite, puis appeler all() pour obtenir la collection.

Sélectionnez uniquement les données nécessaires et imposez un ordre stable. Sans ORDER BY, la base ne promet aucun ordre, même si les lignes semblent toujours revenir de la même façon localement.

Une liste importante doit être paginée. Charger des milliers de tâches pour en afficher vingt consomme mémoire et temps, puis ralentit chaque étape suivante.

À retenir

Construisez la requête, limitez son résultat, puis exécutez-la explicitement.

phpaml — zsh
$tasks = Task::query()
    ->where('completed', '=', false)
    ->where('user_id', '=', $userId)
    ->orderBy('created_at', 'desc')
    ->limit(20)
    ->all();
09.4

Rechercher une tâche

find($id) recherche par clé primaire et retourne Task ou null. Ce résultat oblige le code appelant à considérer la ressource absente plutôt que provoquer une erreur plus loin.

Une variante require() ou findOrFail() peut lancer une exception dédiée, traduite ensuite en 404. Choisissez une convention cohérente pour que tous les contrôleurs traitent l’absence de la même manière.

Pour rechercher selon une autre propriété, utilisez where avec une valeur liée. Ne concaténez jamais le titre ou l’utilisateur dans une chaîne SQL.

À retenir

Une recherche précise retourne une ressource typée ou signale explicitement son absence.

09.5

Créer une tâche

La création reçoit des données déjà validées, applique les valeurs métier par défaut, puis insère une ligne. L’identifiant généré est réaffecté au modèle avant de le retourner.

N’acceptez pas aveuglément tout le tableau du formulaire. Construisez les attributs autorisés et laissez la base remplir completed et les dates lorsque cette responsabilité lui appartient.

Après insertion, relisez seulement si la base produit des valeurs nécessaires que le pilote ne retourne pas autrement. Une requête supplémentaire automatique sur chaque create peut coûter inutilement.

À retenir

Après create, l’objet PHP et la ligne enregistrée doivent représenter exactement la même tâche.

09.6

Mettre à jour l’état

Une mise à jour commence par une tâche existante, applique les changements permis et produit un UPDATE ciblé par son identifiant. Une requête sans condition d’identité pourrait modifier toutes les lignes.

Préférez des comportements comme complete() et reopen() lorsque la transition possède du sens. Ils empêchent la duplication de completed=true dans plusieurs contrôleurs et constituent un endroit naturel pour ajouter completed_at.

Si aucune propriété n’a changé, le modèle peut considérer l’opération comme réussie sans générer UPDATE table SET WHERE..., qui serait une requête SQL invalide.

À retenir

Une mise à jour exprime une transition valide et cible toujours une identité connue.

phpaml — zsh
$task->title = $validated['title'];
$task->save(); // no query when nothing changed
09.7

Supprimer sans surprise

La suppression reçoit une identité existante et produit un DELETE limité. Le résultat doit indiquer si une ligne a réellement été supprimée afin de distinguer réussite et ressource déjà absente.

Avant de supprimer, examinez les relations et les règles : faut-il supprimer l’historique, refuser l’opération ou effectuer une suppression logique avec deleted_at ? La réponse vient du métier, pas d’une habitude technique.

Une suppression logique facilite restauration et audit mais complique toutes les lectures, qui doivent exclure les lignes supprimées par défaut. N’ajoutez cette complexité que lorsqu’elle répond à un besoin réel.

À retenir

Une suppression doit être ciblée, vérifiable et cohérente avec les relations.

09.8

Composer avec QueryBuilder

QueryBuilder construit une requête par étapes : where, orderBy, limit, select et pagination. Les valeurs restent liées comme paramètres, tandis que les noms de colonnes et opérateurs proviennent d’une liste contrôlée.

Créez des scopes lisibles pour les filtres répétés : pending(), completed() ou ownedBy($userId). Le contrôleur exprime alors son intention sans recopier les détails de chaque condition.

Inspectez le SQL et les paramètres lors du diagnostic, mais masquez les données sensibles. Une requête correcte peut rester lente si aucune stratégie d’index ne soutient ses filtres fréquents.

À retenir

Le QueryBuilder rend une requête dynamique composable sans sacrifier la sécurité des valeurs.

09.9

Utiliser une transaction

Une transaction regroupe plusieurs écritures en une unité : soit toutes réussissent, soit aucune n’est conservée. Elle protège les invariants lorsqu’une opération crée une tâche et ajoute simultanément son historique.

Commencez la transaction au niveau du cas d’usage qui connaît l’ensemble de l’opération. Un simple save() du modèle ne sait pas toujours quelles autres écritures doivent participer.

Gardez la transaction courte, annulez sur toute exception et évitez les appels réseau pendant son ouverture. Pour les imbrications SQL, le framework peut utiliser des savepoints plutôt que prétendre démarrer deux transactions indépendantes.

À retenir

La transaction protège une règle qui concerne plusieurs écritures, pas une requête isolée.

phpaml — zsh
$db->transaction(function () use ($task): void {
    $task->save();
    TaskHistory::record($task->id, 'created');
});
09.10

Tester le modèle

Les tests du modèle utilisent une base dédiée et recréée par migrations. Ils vérifient création, lecture, modification, suppression, contraintes et transactions sans toucher aux données de développement.

Ajoutez des cas limites : titre maximal, identifiant absent, update sans changement, rollback après exception et tentative de suppression d’une ressource inexistante.

Testez le contrat observable plutôt que PDO lui-même. Vous voulez prouver que Task conserve la bonne identité et le bon état, pas que la bibliothèque de base sait exécuter INSERT.

À retenir

Une suite de modèles protège les données et les transitions que le reste de l’application suppose vraies.

Atelier guidé

Terminez le modèle Task.

  1. Déclarez table, types, identité et champs modifiables.
  2. Implémentez all, find et filtres pending/completed.
  3. Créez une tâche et vérifiez son identifiant.
  4. Ajoutez complete, reopen et update sans changement.
  5. Implémentez une suppression ciblée et vérifiable.
  6. Composez une requête paginée avec QueryBuilder.
  7. Protégez tâche et historique par transaction.
  8. Testez CRUD, erreurs, rollback et identité.

Correction raisonnée

Comparez toujours l’objet avec la ligne enregistrée.

Après chaque écriture importante, le test doit prouver que l’identifiant et l’état observés en PHP correspondent à ce que la base retrouve. Cette vérification détecte les défauts les plus dangereux avant qu’un update ou delete ne cible la mauvaise ressource.

En résumé

Task est devenu un modèle, pas seulement une ligne.

Vous savez représenter l’identité, limiter les propriétés modifiables, composer des lectures sûres et implémenter les transitions CRUD. QueryBuilder rend les filtres lisibles et les transactions protègent les opérations qui doivent réussir ensemble.

  • conservez la même identité dans l’objet et la base
  • séparez valeurs externes et structure SQL
  • traitez explicitement absence et mise à jour vide
  • placez les transactions autour du cas d’usage complet
  • testez sur une base isolée recréée par migrations

Au chapitre 10, nous relierons routes, contrôleurs, modèle et vues pour terminer la première application MVC fonctionnelle de bout en bout.

Chapitre 08Chapitre 10