Base de données

Base de données

Un ORM Active Record qui retourne des tableaux plutôt que des objets, un Query Builder complet, des migrations portables entre SQLite, MySQL et PostgreSQL — tout vérifié contre packages/database/src/Database/*.php.

Active Record en tableaux

Niang\Core\Database\Model n'enveloppe pas une ligne dans un objet : chaque enregistrement reste un array PHP ordinaire (['id' => 1, 'title' => '...']). Pas d'hydratation, pas de proxy, pas de dirty tracking — un var_dump() sur un résultat montre exactement ce qui a été lu en base.

app/Models/Post.php

namespace App\Models;

use Niang\Core\Database\Model;

class Post extends Model
{
    // protected static string $table = 'posts'; // déduit sinon du nom de classe : Post -> "posts"
}

Sans $table déclarée, Model::table() déduit le nom de table du nom de classe : Post → posts, Comment → comments (minuscule + s). $primaryKey vaut 'id' par défaut.

CRUD sur un Model

MéthodeRetourne
Post::all()Tous les enregistrements (array[]).
Post::find($id)Un enregistrement, ou null.
Post::findOrFail($id)Un enregistrement, ou lève NotFoundException (404).
Post::where('published', true)Les enregistrements qui correspondent (égalité uniquement — pour un opérateur, passez par query()).
Post::create(['title' => '...', 'body' => '...'])L'id inséré (string, PDO::lastInsertId()) — seules les colonnes de $fillable sont écrites.
Post::update($id, ['title' => '...'])bool.
Post::destroy($id)bool.
Post::paginate(15, $page)Un Paginator — voir ci-dessous.

Affectation de masse : $fillable

create() et update() ne gardent que les colonnes déclarées dans $fillable : User::create($request->all()) ne peut pas écrire role ou email_verified_at, même si un visiteur ajoute ces champs au formulaire (les clés ignorées incluent _token).

app/Models/User.php
class User extends Model
{
    // `role` et `email_verified_at` n'en font volontairement pas partie.
    protected static array $fillable = ['name', 'email', 'password'];
}

User::create(['name' => 'Awa', 'email' => 'awa@example.test', 'password' => $hash, 'role' => 'admin']);
// -> INSERT de name, email, password seulement : role garde sa valeur par défaut

Un modèle sans $fillable fait lever une MassAssignmentException à create()/update(), plutôt que de tout accepter (ou tout jeter) en silence — niang make:model génère la propriété, à compléter. Pour du code de confiance qui écrit une colonne sensible — seeder, rôle attribué par un administrateur après validation, date de vérification d'email — forceCreate() et forceUpdate() contournent le filtre :

php
User::forceUpdate($id, ['email_verified_at' => date('Y-m-d H:i:s')]);
User::forceCreate(['name' => 'Administrateur', 'email' => $email, 'password' => $hash, 'role' => 'admin']);

Les factories passent par forceCreate() : leurs données sont écrites par le développeur, pas par un visiteur.

Dates, types et suppression douce

app/Models/Article.php
class Article extends Model
{
    protected static array $fillable = ['title', 'published', 'options'];

    // created_at / updated_at renseignés par create(), updated_at par update().
    // Vrai par défaut ; false pour une table sans ces colonnes.
    protected static bool $timestamps = true;

    // Types à la lecture, quel que soit le SGBD ; json : tableau PHP <-> texte JSON.
    protected static array $casts = ['published' => 'bool', 'views' => 'int', 'options' => 'json'];

    // destroy() renseigne deleted_at au lieu de supprimer la ligne.
    protected static bool $softDeletes = true;
}
PropriétéEffet
$timestampscreate() renseigne created_at et updated_at, update() met updated_at à jour — avec l'horloge PHP, pas CURRENT_TIMESTAMP (UTC sous SQLite). Une valeur fournie l'emporte. Une table sans ces colonnes donne une erreur qui explique quoi faire.
$castsint, float, bool, string, json : appliqués à toutes les lectures (find, all, where, query(), paginate, with(), relations). MySQL renvoie les entiers sous forme de chaînes, SQLite non : les casts rendent le code identique partout. json et bool sont aussi convertis à l'écriture.
$softDeletesdestroy() renseigne deleted_at ; toutes les lectures, relations et eager loading compris, ignorent ces lignes. Migration : $table->softDeletes().
php
Article::destroy($id);             // UPDATE ... SET deleted_at = maintenant
Article::onlyTrashed()->get();     // la corbeille
Article::withTrashed()->count();   // tout, supprimé compris
Article::restore($id);
Article::forceDestroy($id);        // DELETE réel

Model::query()->where(...)->update([...]) (mise à jour en masse par le Query Builder) ne touche pas updated_at : passez-le explicitement.

Query Builder

Post::query() retourne un QueryBuilder chaînable pour tout ce que les raccourcis ci-dessus ne couvrent pas :

php
Post::query()
    ->where('published', true)
    ->where('views', '>', 100)
    ->orWhere('featured', '=', true)
    ->orderBy('created_at', 'desc')
    ->limit(10)
    ->get();

where($colonne, $valeur) (2 arguments, égalité implicite) et where($colonne, $operateur, $valeur) (3 arguments) sont tous deux acceptés — même convention pour orWhere() et having().

MéthodeSQL généré
select('id', 'title')SELECT id, title ... (défaut : *)
distinct()SELECT DISTINCT ...
where($col, $op, $val)WHERE col op ?
orWhere($col, $op, $val)OR col op ?
whereIn($col, $valeurs)WHERE col IN (?, ?, ...)
whereNull($col) / whereNotNull($col)WHERE col IS (NOT) NULL
whereBetween($col, [$a, $b]) / whereNotBetweenWHERE col (NOT) BETWEEN ? AND ?
whereDate($col, '2026-01-15')WHERE DATE(col) = ?
whereColumn('updated_at', '>', 'created_at')WHERE updated_at > created_at (compare deux colonnes, pas de binding)
join($table, $first, $op, $second)JOIN table ON first op second
groupBy(...$colonnes)GROUP BY ...
having($col, $op, $val) / havingRaw($sql, $bindings)HAVING ...
orderBy($col, 'asc'|'desc')ORDER BY col ASC|DESC
limit($n) / offset($n)LIMIT n / OFFSET n
lockForUpdate()... FOR UPDATE
onConnection('read'|'write')Force la connexion utilisée — voir ci-dessous.
toSql()Le SQL généré, sans l'exécuter (debug).

insert(), update() et delete() sont aussi disponibles directement sur le Query Builder (utilisés en interne par Model::create/update/destroy).

Un nom de colonne ou de table ne peut jamais être lié comme valeur (?) — SQL ne le permet pas. Chaque méthode qui en accepte un (where(), whereIn(), whereColumn(), join(), having(), orderBy(), groupBy()...) le valide contre un identifiant simple ou qualifié (table.colonne) et lève une \InvalidArgumentException sinon — orderBy($request->query['tri'] ?? 'id') est donc sûr même si tri vient directement de l'utilisateur. orderBy() valide en plus son second paramètre contre asc/desc. Pour un fragment SQL qui n'est pas un simple identifiant (agrégat, expression) — jamais pour une entrée utilisateur —, havingRaw() et select() restent des échappatoires volontaires.

leftJoin, pluck, chunk, increment

php
// Jointure externe : les articles sans auteur sont gardés (name vaut alors null)
Post::query()->select('posts.title', 'users.name')
    ->leftJoin('users', 'posts.author_id', '=', 'users.id')->get();

// Une seule colonne, éventuellement indexée par une autre
Post::query()->pluck('title');          // ['Premier', 'Second', ...]
Post::query()->pluck('title', 'id');    // [1 => 'Premier', 2 => 'Second', ...]

// Parcourir une grande table par paquets, sans tout charger en mémoire
User::query()->where('active', true)->chunk(500, function (array $users, int $paquet) {
    foreach ($users as $user) { /* ... */ }
    // return false; arrête le parcours
});

