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.
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éthode | Retourne |
|---|---|
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).
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 :
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
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 |
|---|---|
$timestamps | create() 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. |
$casts | int, 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. |
$softDeletes | destroy() renseigne deleted_at ; toutes les lectures, relations et eager loading compris, ignorent ces lignes. Migration : $table->softDeletes(). |
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 :
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éthode | SQL 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]) / whereNotBetween | WHERE 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
// 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
// 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
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 :
$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
// « 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 :
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');
}
| Helper | Usage |
|---|---|
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() :
/** 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'),
];
}
$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() :
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');
}
};
./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() :
Schema::table('posts', function ($table) {
$table->string('slug')->nullable();
$table->renameColumn('body', 'content');
$table->dropColumn('legacy_field');
});
Modifier une colonne : change()
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) :
| Colonne | Signature |
|---|---|
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 :
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é
});
| Modificateur | Effet |
|---|---|
->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 :
$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 abstrait | SQLite | MySQL | PostgreSQL |
|---|---|---|---|
id() | INTEGER PRIMARY KEY AUTOINCREMENT | BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY | BIGSERIAL PRIMARY KEY |
string | VARCHAR(n) | VARCHAR(n) | VARCHAR(n) |
integer | INTEGER | INT | INTEGER |
foreignId | INTEGER | BIGINT UNSIGNED | BIGINT |
boolean | BOOLEAN | TINYINT(1) | BOOLEAN |
decimal | NUMERIC | DECIMAL(p,s) | NUMERIC(p,s) |
float | FLOAT | FLOAT | DOUBLE PRECISION |
dateTime / timestamp | DATETIME | DATETIME | TIMESTAMP |
json | TEXT (pas de type natif) | JSON | JSONB |
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
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.
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.
./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