// Calcul fait par la base : deux commandes simultanées ne perdent pas de mise à jour
Product::query()->where('id', 5)->decrement('stock', 2);
Post::query()->where('id', 5)->increment('views', 1, ['last_viewed_at' => date('Y-m-d H:i:s')]);

chunk() trie par id si vous ne donnez pas d'orderBy() : sans ordre stable, deux paquets pourraient se chevaucher. Ne modifiez pas, dans le rappel, la colonne sur laquelle la requête filtre (active ci-dessus), sinon les paquets suivants sont décalés. pluck() accepte une colonne qualifiée (posts.title) et applique les $casts du modèle.

Les noms de colonnes et de tables, et les opérateurs (=, !=, <>, <, <=, >, >=, LIKE, NOT LIKE), sont insérés tels quels dans le SQL : ils sont donc vérifiés, et tout le reste lève une InvalidArgumentException. where('prix', $_GET['op'], 10) ou QueryBuilder::update($request->all()) ne peuvent pas injecter de SQL. Les valeurs, elles, sont toujours liées.

Sous-requêtes et unions

php
// Articles qui ont au moins un commentaire (sous-requête corrélée)
Post::query()->whereExists(
    (new QueryBuilder('comments'))->select('id')->whereColumn('comments.post_id', 'posts.id')
)->get();

// Articles et archives dans une seule liste, triée et paginée
Post::query()->select('title', 'published_at')
    ->union((new QueryBuilder('archives'))->select('title', 'published_at'))   // unionAll() garde les doublons
    ->orderBy('published_at', 'desc')->paginate(20);

Aussi : whereNotExists(), whereNotIn(). orderBy(), limit(), count() et paginate() s'appliquent au résultat combiné d'une union (portable sur les trois moteurs) : triez par le nom de colonne du résultat (published_at, pas posts.published_at).

Agrégats et pagination

php
Post::query()->count();               // COUNT(*)
Post::query()->where('published', true)->sum('views');
Post::query()->avg('rating');
Post::query()->min('price');
Post::query()->max('price');

Post::query()->where('slug', $slug)->exists();  // bool, sans rapatrier de ligne
Post::query()->first();                          // ?array
Post::query()->firstOrFail();                    // array, ou 404

paginate($perPage, $page) retourne un Niang\Core\Database\Paginator :

php
$paginator = Post::paginate(10, (int) $request->query['page'] ?? 1);

$paginator->items;         // array[] de la page courante
$paginator->total;         // total toutes pages confondues
$paginator->currentPage;
$paginator->lastPage();
$paginator->hasMorePages();
$paginator->links('/blog'); // <nav class="pagination">... liens Précédent/1 2 3/Suivant

Utilisé tel quel dans les vues de cette documentation — voir .pagination dans public/css/niang.css pour le style.

Pagination des grandes tables

php
// « Précédent / Suivant » seulement : pas de COUNT(*)
$page = Post::simplePaginate(20, (int) $request->input('page', 1));

// Par curseur : reprend après le dernier id vu (WHERE id > ?) au lieu d'un OFFSET
$page = Post::cursorPaginate(20, $request->input('cursor'));
$page = Post::cursorPaginate(20, $request->input('cursor'), 'id', 'desc');
// $page->items, $page->nextCursor (null en fin de liste), $page->links('/fil')

Un OFFSET ralentit au fil des pages et, si une ligne arrive entre deux pages, en répète ou en saute une ; le curseur n'a aucun des deux défauts, mais ne permet pas d'aller directement à la page 7. Triez par une colonne unique. Le curseur est une valeur opaque et signée (clé dérivée d'APP_KEY) : un curseur modifié ou fabriqué est ignoré et la pagination repart du début. JsonResource::collection() accepte les trois paginations.

Relations

Pas de déclaration de relation par attribut ni de proxy chargé à la demande : chaque relation est une méthode statique explicite sur le Model, construite avec les helpers protégés de la classe parente :

app/Models/Post.php
public static function comments(int|string $postId): array
{
    return static::hasMany($postId, Comment::class, 'post_id');
}

public static function tags(int|string $postId): array
{
    return static::belongsToMany($postId, Tag::class, 'post_tag', 'post_id', 'tag_id');
}
HelperUsage
hasOne($id, $related, $foreignKey)Un enregistrement lié possédé par celui-ci.
hasMany($id, $related, $foreignKey)Plusieurs enregistrements liés.
belongsTo($record, $related, $foreignKey)L'enregistrement parent (à partir du tableau courant, pas d'un id).
belongsToMany($id, $related, $pivotTable, $foreignKey, $relatedKey)Relation N-N via une table pivot.

Appel côté contrôleur : Post::comments($post['id']) — une requête à chaque appel.

Eager loading (with())

Appeler une relation pour chaque ligne d'une liste fait du N+1 (une requête par ligne). Model::with([...]) charge chaque relation déclarée en une seule requête pour toute la collection (WHERE ... IN (...)), via eagerLoadable() :

app/Models/Post.php
/** Utilisables avec Post::with(['comments', 'tags'])->get() — une requête chacune, pas de N+1. */
public static function eagerLoadable(): array
{
    return [
        'comments' => fn (array $posts) => static::loadMany($posts, 'comments', Comment::class, 'post_id'),
        'tags' => fn (array $posts) => static::loadManyToMany($posts, 'tags', Tag::class, 'post_tag', 'post_id', 'tag_id'),
    ];
}
php
$posts = Post::with(['comments', 'tags'])
    ->orderBy('created_at', 'desc')
    ->paginate(10);
// $posts->items[0]['comments'] et ['tags'] déjà chargés — 3 requêtes au total (posts + comments + tags),
// quel que soit le nombre de posts sur la page.

Model::with() retourne un EagerLoadBuilder qui délègue where()/orderBy()/whereIn()/limit()/offset() au Query Builder sous-jacent, puis charge les relations demandées après get()/first()/paginate(). Trois helpers de bas niveau existent pour écrire sa propre relation eager-loadable : loadMany() (hasMany), loadOne() (belongsTo) et loadManyToMany() (pivot).

Migrations

Chaque migration est une classe anonyme retournée par le fichier, avec up()/down() :

database/migrations/2026_09_10_150001_create_posts_table.php

use Niang\Core\Database\Migration;
use Niang\Core\Database\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('posts', function ($table) {
            $table->id();
            $table->string('title');
            $table->text('body');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::drop('posts');
    }
};
bash
./bin/niang make:migration create_posts_table
./bin/niang migrate
./bin/niang migrate:rollback
./bin/niang migrate:fresh

make:migration create_posts_table préfixe le fichier d'un horodatage (2026_09_10_150001_...) et déduit le nom de table (posts) du motif create_<table>_table. Les migrations sont exécutées dans l'ordre du nom de fichier, suivies dans une table migrations (colonnes migration, batch) créée automatiquement au premier migrate. migrate:rollback annule uniquement le dernier lot (batch) ; migrate:fresh annule tout puis rejoue tout depuis zéro.

Schema expose aussi table() (modifier une table existante), rename(), drop() et dropIfExists() :

php
Schema::table('posts', function ($table) {
    $table->string('slug')->nullable();
    $table->renameColumn('body', 'content');
    $table->dropColumn('legacy_field');
});

Modifier une colonne : change()

php
Schema::table('posts', function ($table) {
    $table->string('title', 500)->change();            // agrandir
    $table->text('summary')->nullable()->change();     // changer le type, autoriser NULL
    $table->integer('views')->default(0)->change();
});

La définition complète remplace l'ancienne : répétez nullable() ou default() s'ils doivent être conservés. MySQL utilise MODIFY, PostgreSQL ALTER COLUMN (conversion de type avec USING). SQLite ne sait pas modifier une colonne : la table est reconstruite (copie des données, index recréés, clés étrangères désactivées le temps de l'opération).

Schema Builder : toutes les colonnes

Méthodes de Blueprint réellement implémentées (une instruction ADD COLUMN par colonne pour Schema::table() — limite volontaire, portable entre les trois moteurs) :

ColonneSignature
id()Clé primaire auto-incrémentée (nom id par défaut).
string($nom, $longueur = 255)VARCHAR.
text($nom)TEXT.
integer($nom)Entier.
boolean($nom)Booléen.
decimal($nom, $precision = 10, $scale = 2)Nombre décimal exact.
float($nom)Nombre à virgule flottante.
date($nom)Date seule.
dateTime($nom)Date et heure.
timestamp($nom)Identique à dateTime au niveau SQL.
json($nom)JSON natif (MySQL/Postgres) ou TEXT (SQLite).
bigInteger($nom)Entier 64 bits (BIGINT ; SQLite : INTEGER, déjà 64 bits).
uuid($nom = 'uuid')UUID : type natif UUID sous PostgreSQL, 36 caractères ailleurs. Générez la valeur avec uuid().
enum($nom, ['draft', 'published'])ENUM sous MySQL, VARCHAR + CHECK sous SQLite et PostgreSQL : la base refuse une valeur hors liste sur les trois moteurs.
foreignId($nom)Colonne entière de référence (ex. post_id) — chaînez ->constrained() pour la contrainte.
timestamps()Ajoute created_at et updated_at, défaut CURRENT_TIMESTAMP (renseignées par le Model, voir ci-dessus).
softDeletes()Ajoute deleted_at (nullable), pour un Model avec $softDeletes.

Modificateurs chaînables sur une colonne :

php
Schema::create('posts', function ($table) {
    $table->id();
    $table->string('title');
    $table->string('slug')->nullable()->unique();
    $table->foreignId('author_id')->constrained('users');
    $table->decimal('price', 8, 2)->default(0);
    $table->timestamps();

    $table->unique(['author_id', 'slug']);   // contrainte UNIQUE multi-colonnes
    $table->index('slug');                    // CREATE INDEX séparé
});
ModificateurEffet
->nullable()Retire NOT NULL.
->default($valeur)DEFAULT ... (accepte une Expression pour du SQL brut, ex. CURRENT_TIMESTAMP).
->unique()UNIQUE au niveau colonne.
->constrained($table = null, $column = 'id')Clé étrangère ; sans argument, devine la table depuis le nom (author_id → authors).

Contrainte de clé étrangère explicite (indépendamment de constrained()) et actions ON DELETE :

php
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
// ->nullOnDelete() ou ->restrictOnDelete() à la place de ->cascadeOnDelete()

Grammaire multi-SGBD

Niang\Core\Database\Grammar\{SQLiteGrammar,MySqlGrammar,PostgresGrammar} traduisent les types abstraits du Blueprint vers le SQL réel du moteur choisi par DB_CONNECTION (.env) — c'est la seule couche du framework qui connaît ces différences :

Type abstraitSQLiteMySQLPostgreSQL
id()INTEGER PRIMARY KEY AUTOINCREMENTBIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEYBIGSERIAL PRIMARY KEY
stringVARCHAR(n)VARCHAR(n)VARCHAR(n)
integerINTEGERINTINTEGER
foreignIdINTEGERBIGINT UNSIGNEDBIGINT
booleanBOOLEANTINYINT(1)BOOLEAN
decimalNUMERICDECIMAL(p,s)NUMERIC(p,s)
floatFLOATFLOATDOUBLE PRECISION
dateTime / timestampDATETIMEDATETIMETIMESTAMP
jsonTEXT (pas de type natif)JSONJSONB

Les identifiants sont aussi entourés différemment ("col" pour SQLite/PostgreSQL, `col` pour MySQL) — un détail que Grammar::wrap() gère seul, jamais visible depuis Blueprint, Schema ou une migration applicative. Le Query Builder entoure lui aussi chaque table et colonne (where, orderBy, select, insert...) : une colonne nommée rank, key, group ou order fonctionne sur les trois moteurs. Dans select(), une expression ('COUNT(*) AS total', 'UPPER(name) AS n') est transmise telle quelle.

Transactions

php
DB::transaction(function () use ($postId, $tagIds) {
    Post::update($postId, ['published' => true]);

    foreach ($tagIds as $tagId) {
        DB::statement('INSERT INTO post_tag (post_id, tag_id) VALUES (?, ?)', [$postId, $tagId]);
    }
});
// rollback automatique si le callback lève une exception ; sinon commit et retourne sa valeur

DB::beginTransaction(), DB::commit() et DB::rollBack() restent disponibles pour un contrôle manuel.

Les transactions s'imbriquent réellement via SAVEPOINT (PDO ne permet qu'une seule transaction active à la fois) : un DB::transaction() appelé depuis du code métier alors qu'une transaction est déjà ouverte plus haut (par exemple RefreshDatabase dans un test) ne lève pas d'exception — chaque niveau imbriqué n'annule que ses propres écritures en cas d'échec, jamais celles de la transaction englobante. DB::inTransaction() indique si une transaction (ou un niveau d'imbrication) est actuellement ouverte.

Séparer lecture et écriture

DB_READ_HOST/DB_READ_DATABASE/DB_READ_CONNECTION dans .env pointent les lectures vers un réplica ; sans eux, lecture et écriture partagent la même connexion (repli automatique, rien à configurer en développement). Les méthodes du Query Builder qui lisent (get(), first(), les agrégats...) utilisent la connexion 'read' par défaut, celles qui écrivent utilisent 'write' — ->onConnection('write') force une lecture sur la connexion d'écriture (ex. juste après un insert(), pour lire sa propre écriture sans latence de réplication).

Factories et seeders

Model::factory($definition) ne génère pas de fausses données automatiquement (zéro dépendance externe) : vous fournissez la closure qui construit un enregistrement.

database/seeders/DatabaseSeeder.php

use App\Models\Comment;
use App\Models\Post;
use Niang\Core\Database\Seeder;

return new class extends Seeder {
    public function run(): void
    {
        $postIds = Post::factory(fn () => [
            'title' => 'Article de démonstration',
            'body' => 'Contenu généré par le seeder NiangPro.',
        ])->count(3)->create();

        foreach ($postIds as $postId) {
            Comment::factory(fn () => [
                'post_id' => $postId,
                'body' => 'Commentaire de test.',
            ])->count(2)->create();
        }
    }
};

->make($overrides = []) retourne le(s) tableau(x) sans les insérer ; ->create($overrides = []) insère et retourne la liste des id créés. Les $overrides se fusionnent par-dessus la définition de base.

bash
./bin/niang make:seeder DatabaseSeeder
./bin/niang db:seed              # exécute database/seeders/DatabaseSeeder.php
./bin/niang db:seed Tags         # exécute database/seeders/TagsSeeder.php
⏱ 12.89 ms 🗄 0 requête(s) SQL 🧠 4.00 MB ↩ 200