Search by

niangpro / framework

NiangPro

Le framework NiangPro (v2) : les 14 paquets niangpro/* réunis. Pour créer une application : composer create-project niangpro/niangpro mon-app.

v2.0.2 2026-09-28 20:32 UTC

README

Logo NiangPro

NiangPro

Un micro-framework PHP ultra simple, plus simple que Laravel : pas de magie, pas de compilation, juste du PHP.

Documentation complète (FR + EN) : https://app.niangprogrammeur.com

Installation

Pour démarrer un nouveau projet :

composer create-project niangpro/niangpro mon-app
cd mon-app
./bin/niang serve

Le projet créé contient votre application (app/, routes/, resources/views/, config/...) ; le framework est une dépendance, niangpro/framework, installée dans vendor/ et mise à jour avec composer update niangpro/framework. Il se compose de 14 paquets (niangpro/core, niangpro/http, niangpro/database, niangpro/auth...) utilisables aussi séparément.

Version 1.x : un projet créé était une copie du framework (composer create-project niangpro/framework:^1.5 mon-app, toujours disponible). Pour passer un projet 1.x en 2.0, voir UPGRADE.md.

Testé en CI sur Linux, macOS et Windows (voir .github/workflows/ci.yml, job cross-platform). Sous Windows en CMD/PowerShell natif (hors WSL/Git Bash, où ./bin/niang fonctionne tel quel), utilisez bin\niang serve (le shebang #!/usr/bin/env php n'y est pas interprété ; bin\niang.bat relaie automatiquement vers php bin\niang).

À la fin de l'installation, NiangPro vous demande quel type de site vous voulez construire et installe un thème visiteur complet et moderne (clair et sombre, responsive, accessible, sans CDN ni dépendance) :

Quel type de site souhaitez-vous construire ?

  1) vitrine    Site vitrine / informationnel
  2) ecommerce  Boutique en ligne
  3) blog       Blog / magazine
  4) portfolio  Portfolio
  5) landing    Landing page one-page
  6) auth       Application avec comptes (inscription, connexion, espace membre)
  7) api        API REST (JSON, jetons, CORS, OpenAPI)
  8) saas       SaaS (organisations, membres, abonnements)
  9) minimal    Minimal — squelette de démonstration (défaut)

Votre choix [9] :
Type Pages livrées
vitrine Accueil, à propos, services, réalisations, FAQ, contact, mentions légales, confidentialité, 404
ecommerce Accueil, catalogue (filtre, recherche, tri), fiche produit, panier, commande, compte client et « Mes commandes », à propos, contact, FAQ livraison et retours, CGV, mentions légales, 404 — sans paiement réel (voir Boutique en ligne)
blog Accueil, articles paginés, article (tags, articles proches), catégories et thèmes, à propos, contact, 404
portfolio Accueil, projets filtrables et pages de détail avec galerie, à propos et CV, contact, 404
landing Une page à sections ancrées (fonctionnalités, fonctionnement, avis, tarifs, FAQ), mentions légales, 404
auth Accueil, inscription, connexion, mot de passe oublié, vérification d'email, double authentification, OAuth, tableau de bord, « Mon compte »
api API REST JSON /api/v1 : comptes et jetons, ressource d'exemple, CORS, limitation de débit, description OpenAPI
saas Organisations (multi-locataire), membres et rôles, invitations, plans et abonnements, « Mon compte »
minimal Le squelette de démonstration, tel quel

Sans terminal interactif (CI, script, --no-interaction), la question n'est pas posée : minimal est utilisé, sauf si vous choisissez explicitement le type avec la variable d'environnement NIANG_SITE_TYPE :

NIANG_SITE_TYPE=blog composer create-project niangpro/niangpro mon-app --no-interaction

Visitez http://127.0.0.1:8000. Pour les types ecommerce et blog, la base est migrée et alimentée automatiquement à la création (setup du theme.json), avec un compte administrateur de test : admin@example.com / admin1234. Connectez-vous avec ce compte sur /login : vous arrivez sur le tableau de bord /admin (mot de passe à changer dans /admin/parametres avant toute mise en ligne).

Pour contribuer au framework lui-même (cloner ce dépôt directement ; l'application de démonstration y sert au développement et aux tests, ./bin/niang new essai crée une application reliée à votre copie locale) :

git clone https://github.com/NiangPro/niangpro.git
cd niangpro
composer install
cp .env.example .env
./bin/niang key:generate
./bin/niang serve

Structure

D'une application :

app/Controllers/      Vos contrôleurs
app/Middleware/       Vos middlewares
app/Models/           Vos modèles (Active Record minimal)
routes/web.php        Toutes vos routes
resources/views/      Vues PHP natives (pas de moteur de template)
resources/scaffold/   Thèmes de site proposés à la création d'un projet
public/index.php      Point d'entrée unique
vendor/niangpro/framework/   Le framework (ne pas modifier : composer update niangpro/framework)

De ce dépôt (le framework) :

packages/<nom>/src/   Les 14 paquets (core, http, database, auth...), namespace Niang\Core
skeleton/             composer.json et fichiers propres au squelette niangpro/niangpro
app/, routes/...      L'application de démonstration, base du squelette (tools/build-skeleton.php)

Routes

$router->get('/', [HomeController::class, 'index']);
$router->get('/hello/{name}', [HomeController::class, 'hello']);
$router->post('/echo', [HomeController::class, 'echoBody']);

$router->group(['prefix' => '/api', 'middleware' => [SomeMiddleware::class]], function ($router) {
    $router->get('/status', fn () => Response::json(['ok' => true]));
});

Les paramètres de route ({id}) sont injectés automatiquement dans les méthodes du contrôleur si le nom de paramètre correspond.

Contrôleurs

class HomeController extends Controller
{
    public function index(): Response
    {
        return $this->view('home', ['title' => 'Salut']);
    }
}

Middlewares

Implémentez Niang\Core\Middleware :

class LogRequest implements Middleware
{
    public function handle(Request $request, Closure $next): Response
    {
        // avant
        $response = $next($request);
        // après
        return $response;
    }
}

Attachez-le à une route : $router->get('/ping', $action, [LogRequest::class]);

Modèles (Active Record minimal)

class User extends Model
{
    // protected static string $table = 'users';

    // Colonnes qu'un formulaire peut remplir via create()/update() — les autres sont ignorées.
    protected static array $fillable = ['name', 'email', 'password'];
}

User::all();
User::find(1);
User::findOrFail(1);  // lève NotFoundException (404) si absent, plutôt que null
User::where('email', 'a@b.com');
User::create(['name' => 'Awa']);
User::update(1, ['name' => 'Fatou']);
User::destroy(1);

Protection contre l'affectation de masse : create() et update() ne gardent que les colonnes de $fillable. User::create($request->all()) ne peut donc pas écrire role ou email_verified_at, même si un visiteur ajoute ces champs au formulaire. 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, date de vérification d'email) : User::forceCreate([...]) et User::forceUpdate($id, [...]). Les factories passent par forceCreate().

Dates, types et suppression douce — trois propriétés facultatives :

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 (MySQL renvoie les entiers en chaînes) ;
    // json : tableau PHP <-> texte JSON en base.
    protected static array $casts = ['published' => 'bool', 'views' => 'int', 'options' => 'json'];

    // destroy() renseigne deleted_at au lieu de supprimer ; toutes les lectures l'ignorent.
    protected static bool $softDeletes = true;   // 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

Les lignes supprimées en douceur sont aussi ignorées par with() et les relations. Les dates sont écrites avec l'horloge PHP (date_default_timezone), pas CURRENT_TIMESTAMP de la base (UTC sous SQLite). Model::query()->where(...)->update([...]) (mise à jour en masse via le Query Builder) ne touche pas updated_at : passez-le explicitement.

Configurez la connexion dans .env (DB_CONNECTION=sqlite par défaut, ou mysql/pgsql).

Query Builder

Pour les requêtes plus riches qu'un find/where simple :

Post::query()
    ->select('id', 'title')
    ->where('published', true)
    ->orWhere('author_id', 1)
    ->whereIn('category_id', [1, 2, 3])
    ->join('users', 'posts.author_id', '=', 'users.id')
    ->groupBy('category_id')
    ->orderBy('created_at', 'desc')
    ->limit(10)
    ->offset(20)
    ->get();

Post::query()->where('id', 5)->update(['title' => 'Nouveau titre']);
Post::query()->where('published', true)->count();
Post::query()->where('id', 5)->lockForUpdate()->first(); // SELECT ... FOR UPDATE, dans une transaction
Post::query()->onConnection('read')->get(); // force la connexion 'read' ou 'write'

// Filtres supplémentaires
Post::query()->whereNull('deleted_at')->get();
Post::query()->whereBetween('views', [10, 1000])->get();
Post::query()->whereColumn('updated_at', '>', 'created_at')->get();
Post::query()->select('category_id')->distinct()->get();
Post::query()->groupBy('category_id')->having('id', '>', 1)->get();

// Agrégats
Post::query()->count();          // ou count('id')
Post::query()->sum('views');
Post::query()->avg('views');
Post::query()->min('views');
Post::query()->max('views');
Post::query()->where('id', 5)->exists();  // bool

Post::query()->where('id', 5)->firstOrFail();  // lève NotFoundException si aucune ligne

// 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
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
});

// Incrément calculé 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')]);

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();
// ->whereNotExists(...), ->whereNotIn('status', ['draft', 'archived'])

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

orderBy(), limit(), count() et paginate() s'appliquent au résultat combiné d'une union : triez par le nom de colonne tel qu'il apparaît dans le résultat (published_at, pas posts.published_at). Les requêtes ajoutées par union() ne peuvent pas être triées ni limitées elles-mêmes.

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 (par exemple active ci-dessus), sinon les paquets suivants sont décalés.

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 est refusé. where('prix', $_GET['op'], 10) ou update($_POST) ne peuvent pas injecter de SQL. Les valeurs, elles, sont toujours liées.

Migrations

./bin/niang make:migration create_posts_table
./bin/niang migrate
./bin/niang migrate:rollback
./bin/niang migrate:fresh
Schema::create('posts', function ($table) {
    $table->id();
    $table->string('title', 255);       // longueur par défaut : 255
    $table->text('body')->nullable();
    $table->integer('views')->default(0);
    $table->boolean('published')->default(false);
    $table->decimal('price', 8, 2)->nullable();  // precision, scale
    $table->float('rating')->nullable();
    $table->date('published_at')->nullable();
    $table->timestamp('deleted_at')->nullable();
    $table->json('metadata')->nullable();
    $table->bigInteger('downloads')->default(0);          // 64 bits (BIGINT)
    $table->uuid('public_id')->unique();                   // remplissez-la avec uuid()
    $table->enum('status', ['draft', 'published'])->default('draft');
    $table->foreignId('author_id')->constrained();  // -> table `authors`, colonne `id`
    $table->string('slug')->unique();
    $table->timestamps();               // created_at + updated_at

    $table->unique(['title', 'author_id']);  // contrainte unique multi-colonnes
    $table->index('published_at');
});

Chaque colonne accepte ->nullable(), ->default($valeur) et ->unique(), chaînables entre eux.

enum() devient un vrai ENUM en MySQL, et un VARCHAR avec une contrainte CHECK en SQLite et PostgreSQL : sur les trois moteurs, la base refuse une valeur hors liste. uuid() est un type natif UUID en PostgreSQL et 36 caractères ailleurs ; le helper uuid() génère une valeur (version 4).

Multi-SGBD (SQLite, MySQL, PostgreSQL)

Les migrations sont traduites par un Grammar propre à chaque moteur (DB_CONNECTION dans .env) : $table->id() génère INTEGER PRIMARY KEY AUTOINCREMENT en SQLite, BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY en MySQL, BIGSERIAL PRIMARY KEY en PostgreSQL — sans rien changer à vos migrations.

$table->foreignId('author_id')->constrained();                    // devine la table `authors`
$table->foreignId('author_id')->constrained('users');              // table explicite
$table->foreign('author_id')->references('id')->on('authors')      // syntaxe complète
    ->cascadeOnDelete();                                            // ou ->nullOnDelete() / ->restrictOnDelete()

$table->renameColumn('old', 'new');
$table->dropColumn('champ_obsolete');
Schema::rename('anciens_posts', 'posts');

En SQLite, les clés étrangères sont activées (PRAGMA foreign_keys = ON) — comme en MySQL/PostgreSQL, une insertion référençant une ligne inexistante est rejetée.

Modifier une colonne existante : la nouvelle définition remplace entièrement l'ancienne (type, NULL, valeur par défaut).

Schema::table('posts', function ($table) {
    $table->string('title', 500)->nullable()->change();
    $table->integer('views')->default(0)->change();
});

MySQL : MODIFY COLUMN ; PostgreSQL : ALTER COLUMN (type avec conversion USING, NULL, défaut) ; SQLite, qui ne sait pas modifier une colonne : reconstruction de la table selon la procédure officielle (données, index et clés étrangères conservés). ->unique() ne se combine pas avec change() : ajoutez l'index à part.

Seeders & factories

./bin/niang make:seeder DatabaseSeeder
./bin/niang db:seed
Post::factory(fn () => [
    'title' => 'Article ' . random_int(1, 999),
    'body' => 'Contenu...',
])->count(10)->create();

Pas de génération de fausses données intégrée (pas de dépendance externe type Faker) : vous fournissez la closure qui décrit un enregistrement.

Relations

Les enregistrements restent de simples tableaux (pas d'hydratation d'objets) ; les relations sont des méthodes statiques qui interrogent la table liée :

class Post extends Model
{
    public static function author(int|string $authorId): ?array
    {
        return static::hasOne($authorId, Author::class, 'post_id');
    }

    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');
    }
}

class Comment extends Model
{
    public static function post(array $comment): ?array
    {
        return static::belongsTo($comment, Post::class, 'post_id');
    }
}

Eager loading (éviter le N+1)

Appeler Post::comments($id) pour chaque post d'une liste déclenche une requête par post (N+1). Model::with() charge chaque relation en une seule requête pour toute la collection, via whereIn (hasMany/belongsTo) ou une jointure (belongsToMany) :

class Post extends Model
{
    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'),
        ];
    }
}

class Comment extends Model
{
    public static function eagerLoadable(): array
    {
        return [
            'post' => fn (array $comments) => static::loadOne($comments, 'post', Post::class, 'post_id'),
        ];
    }
}

Post::with(['comments', 'tags'])->get();               // 3 requêtes, quel que soit le nombre de posts
Post::with('comments')->where('published', true)->get();
Post::with('comments')->first();
Post::with('comments')->paginate(10, $page);

with() renvoie un EagerLoadBuilder qui délègue where/orderBy/limit/etc. au QueryBuilder sous-jacent, puis charge les relations déclarées après get()/first()/paginate(). Trois méthodes protégées de Model couvrent les trois types de relation : loadMany() (hasMany), loadOne() (belongsTo), loadManyToMany() (belongsToMany via pivot) — toujours des tableaux associatifs, jamais d'objets hydratés.

Pour vérifier qu'un endroit précis n'a plus de N+1, DB::resetQueryCount() puis DB::queryCount() donnent le nombre exact de requêtes exécutées entre les deux appels.

Transactions

DB::transaction(function () {
    $postId = Post::create([...]);
    Comment::create(['post_id' => $postId, ...]);
});

Rollback automatique si une exception est levée à l'intérieur.

Connexions lecture / écriture

Par défaut, une seule connexion sert tout. Pour séparer lecture et écriture, ajoutez dans .env :

DB_READ_HOST=replica.example.com
DB_READ_DATABASE=niangpro

DB::select() utilise alors automatiquement la connexion read, et les écritures restent sur write. Sans ces variables, les deux pointent vers la même base — rien à configurer par défaut.

Sessions, CSRF & messages flash

Session::put('user_id', 1);
Session::get('user_id');

$this->redirect('/contact')->with('success', 'Message envoyé !');
// dans la vue :
flashed('success');

Ajoutez VerifyCsrfToken::class aux routes POST/PUT/PATCH/DELETE qui traitent des formulaires HTML, et déposez <?= csrf_field() ?> dans le <form>. Les routes API en JSON n'en ont pas besoin.

Validation

class ContactController extends Controller
{
    public function store(Request $request): Response
    {
        $this->validate($request, [
            'email' => 'required|email',
            'message' => 'required|string|min:10',
        ]);
        // ...
    }
}

Règles disponibles :

required          string            numeric           integer
boolean           array             email             url
date              date_format:F     min:n              max:n
between:min,max   in:a,b,c          not_in:a,b,c       same:champ
different:champ   regex:/motif/     confirmed          nullable
required_if:champ,valeur           required_with:champ
required_without:champ             unique:table,colonne[,id_à_ignorer]
exists:table,colonne

nullable : si le champ est vide, les autres règles ne s'appliquent pas (sinon un champ absent y échoue normalement). unique/exists interrogent la base — unique:users,email,5 ignore la ligne d'id 5 (cas « je modifie mon propre profil »). min/max/between comparent une longueur de chaîne ou une valeur numérique selon le type du champ.

Validation d'un tableau, élément par élément :

$this->validate($request, ['items.*.name' => 'required|string']);
// erreurs indexées : items.0.name, items.1.name...

Messages et noms de champs personnalisés (aussi disponibles sur FormRequest via messages() et attributes()) :

$this->validate($request, ['email' => 'required|email'],
    messages: ['email.required' => 'Merci de renseigner votre email.'],
    attributes: ['email' => 'Adresse email']
);

Si la validation échoue : redirection automatique vers la page précédente avec les erreurs et l'ancienne saisie en flash (errors('email'), old('email')), ou réponse JSON 422 si la requête attend du JSON.

Langues (i18n)

Les textes que voit un visiteur — messages de validation, erreurs d'upload, pages d'erreur, messages 401/403/419/429, pagination — viennent de lang/<langue>/*.php, livrés en français (défaut) et en anglais :

APP_LOCALE=en
__('validation.required', ['attribute' => 'email']);   // « The email field is required. »
__('http.404');                                         // « Page not found. »
Lang::setLocale('en');                                  // pour la requête en cours (ex. dans un middleware)

Les fichiers de lang/ appartiennent à votre projet : modifiez un message directement, ajoutez une langue en copiant lang/en/ vers lang/es/, ou donnez un libellé lisible à vos champs dans la section attributes de validation.php ('email' => 'adresse email', 'items.*.name' => 'nom de l\'article'). :attribute, :min... sont remplacés ; :Attribute met la première lettre en majuscule. Une clé absente de la langue courante est cherchée dans APP_FALLBACK_LOCALE (fr), puis affichée telle quelle.

Pour choisir la langue par visiteur, un middleware suffit :

class SetLocale implements Middleware
{
    public function handle(Request $request, \Closure $next): Response
    {
        $wanted = substr((string) $request->header('Accept-Language', 'fr'), 0, 2);
        Lang::setLocale(in_array($wanted, ['fr', 'en'], true) ? $wanted : 'fr');

        return $next($request);
    }
}

Pluriels : plusieurs formes séparées par |, choisies selon le nombre.

// lang/fr/panier.php
return [
    'articles' => 'un article|:count articles',
    'etat' => '{0} Votre panier est vide|{1} Un article|[2,9] :count articles|[10,*] Plus de :count articles',
];

trans_choice('panier.articles', 3);       // « 3 articles » (:count remplacé automatiquement)
Lang::choice('panier.etat', 0);            // « Votre panier est vide »

Les formes {n} et [min,max] (* = sans limite) sont testées d'abord. Sinon la règle de la langue choisit : en français, 0 et 1 sont au singulier (« 0 article »), en anglais seul 1 l'est (« 0 items »).

Les messages destinés au développeur (exceptions internes, CLI) restent en français.

Gestion des erreurs

Tout passe par un point d'entrée unique : Niang\Core\Exceptions\Handler. Le routeur, les middlewares (CSRF, rate limiting, authentification) et vos contrôleurs n'ont plus besoin de construire eux-mêmes une réponse d'erreur — il suffit de lever une exception.

abort(404);
abort(404, 'Article introuvable.');           // message personnalisé (visible en JSON)
abort(403, 'Réservé aux administrateurs.');

throw new \Niang\Core\Exceptions\NotFoundException('Article introuvable.');
throw new \Niang\Core\Exceptions\AuthenticationException();

Le Handler adapte automatiquement la réponse :

  • Client JSON (Accept: application/json) → {"message": "..."} avec le bon statut.
  • Client HTML → la page dédiée si elle existe (resources/views/errors/404.php, 403.php, 500.php), sinon resources/views/errors/generic.php avec le message.
  • APP_DEBUG=true → page de debug complète (exception, fichier:ligne, requête, route, utilisateur connecté, durée, stack trace) — jamais affichée si APP_DEBUG=false.
  • Une \PDOException est toujours journalisée et jamais montrée telle quelle en production (le SQL et la chaîne de connexion ne doivent pas fuiter).

Créez resources/views/errors/{code}.php (404, 403, 500...) pour personnaliser une page précise ; resources/views/errors/generic.php sert de filet pour tous les autres statuts (401, 419, 429...).

Logs

Log::info('Utilisateur {id} connecté', ['id' => $user['id']]);
Log::error('Échec du paiement', ['order' => $orderId]);

Niveaux disponibles (style PSR-3) : emergency, alert, critical, error, warning, notice, info, debug. Les {clé} dans le message sont remplacées par les valeurs correspondantes du tableau de contexte. Un fichier par jour dans storage/logs/. Les exceptions non interceptées y sont aussi consignées automatiquement.

LOG_CHANNEL=daily   # daily (un fichier par jour), single, errorlog, syslog ou stderr (Docker)
LOG_LEVEL=warning   # niveau minimal écrit (debug par défaut) : warning ignore debug, info et notice
LOG_DAYS=14         # jours de fichiers conservés ; 0 = ne jamais supprimer

Les fichiers plus anciens que LOG_DAYS sont supprimés au premier message de chaque journée. Une valeur de contexte dont la clé évoque un secret (password, token, secret, api_key, authorization, cookie, card, cvv, iban...) est remplacée par [masqué], à n'importe quelle profondeur : Log::info('Connexion', $request->all()) n'écrit pas le mot de passe. niang doctor signale un LOG_LEVEL inconnu (tout est alors journalisé) et debug en production. Config::set('logging.level', 'error') change la valeur pour la suite du process (tests).

Pour injecter un logger plutôt qu'appeler la façade statique (interop avec du code tiers, tests avec un mock), Niang\Core\Logger implémente Psr\Log\LoggerInterface et écrit dans les mêmes fichiers — voir PSR-11 et PSR-3.

Vues : layouts, composants, échappement

Toujours du PHP natif — pas de compilateur de templates, pas de cache à invalider. Juste deux helpers :

<?php layout('layouts.app', ['title' => 'Contact']); ?>

<h1>Contact</h1>
<p>Bonjour <?= e($name) ?></p>

layout() injecte le contenu déjà rendu de la vue courante dans $content de layouts.app. component('components/field-errors', ['field' => 'email']) inclut un fragment réutilisable et retourne son HTML. e($valeur) échappe pour l'affichage (alias court de htmlspecialchars) — à utiliser à chaque sortie de donnée utilisateur.

Autres helpers globaux utiles : view('home', ['title' => 'Salut']) retourne directement une Response HTML (équivalent de $this->view() en dehors d'un contrôleur) ; json_response($data, 201) retourne une Response JSON ; dd($valeur, ...) (dump and die) affiche une variable et arrête l'exécution — pratique en debug, à retirer avant de committer.

Pagination

$paginator = Post::paginate(10, (int) $request->input('page', 1));
// $paginator->items, ->total, ->lastPage(), ->hasMorePages()

Dans la vue :

<?php foreach ($paginator->items as $post): ?>...<?php endforeach; ?>
<?= $paginator->links('/blog') ?>

Pour les grandes tables, deux variantes sans COUNT(*) :

// « Précédent / Suivant » seulement : une ligne de plus est lue pour savoir s'il reste une page
$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'));             // tri par id croissant
$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 (la base parcourt toutes les lignes sautées) 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 à la page 7 directement. Triez par une colonne unique (id par défaut). Le curseur est une valeur opaque, toujours liée comme paramètre ; la colonne vient du code, jamais du curseur. JsonResource::collection() accepte les trois (méta current_page/per_page, ou next_cursor).

Documentation OpenAPI

./bin/niang openapi                                  # public/openapi.json, routes commençant par /api
./bin/niang openapi --prefix=/v2 --output=docs/api.json
./bin/niang openapi --prefix=/                       # toutes les routes

Le fichier (OpenAPI 3.0) est généré à partir des routes réelles, jamais retapé : chemins, méthodes, paramètres (typés integer si leur contrainte where() est numérique), corps de requête déduit des règles de la FormRequest injectée dans l'action (types, formats email/url/uuid/date, in, min/max, tableaux imbriqués items.*.x, fichiers en multipart/form-data), authentification (session ou Bearer, d'après les middlewares), résumé et description tirés du docblock de l'action, et réponses 401, 403, 404, 422 et 429 quand elles s'appliquent. Il s'ouvre dans n'importe quel outil OpenAPI (Swagger UI, Redoc, Postman, Insomnia) ou sert à générer un client.

Deux choses ne sont pas déduites : une validation écrite dans le corps de l'action ($this->validate(...) au lieu d'une FormRequest) et la forme des réponses. Relancez la commande à chaque déploiement (ou dans la CI) pour que le fichier reste à jour. OpenAPI reste facultatif.

API : JSON Resources & CORS

JsonResource enveloppe un enregistrement dans {"data": ...} — surchargez toArray() pour choisir exactement les champs exposés (masquer un mot de passe haché, renommer une clé, ajouter un champ calculé) plutôt que de renvoyer l'enregistrement brut de la base :

class PostResource extends JsonResource
{
    public function toArray(): array
    {
        return [
            'id' => $this->resource['id'],
            'title' => $this->resource['title'],
            'comments_count' => count($this->resource['comments'] ?? []),
        ];
    }
}

// un seul enregistrement
(new PostResource($post))->toResponse();

// une collection (tableau ou Paginator) -> {"data": [...], "meta": {...}, "links": {...}}
PostResource::collection(Post::with('comments')->paginate(10, $page))->toResponse();

Pas de JSON:API complet imposé : juste data/meta/links, la partie utile sans la complexité de la spec entière. meta (current_page, last_page, per_page, total) et links (prev/next) n'apparaissent que si la source est un Paginator.

CORS se configure dans config/cors.php (allowed_origins, allowed_methods, allowed_headers, exposed_headers, supports_credentials, max_age) et s'applique via le middleware App\Middleware\HandleCors :

$router->group(['prefix' => '/api', 'middleware' => [HandleCors::class]], function ($router) {
    $router->get('/posts', [PostController::class, 'apiIndex']);
    $router->options('/posts', fn () => Response::html('', 204)); // chaque route API a besoin de sa contrepartie OPTIONS
});

HandleCors répond directement au préflight OPTIONS (204, sans exécuter la route ni ses autres middlewares) et ajoute les en-têtes Access-Control-* à la vraie réponse. Avec supports_credentials: true, l'origine exacte de la requête est toujours reflétée (jamais *, que les navigateurs rejettent dans ce cas) — sans credentials et avec allowed_origins: ['*'], c'est * littéral.

Routes nommées, contraintes, ressources REST

$router->get('/posts/{id}', [PostController::class, 'show'])
    ->name('posts.show')
    ->where(['id' => '[0-9]+']); // /posts/abc ne matche plus

route('posts.show', ['id' => 5]); // '/posts/5'

Liaison de modèle : le paramètre devient directement la ligne, ou une 404 si elle n'existe pas.

$router->get('/posts/{post}', [PostController::class, 'show'])->bind(['post' => Post::class]);           // par id
$router->get('/blog/{post}', [PostController::class, 'show'])->bind(['post' => Post::class . ':slug']);  // par slug

public function show(array $post): Response   // même nom que le paramètre
{
    return $this->view('posts/show', ['post' => $post]);
}

La recherche passe par le modèle (suppression douce et $casts respectés) et a lieu après les middlewares : un visiteur non authentifié ne peut pas sonder l'existence d'une ligne. Elle ne vérifie pas les droits : pour /users/{user}/invoices/{invoice}, contrôlez vous-même que la facture appartient bien à l'utilisateur (ou passez par une Policy), sinon changer l'identifiant dans l'URL suffit à lire celle d'un autre. Compatible avec route:cache.

$router->resource('tags', TagController::class);
// génère : GET /tags (index), GET /tags/create, POST /tags (store),
// GET /tags/{id} (show), GET /tags/{id}/edit, PUT /tags/{id} (update), DELETE /tags/{id} (destroy)
// nommées tags.index, tags.create, tags.store, tags.show, tags.edit, tags.update, tags.destroy

Une seule action pour plusieurs méthodes :

$router->match(['GET', 'POST'], '/contact', [ContactController::class, 'handle']);

Route de secours quand rien ne correspond (remplace la 404 par défaut) :

$router->fallback(function () {
    return json_response(['message' => 'Page introuvable.'], 404);
});

HEAD fonctionne automatiquement sur toute route GET (corps vidé, en-têtes/statut conservés) — pas besoin de la déclarer. Pour un comportement HEAD différent du GET correspondant, déclarez-le explicitement avec $router->head(...) ; elle garde alors entièrement la main sur sa réponse.

Sous-domaines

$router->domain('{tenant}.niangpro.test', function ($router) {
    $router->get('/', [TenantController::class, 'index']);
    // $request->param('tenant') disponible dans le contrôleur
});

Cache de routes (production)

./bin/niang route:cache   # compile routes/web.php -> storage/framework/routes.php
./bin/niang route:clear   # supprime le cache

Limite assumée : les routes définies avec une closure ne sont pas sérialisables et sont exclues du cache (avec un avertissement à la compilation). Utilisez des contrôleurs ([Controller::class, 'method']) pour les routes qui doivent survivre au cache — c'est de toute façon la pratique recommandée.

FormRequest (validation automatique à l'injection)

./bin/niang make:request ContactRequest
class ContactRequest extends FormRequest
{
    public function rules(): array
    {
        return ['email' => 'required|email'];
    }

    // public function authorize(): bool { return true; }
}

Type-hintez-la directement dans le contrôleur : le Container la construit, vérifie authorize() puis valide rules() avant d'appeler la méthode — plus besoin d'appeler $this->validate() :

public function store(ContactRequest $request): Response
{
    $data = $request->validated();
}

Tinker

./bin/niang tinker
> App\Models\Post::all()

REPL minimal (pas de complétion façon PsySH) : tape du PHP, exit pour sortir.

Authentification

Convention : app/Models/User.php avec les colonnes email et password.

Auth::attempt($email, $password); // true/false, connecte si succès
Auth::login($user);
Auth::logout();
Auth::check();  // true si connecté
Auth::guest();  // true si non connecté (inverse de check())
Auth::user();   // le tableau utilisateur, ou null
Auth::id();

$user['password'] = Hash::make($plain); // Argon2id si dispo, sinon bcrypt

Un autre modèle ? Auth::useModel(MonUser::class).

Se souvenir de moi. Auth::attempt($email, $password, remember: true) (ou Auth::login($user, remember: true)) pose un cookie chiffré de 30 jours, qui reconnecte l'utilisateur quand sa session a expiré. Il faut la colonne users.remember_token (migration fournie). La case est déjà présente sur /login.

Auth::logout();            // déconnecte cet appareil ; les autres appareils mémorisés le restent
Auth::logoutEverywhere();  // invalide aussi le « se souvenir de moi » de tous les appareils

Rehachage automatique. À chaque connexion réussie, un mot de passe haché avec d'anciens paramètres (bcrypt, puis Argon2id devenu disponible, ou un coût relevé) est recalculé et enregistré : c'est le seul moment où le mot de passe en clair est connu. Hash::needsRehash($hash) fait la vérification. Un email inconnu coûte le même temps de calcul qu'un mauvais mot de passe : la durée de la réponse ne révèle pas quels comptes existent.

Double authentification (TOTP). Compatible avec Google Authenticator, Microsoft Authenticator, Aegis, 1Password… (codes à 6 chiffres renouvelés toutes les 30 secondes, RFC 6238), sans dépendance. Les pages sont fournies : /user/two-factor pour l'activer (mot de passe demandé, clé et lien otpauth:// à ajouter dans l'application, 8 codes de secours affichés une seule fois, puis un premier code pour confirmer) et /two-factor-challenge, demandé après le mot de passe.

if (Auth::attempt($email, $password)) {
    if (Auth::twoFactorPending()) {          // mot de passe correct, mais pas encore connecté
        return redirect vers la saisie du code;
    }
}
Auth::completeTwoFactor($code);              // code de l'application ou code de secours

TwoFactor::enable($user);                    // ['secret', 'uri', 'recovery_codes'], à confirmer
TwoFactor::confirm($user, $code);            // la double authentification devient obligatoire
TwoFactor::disable($user);
TwoFactor::regenerateRecoveryCodes($user);

Le secret est chiffré en base avec APP_KEY, les codes de secours ne sont stockés que sous forme d'empreintes et servent une fois, et un code déjà utilisé est refusé (pas de rejeu). La saisie du code doit se faire dans les 5 minutes, et la route est limitée en débit. POST /api/tokens exige aussi le code (champ code) : un jeton API ne contourne pas la double authentification. Pas de QR code intégré (il faudrait un encodeur) : la clé s'affiche en groupes de 4 caractères, et le lien otpauth:// ouvre directement l'application sur mobile ; pour un QR code, passez $setup['uri'] à la bibliothèque JavaScript de votre choix.

Connexion avec Google ou GitHub (OAuth 2). Sans dépendance. Créez une application chez le fournisseur (Google, GitHub), déclarez l'URL de retour APP_URL/auth/google/callback (ou github), puis dans .env :

GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...

Le bouton « Continuer avec GitHub » apparaît alors sur /login (routes /auth/{provider}/redirect et /auth/{provider}/callback, 404 tant qu'un fournisseur n'est pas configuré). À la connexion, le compte est retrouvé par son email, ou créé (email marqué vérifié, mot de passe aléatoire : « mot de passe oublié » permet d'en choisir un). Pour vos propres routes :

return OAuth::redirect('github');
$profil = OAuth::user('github', $request); // ['id', 'email', 'email_verified', 'name', 'avatar', 'raw']
Auth::loginOrRequireTwoFactor($user);       // connecte, ou exige le code si la 2FA est activée

Protections : state aléatoire à usage unique (un lien de retour forgé ou rejoué est refusé), PKCE (un code intercepté est inutilisable), secret client jamais envoyé au navigateur. Seul un email vérifié par le fournisseur désigne un compte : sans cette règle, créer chez le fournisseur une adresse non vérifiée identique à celle d'un compte suffirait à s'y connecter. La double authentification reste exigée.

Protégez une route avec Authenticate::class (redirige vers /login, ou 401 JSON si la requête l'attend) ; empêchez l'accès aux pages login/register une fois connecté avec RedirectIfAuthenticated::class.

Autorisation (Gates & Policies)

Une règle isolée : Gate::define().

Gate::define('view-admin', fn (?array $user) => $user && $user['role'] === 'admin');

Dans un contrôleur :

$this->authorize('view-admin'); // lève une 403 si refusé

Gate::allows('view-admin'); // true/false, sans lever d'exception
Gate::denies('view-admin'); // inverse de allows()

Plusieurs règles autour d'un même modèle : une Policy, plutôt qu'une longue liste de closures. Les enregistrements restant de simples tableaux (pas d'objets), la policy se choisit par le préfixe de l'ability ('post.delete' → policy 'post'), résolu vers la méthode du même nom :

class PostPolicy
{
    public function delete(?array $user, array $post): bool
    {
        return $user && $post['author_id'] === $user['id'];
    }
}

Gate::policy('post', PostPolicy::class);

$this->authorize('post.delete', $post); // résout PostPolicy::delete(Auth::user(), $post)

Gate::define() reste prioritaire si une ability du même nom existe des deux côtés — utile pour surcharger ponctuellement une règle de policy sans y toucher.

Rôles et permissions

Pour ne pas écrire une règle par action : un rôle par utilisateur (colonne users.role, fournie par le module d'administration des thèmes boutique et blog, sinon $table->string('role')->default('user')), et les permissions de chaque rôle dans config/permissions.php :

'roles' => [
    'admin' => ['*'],                                // tout
    'editor' => ['posts.*', 'comments.moderate'],    // posts.create, posts.update...
    'user' => [],
],
Auth::hasRole('admin');            // ou ['admin', 'editor']
Auth::can('posts.update');         // = Gate::allows('posts.update')

// Route protégée : plusieurs abilities séparées par des virgules sont toutes exigées
$router->delete('/posts/{id}', [PostController::class, 'destroy'], [Authorize::class . ':posts.delete']);

Gate::allows() consulte d'abord les règles define() puis les Policies ; les permissions de rôle ne décident que si aucune des deux ne répond pour cette ability. Un invité n'a aucune permission ; Authorize le renvoie vers /login (401 en JSON) et répond 403 à un utilisateur sans la permission. Plus généralement, un middleware de route reçoit des arguments après : (MonMiddleware::class . ':a,b' → handle($request, $next, 'a', 'b')).

Rate limiting

ThrottleRequests::class limite par défaut à 10 requêtes/minute par IP et par route (utile sur /login, /register, tout formulaire public). Besoin d'une autre limite ailleurs ? Dupliquez la classe avec vos valeurs plutôt que d'ajouter un système de configuration générique.

En-têtes de sécurité

Appliqués automatiquement à toutes les réponses par Application (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Content-Security-Policy, Strict-Transport-Security) — sûr par défaut, sans middleware à ajouter. Ajustez-les dans Application::applySecurityHeaders() si besoin (par exemple un CSP plus permissif pour charger un script tiers).

Toutes les requêtes SQL de l'ORM et du Query Builder passent par des requêtes préparées PDO — aucune concaténation de valeurs utilisateur dans le SQL, nulle part.

Cache applicatif

Cache::remember('posts.index', 60, fn () => Post::all()); // TTL 60s
Cache::put('clé', $valeur, 300);
Cache::forget('clé');

Cache::increment('visites.accueil');      // 1, 2, 3... (0 si absente) ; decrement() aussi
Cache::increment('stock.42', -3);

increment() et decrement() sont atomiques : deux requêtes simultanées ne perdent pas d'incrément (verrou sur fichier, ou compare-and-swap en base). Le TTL d'une valeur existante est conservé ; une valeur qui n'est pas un entier lève une erreur plutôt que d'être écrasée.

Trois pilotes (CACHE_DRIVER) : file (défaut, storage/framework/cache/), database (voir ci-dessous) et array (mémoire du process, vidée à chaque requête : pour les tests et la CLI). Pas de dépendance à Redis/Memcached. SESSION_DRIVER=array existe aussi pour les tests (jamais pour un site : le visiteur serait déconnecté à chaque page). Avec CACHE_DRIVER=array, la limitation de débit reste sur fichier.

Plusieurs serveurs web (sessions, cache et limitation de débit partagés)

Derrière un répartiteur de charge, chaque serveur a ses propres fichiers : un visiteur envoyé sur un autre serveur perd sa session (déconnecté, panier vide), le cache n'est pas partagé, et une limite « 10 tentatives de connexion par minute » devient 10 × le nombre de serveurs. Tout passe en base de données avec deux variables :

SESSION_DRIVER=database
CACHE_DRIVER=database

puis ./bin/niang migrate (tables sessions, cache_entries, rate_limits, livrées avec le framework ; niang doctor signale une table manquante). CACHE_DRIVER pilote aussi RateLimiter, dont l'incrément reste atomique (un seul UPDATE conditionnel). Aucun verrou par session en base : si deux requêtes simultanées du même visiteur modifient la session, la dernière écriture l'emporte.

Redis

Pour le cache, les sessions, la file d'attente et la limitation de débit, redis remplace file ou database : CACHE_DRIVER=redis, SESSION_DRIVER=redis, QUEUE_DRIVER=redis, avec REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_DB et REDIS_PREFIX (toutes les clés de l'application commencent par ce préfixe ; Cache::flush() n'efface qu'elles, jamais la base Redis entière). Un hôte en tls:// chiffre la connexion (Upstash, ElastiCache...).

Le client Redis est écrit à la main (protocole RESP), sans extension ni bibliothèque. Les opérations qui doivent être atomiques (incrément de cache, limitation de débit, réservation d'un job) sont des scripts Lua exécutés par Redis. Utilisable directement : Redis::command('INCR', Redis::connection()->key('visites')). niang doctor vérifie que Redis répond. Redis 6 ou plus.

Tâches planifiées

Les tâches se déclarent dans routes/schedule.php ; une seule ligne cron les lance toutes :

$schedule->command('queue:work')->everyMinute()->withoutOverlapping();
$schedule->command('db:seed', ['Tags'])->dailyAt('03:00');
$schedule->call(fn () => Cache::forget('stats'), 'vider les statistiques')->hourly();
$schedule->job(new SendWeeklyReportJob())->weeklyOn(1, '08:00');   // 0 = dimanche
* * * * * cd /chemin/vers/le/projet && php bin/niang schedule:run >> /dev/null 2>&1
./bin/niang schedule:list   # tâches, expression cron, prochaine exécution

Fréquences : everyMinute(), everyFiveMinutes()/Ten/Fifteen/Thirty, hourly(), hourlyAt(17), daily(), dailyAt('03:00'), weekly(), weeklyOn(), monthly(), monthlyOn(), weekdays(), weekends(), ou cron('30 8 * * 1-5'). Une commande tourne dans un process séparé (un plantage ou un exit() n'arrête pas les autres tâches) ; une closure, dans l'application démarrée ; un job est poussé sur la file. withoutOverlapping() saute une exécution tant que la précédente tourne. Une tâche en échec est journalisée et schedule:run sort avec le code 1. Sur plusieurs serveurs, ne placez la ligne cron que sur l'un d'eux.

Jobs différés

./bin/niang queue:work
class SendWelcomeEmailJob extends Job
{
    public int $tries = 3; // par défaut : 1 (aucune retentative)

    public function __construct(private string $email) {}
    public function handle(): void { /* ... */ }
}

Queue::push(new SendWelcomeEmailJob($email));
Queue::later(300, new SendWelcomeEmailJob($email)); // dû dans 5 minutes

Pas de démon fourni : lancez queue:work via cron (ou le planificateur), ou en boucle. Trois pilotes, choisis par QUEUE_DRIVER :

Pilote Stockage Usage
file (défaut) storage/framework/queue/ un seul serveur
database tables jobs et failed_jobs (./bin/niang migrate) plusieurs serveurs ou workers : chaque job est réservé par un seul worker, même sur une autre machine
sync aucun exécution immédiate, sans worker (développement, tests)

Avec database, un job réservé par un worker qui s'est arrêté en cours de route est rendu à la file après QUEUE_RETRY_AFTER secondes (600 par défaut) : cette valeur doit dépasser la durée de votre plus long job. niang doctor signale des tables manquantes.

Un job qui échoue est retenté jusqu'à Job::$tries fois, avec un backoff exponentiel (10s, 20s, 40s...) entre les tentatives, puis déplacé vers les jobs échoués :

./bin/niang queue:failed          # liste les jobs qui ont épuisé leurs tentatives
./bin/niang queue:retry <id>      # remet un job échoué en file, tentatives réinitialisées
./bin/niang queue:flush           # supprime définitivement tous les jobs échoués

Stockage de fichiers

Deux disques, choisis par FILESYSTEM_DISK : local (défaut, storage/app/) et s3 (AWS S3, Cloudflare R2, MinIO, Wasabi, Scaleway... tout service compatible S3). Même API sur les deux :

Storage::put('avatars/1.png', $contents);                  // 3e argument facultatif : type MIME
Storage::get('avatars/1.png');     // contenu, ou null si absent
Storage::exists('avatars/1.png');
Storage::delete('avatars/1.png');
Storage::size('avatars/1.png');    // en octets, ou null
Storage::url('avatars/1.png');     // local : '/storage/avatars/1.png' ; s3 : URL publique (AWS_URL) ou de l'objet
Storage::temporaryUrl('factures/1.pdf', 300);  // lien de lecture valable 5 minutes, sans rendre le fichier public
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_DEFAULT_REGION=eu-west-3
AWS_BUCKET=mon-bucket
AWS_ENDPOINT=https://<compte>.r2.cloudflarestorage.com   # service compatible ; vide pour AWS
AWS_URL=https://cdn.exemple.sn                           # facultatif : URL publique (CDN)

Le client S3 est écrit à la main (signature AWS SigV4, vérifiée contre les exemples publiés par AWS et un serveur S3 local en CI), sans SDK. temporaryUrl() donne une URL pré-signée sur S3, et une URL signée (UrlSignature) sur le disque local. $file->store('avatars') envoie le fichier sur le disque choisi. Storage::path() n'existe que sur le disque local. niang doctor signale une configuration S3 incomplète.

Les chemins contenant .. sont rejetés (InvalidArgumentException) : sans ça, un chemin construit à partir d'une entrée utilisateur pourrait écrire ou lire en dehors de storage/app/.

Upload de fichiers

$request->file('champ') retourne un Niang\Core\Http\UploadedFile (ou null). Le nom et le type envoyés par le navigateur ne sont jamais crus : le type réel est lu dans le contenu du fichier, et store() range le fichier dans storage/app/ (hors de public/, donc jamais exécutable) sous un nom aléatoire suivi de l'extension réelle :

public function updateAvatar(Request $request): Response
{
    $data = $this->validate($request, [
        'avatar' => 'required|image|max:2048|dimensions:min_width=100,min_height=100',
    ]);

    $path = $data['avatar']->store('avatars');   // 'avatars/3f9c...e1.png'
    User::update(Auth::id(), ['avatar' => $path]);

    return $this->redirect('/profil');
}
<form method="POST" action="/profil/avatar" enctype="multipart/form-data">
    <?= csrf_field() ?>
    <input type="file" name="avatar" accept="image/*">
</form>

Règles de validation : file, image (JPEG, PNG, GIF, WebP, AVIF — SVG exclu, car il peut contenir du JavaScript ; autorisez-le explicitement avec mimes:svg), mimes:jpg,png,pdf (extension déduite du contenu), mimetypes:application/pdf,image/*, dimensions:min_width=,max_width=,min_height=,max_height=,width=,height=, et min/max/between en kilo-octets pour un fichier. Un upload arrivé en erreur (trop gros pour le serveur, partiel...) affiche sa vraie raison plutôt qu'un message générique ; un champ fichier laissé vide est traité comme absent (required échoue, nullable passe).

Plusieurs fichiers (name="photos[]") : $request->files['photos'] est une liste, validée avec 'photos.*' => 'image'. Autres méthodes : hasFile(), clientName() (affichage seulement), mimeType(), extension(), size(), storeAs($dossier, $nom).

La taille maximale réelle est aussi limitée par upload_max_filesize et post_max_size dans php.ini — au-delà de post_max_size, PHP vide la requête entière. En test :

$this->post('/profil/avatar', ['avatar' => UploadedFile::fakeImage('moi.png', 200, 200)]);
$this->post('/cv', ['cv' => UploadedFile::fake('cv.pdf', "%PDF-1.4 ...")]);

Réponses fichiers

return Response::download(Storage::path($facture['path']), 'Facture été 2026.pdf'); // téléchargement
return Response::file(Storage::path($user['avatar']));                               // affichage (image, PDF...)
return Response::stream(function () {                                               // corps écrit au fil de l'eau
    foreach (Order::query()->get() as $order) {
        echo $order['reference'] . ';' . $order['total_cents'] . "\n";
    }
}, 200, ['Content-Type' => 'text/csv']);

Le fichier est lu depuis le disque à l'envoi, sans être chargé en mémoire. Ni la compression gzip ni la barre de debug ne touchent un fichier ou un flux. file() n'affiche dans le navigateur que des types sûrs (images, PDF, texte, MP3, MP4) : un fichier HTML ou SVG envoyé par un visiteur exécuterait son JavaScript sur votre domaine, il est donc proposé en téléchargement. Le nom proposé peut contenir des accents (RFC 6266) ; un fichier absent donne une 404. Pour servir les uploads de storage/app/, passez par une route qui vérifie les droits avant Response::file().

Un cookie peut accompagner n'importe quelle réponse, chiffré comme ceux de Cookie::set(). Il n'est envoyé que si la réponse l'est, et reste lisible dans les tests (assertCookie()) :

return Response::redirect('/')->cookie('theme', 'sombre', 60 * 24 * 30); // minutes
return Response::redirect('/')->withoutCookie('theme');

Temps réel : Server-Sent Events

Pour pousser des mises à jour au navigateur (progression d'un export, notifications, tableau de bord) sans WebSocket ni dépendance :

use Niang\Core\Http\ServerSentEvent;

$router->get('/export/{id}/progression', function (string $id): Response {
    return Response::eventStream(function () use ($id) {
        while (($pourcent = Export::progression($id)) < 100) {
            yield new ServerSentEvent(['pourcent' => $pourcent], event: 'progress', id: (string) $pourcent);
            sleep(1);
        }
        yield new ServerSentEvent(['pourcent' => 100], event: 'done');
    });
});
const source = new EventSource('/export/42/progression');
source.addEventListener('progress', e => barre.value = JSON.parse(e.data).pourcent);
source.addEventListener('done', () => source.close());

Chaque yield part immédiatement : une chaîne est envoyée telle quelle, toute autre valeur en JSON. yield null ne produit rien, mais envoie un commentaire : ping si rien n'est parti depuis $heartbeat secondes (15 par défaut), pour que les proxys ne coupent pas la connexion. Pendant le flux, la session est libérée (les autres onglets du visiteur ne sont pas bloqués ; écrivez en session avant de commencer), et l'en-tête X-Accel-Buffering: no empêche Nginx de retenir les événements. Le flux s'arrête quand le générateur se termine ou, à un ou deux événements près, quand le client se déconnecte.

Chaque flux occupe un processus PHP tant qu'il est ouvert : prévoyez assez de workers PHP-FPM (pm.max_children). En développement, PHP_CLI_SERVER_WORKERS=4 ./bin/niang serve permet de naviguer pendant qu'un flux est ouvert. Pour des milliers de connexions simultanées, un serveur dédié (WebSocket, Mercure) reste plus adapté.

Emails

Trois drivers, pilotés par MAIL_MAILER dans .env (log par défaut) : smtp pour un envoi réel, log en développement, array en test. Une valeur inconnue lève une erreur plutôt que de retomber silencieusement sur log.

class WelcomeMailable extends Mailable
{
    public function __construct(private string $name) {}
    public function subject(): string { return 'Bienvenue'; }
    public function body(): string { return "Bonjour {$this->name} !"; }
    // Facultatif : avec une version HTML, l'email part en texte + HTML (multipart/alternative).
    public function html(): ?string { return '<p>Bonjour <b>' . e($this->name) . '</b> !</p>'; }
}

Mail::to('awa@example.com')->send(new WelcomeMailable('Awa'));

Copies, pièces jointes, envoi différé

Mail::to('awa@example.com')
    ->cc('comptabilite@example.com')           // visible de tous
    ->bcc(['archives@example.com'])            // copie cachée : n'apparaît jamais dans le message
    ->send(new InvoiceMailable($facture));

class InvoiceMailable extends Mailable
{
    // ...
    public function attachments(): array
    {
        return [
            MailAttachment::fromPath(Storage::path($this->facture['path']), 'Facture été 2026.pdf'),
            MailAttachment::fromData($csv, 'export.csv', 'text/csv'),   // contenu déjà en mémoire
        ];
    }
}

Mail::to($user['email'])->queue(new WelcomeMailable($user['name']));       // envoyé par queue:work
Mail::to($user['email'])->later(3600, new ReminderMailable($user['name'])); // dans une heure au plus tôt

queue() ne fait pas attendre la requête HTTP le serveur SMTP ; le job retente 3 fois si le serveur est indisponible. Le Mailable est sérialisé : pas de closure ni de connexion dans ses propriétés, et un fichier joint par fromPath() doit encore exister quand queue:work envoie. Le type MIME d'une pièce jointe est détecté (extension fileinfo) si vous ne le donnez pas ; un nom accentué est encodé (RFC 2231).

SMTP (production)

Un client SMTP écrit à la main (Niang\Core\SmtpTransport), sans extension ni dépendance — STARTTLS ou TLS implicite, AUTH PLAIN/LOGIN, sujets et noms accentués encodés :

MAIL_MAILER=smtp
MAIL_HOST=smtp.example.com
MAIL_PORT=587
MAIL_ENCRYPTION=tls
MAIL_USERNAME=contact@example.com
MAIL_PASSWORD=secret
MAIL_FROM_ADDRESS=contact@example.com
MAIL_FROM_NAME="Mon site"
MAIL_TIMEOUT=10

MAIL_ENCRYPTION vaut tls (STARTTLS, port 587, le défaut), ssl (TLS implicite, port 465) ou none (serveur local uniquement). Pas de commentaire en fin de ligne dans .env : il ferait partie de la valeur.

Deux garde-fous : avec MAIL_ENCRYPTION=tls, un serveur qui ne propose pas STARTTLS fait échouer l'envoi (jamais de repli en clair), et MAIL_USERNAME/MAIL_PASSWORD ne partent jamais sur une connexion non chiffrée, sauf vers localhost (Mailpit, MailHog...). Le certificat du serveur est vérifié. Une erreur (serveur injoignable, identifiants refusés, destinataire rejeté) lève une Niang\Core\Exceptions\MailException qui cite la réponse du serveur — jamais le mot de passe. niang doctor signale une configuration SMTP incomplète, et avertit si MAIL_MAILER vaut log ou array en production. En développement, Mailpit avec MAIL_HOST=127.0.0.1, MAIL_PORT=1025 et MAIL_ENCRYPTION=none affiche les emails dans le navigateur.

send() est synchrone : pour ne pas faire attendre la requête, utilisez queue() (voir plus haut).

Développement et tests

MAIL_MAILER=log (défaut) écrit le contenu complet dans storage/logs/ — pratique en développement pour lire un lien de vérification sans boîte mail réelle (ne le gardez pas en production si vos emails transportent des secrets). MAIL_MAILER=array, ou Mail::fake() dans un test, garde les emails en mémoire pour Mail::sent() plutôt que de les journaliser :

Mail::fake();
$this->post('/register', [...]);
$this->assertCount(1, Mail::sent());

Notifications

Un même message envoyé sur un ou plusieurs canaux : mail, database (table notifications, fournie avec les migrations), webhook, ou votre propre canal (SMS, Slack...).

use Niang\Core\Notification;

class CommandeExpediee extends Notification
{
    public function __construct(private array $commande) {}

    public function via(array $user): array
    {
        return ['mail', 'database'];
    }

    public function toMail(array $user): Mailable
    {
        return new CommandeExpedieeMailable($this->commande);
    }

    public function toDatabase(array $user): array
    {
        return ['commande' => $this->commande['id'], 'message' => 'Votre commande est en route'];
    }
}

Notification::send($user, new CommandeExpediee($commande));        // un destinataire (sa colonne email)
Notification::send($admins, new CommandeExpediee($commande));      // ou une liste

Notification::for($user);             // ses notifications (data décodé), les plus récentes d'abord
Notification::unread($user);          // non lues ; Notification::unreadCount($user)
Notification::markAsRead($user, $id); // false si la notification n'est pas la sienne
Notification::markAllAsRead($user);

Une notification qui implémente ShouldQueue part par la file (un job par destinataire, 3 tentatives) ; Notification::sendNow() l'envoie tout de suite. En test, Notification::fake() puis Notification::sent().

Webhook : toWebhook() renvoie le corps JSON, webhookUrl() l'adresse (http ou https uniquement). Avec webhookSecret(), l'en-tête X-Niang-Signature: sha256=<HMAC du corps> permet au destinataire de vérifier l'origine (hash_equals() avec son propre calcul). Les redirections ne sont pas suivies, et une réponse hors 2xx lève une NotificationException.

Canal sur mesure (SMS...) : une classe qui implémente Niang\Core\Contracts\NotificationChannel (send(array $notifiable, Notification $notification)), dont vous renvoyez le nom depuis via().

Compression & supervision

Les réponses sont automatiquement compressées en gzip si le client l'accepte et que ça vaut le coût.

Route Vérifie Usage
GET /health (alias /up, /health/ready) base de données, cache, stockage, file d'attente : {"status":"ok","services":{"database":"ok",...}}, 503 si l'un échoue supervision, sonde readiness (retirer un serveur du trafic)
GET /health/live rien : le process PHP répond sonde liveness (redémarrer un conteneur bloqué)

Les quatre routes restent accessibles en mode maintenance. ./bin/niang health fait la même vérification en ligne de commande.

Debug toolbar

Avec APP_DEBUG=true (défaut en développement), une barre s'ajoute en bas de chaque page HTML : temps de réponse, nombre de requêtes SQL exécutées (DB::queryCount(), remis à zéro à chaque requête), pic mémoire, code de statut. Jamais injectée sur une réponse JSON, jamais sur une page sans balise </body>, et absente dès que APP_DEBUG=false — donc jamais en production avec la configuration par défaut.

Passage en production

composer install --no-dev --optimize-autoloader
./bin/niang route:cache   # ou : ./bin/niang optimize

Avec APP_ENV=production, le mode debug est toujours désactivé (pas de trace d'erreur, pas de barre de debug), même si APP_DEBUG=true traîne dans .env ; et aucune requête n'est servie sans APP_KEY valide (réponse 503, détail dans les logs). Hors production, le debug est actif sauf APP_DEBUG=false.

Activez opcache.enable=1 et opcache.validate_timestamps=0 dans le php.ini de production (remettez validate_timestamps=1 en développement, sinon vos modifications de code ne seront pas prises en compte sans redémarrage).

Performances mesurées

php -d opcache.enable_cli=1 benchmarks/run.php            # routage, conteneur, base, vues, requête complète
php -d opcache.enable_cli=1 benchmarks/run.php --markdown # met à jour benchmarks/RESULTS.md

Chaque couche est comparée à son équivalent en PHP natif (p50, p95, p99, débit, mémoire), avec les conditions de mesure. Sur un Apple M1 avec OPcache, une requête complète coûte environ 0,015 ms de framework, plus 0,08 ms de démarrage : voir benchmarks/RESULTS.md.

Docker (facultatif)

docker compose up -d     # PHP-FPM 8.3 + Nginx + MySQL 8.4, sur http://localhost:8080
docker compose exec app php bin/niang db:seed
docker compose down      # ajoutez -v pour supprimer aussi la base

Au premier démarrage, le conteneur app installe vendor/, crée .env et sa clé APP_KEY (jamais remplacée ensuite), puis lance les migrations (NIANGPRO_MIGRATE=false pour s'en passer). Le code est monté depuis votre dossier : une modification est visible sans reconstruire l'image. Les variables de compose.yaml (DB_HOST=db...) priment sur .env. Nginx ne sert que public/, n'exécute que index.php et refuse les fichiers cachés. Autre port : NIANGPRO_HTTP_PORT=8000 docker compose up -d. Une base niangpro_test est créée pour lancer la suite Database contre MySQL : docker compose exec -e DB_DATABASE=niangpro_test app vendor/bin/phpunit --testsuite=Database.

Pour la production, docker/php/Dockerfile construit une image autonome (code copié, composer install --no-dev) : passez APP_KEY, APP_ENV=production, APP_DEBUG=false et les DB_* comme variables d'environnement, et mettez opcache.validate_timestamps=0 dans docker/php/php.ini.

Docker reste facultatif : ./bin/niang serve avec SQLite suffit pour développer.

Mode maintenance

./bin/niang down                      # toute requête reçoit une page 503
./bin/niang down --retry=60           # + en-tête Retry-After: 60
./bin/niang down --secret             # affiche une URL secrète qui vous laisse naviguer (cookie 12 h)
./bin/niang up                        # rouvre le site

/up et /health restent accessibles, pour que la supervision ne déclenche pas d'alerte pendant une maintenance prévue. La page 503 (resources/views/errors/503.php) n'utilise pas le layout du site, qui pourrait dépendre d'une base en cours de migration. Seul le hachage du secret est écrit sur le disque (storage/framework/down). Avec plusieurs serveurs web, lancez down et up sur chacun.

Tests

Les seules dépendances Composer du projet sont en require-dev (jamais livrées en production) : PHPUnit, PHP-CS-Fixer, PHPStan.

composer test       # PHPUnit
composer lint        # PHP-CS-Fixer (dry-run)
composer lint:fix     # PHP-CS-Fixer (applique)
composer analyse      # PHPStan niveau 7
vendor/bin/phpunit --testsuite=Security   # la suite de sécurité seule

tests/Security/ regroupe les attaques de la roadmap (§55) : injection SQL (valeurs, identifiants, opérateurs, curseurs), XSS (e(), json_for_html()), CSRF, redirection ouverte, en-tête Host (liens envoyés par email construits avec APP_URL), fixation de session, cookies HttpOnly/SameSite, traversée de chemin, fichiers déguisés, noms de fichiers aléatoires, HTML/SVG jamais affichés, affectation de masse, IDOR, limitation de débit, en-têtes de sécurité.

Couverture de code (extension pcov ou xdebug), vérifiée en CI : un seuil global (78 %) et un seuil par composant critique (Router, Container, Database, Auth, Validation, HTTP, sécurité), définis dans tools/coverage-check.php :

vendor/bin/phpunit --coverage-clover build/clover.xml
php tools/coverage-check.php build/clover.xml

Client de test sans serveur HTTP réel (dispatche directement dans le Router) :

use Niang\Core\Testing\TestCase;

class HomeTest extends TestCase
{
    public function test_home_page_loads(): void
    {
        $this->get('/')->assertOk()->assertSee('NiangPro');
    }
}

Assertions disponibles : assertStatus, assertOk, assertRedirect, assertSee, assertDontSee, assertJson, ->json(). Pour un test unitaire pur (sans HTTP), étendez directement PHPUnit\Framework\TestCase — voir tests/Unit/ValidatorTest.php.

Isolation complète

Les tests tournent entièrement isolés du développement local, sans rien à configurer :

  • phpunit.xml force APP_ENV=testing, ce qui fait charger .env.testing (committé — clé de test, pas un secret) au lieu de .env.
  • .env.testing pointe DB_DATABASE sur :memory: : jamais storage/database.sqlite.
  • Niang\Core\Testing\TestCase migre automatiquement cette base en mémoire au premier test qui en a besoin (idempotent, pas de doublon).
  • tests/bootstrap.php repart d'un storage/framework/ propre à chaque run : le cache applicatif et le rate limiting (sur fichier) ne s'accumulent pas d'une exécution de la suite à l'autre.

Pour qu'un test qui écrit en base n'affecte pas les suivants (la base :memory: survit tout le run PHPUnit, contrairement à un vrai processus web), utilisez le trait RefreshDatabase — chaque test est enrobé dans une transaction annulée à la fin :

use Niang\Core\Testing\RefreshDatabase;
use Niang\Core\Testing\TestCase;

class AuthTest extends TestCase
{
    use RefreshDatabase;

    public function test_registration_creates_a_user(): void
    {
        $this->post('/register', [...]);
        $this->assertNotEmpty(User::where('email', 'awa@example.test'));
    }
}

Conséquence directe : composer test, composer lint et composer analyse fonctionnent sans aucune préparation (pas de .env, pas de migrate) — vérifié en CI comme en local.

CI multi-versions et multi-SGBD

.github/workflows/ci.yml fait tourner :

  • test : lint + PHPStan + tests, en matrice sur PHP 8.1, 8.2, 8.3 et 8.4 (SQLite :memory:).
  • mysql / postgres : les migrations (tests/Database/) contre un vrai conteneur MySQL 8 et PostgreSQL 16 — pas seulement SQLite. DB_CONNECTION/DB_HOST/... passés en variables d'environnement CI ont priorité sur .env.testing (une variable d'environnement déjà présente garde toujours la priorité sur les fichiers .env*, utile aussi derrière un vrai hébergeur).

Ces deux jobs ont fait remonter un vrai bug avant même d'être poussés en CI (testé en local contre un MySQL réel) : la table interne de suivi des migrations était créée en SQL SQLite brut, hors du Grammar — corrigé pour passer par Schema/Blueprint comme n'importe quelle migration applicative.

CLI

./bin/niang serve                    # démarre le serveur de dev
./bin/niang make:controller Blog     # génère app/Controllers/BlogController.php
./bin/niang make:model Post          # génère app/Models/Post.php
./bin/niang make:migration create_x  # génère database/migrations/..._create_x.php
./bin/niang make:seeder Demo         # génère database/seeders/DemoSeeder.php
./bin/niang migrate                  # applique les migrations en attente
./bin/niang migrate:rollback         # annule le dernier lot de migrations
./bin/niang migrate:fresh            # réinitialise la base et rejoue tout
./bin/niang db:seed                  # exécute DatabaseSeeder
./bin/niang route:cache              # compile les routes pour la prod
./bin/niang route:clear              # supprime le cache de routes
./bin/niang route:list               # liste toutes les routes
./bin/niang make:middleware Cors     # génère app/Middleware/Cors.php
./bin/niang make:request ContactRequest  # génère app/Requests/ContactRequest.php
./bin/niang tinker                   # REPL interactif
./bin/niang key:generate             # régénère APP_KEY dans .env
./bin/niang queue:work               # traite les jobs différés en attente
./bin/niang queue:failed             # liste les jobs qui ont épuisé leurs tentatives
./bin/niang queue:retry <id>         # remet un job échoué en file
./bin/niang queue:flush              # supprime définitivement tous les jobs échoués
./bin/niang cache:clear              # vide le cache applicatif
./bin/niang optimize                 # cache les routes + rappels de prod
./bin/niang optimize:clear           # supprime les caches de routes et de configuration
./bin/niang about                    # versions, environnement, pilotes, caches
./bin/niang env                      # environnement courant (APP_ENV)
./bin/niang cors:check https://app.example.com  # vérifie config/cors.php, simule un préflight
./bin/niang make:notification CommandeExpediee  # génère app/Notifications/CommandeExpedieeNotification.php
./bin/niang openapi                  # génère public/openapi.json (voir Documentation OpenAPI)
./bin/niang down / up                # mode maintenance (voir Passage en production)
./bin/niang new mon-app              # crée un nouveau projet (pose la question du type de site)
./bin/niang new mon-app --type=blog  # idem sans question : vitrine, ecommerce, blog, portfolio, landing, minimal
./bin/niang np:install               # installe le raccourci global `np` (macOS, Linux, Windows)
./bin/niang theme:add vendor/theme-x # installe un thème publié comme paquet Composer (extra.niangpro-theme)

Raccourci np (optionnel)

Pour taper np serve au lieu de ./bin/niang serve, installez une fois le raccourci global (macOS, Linux et Windows) :

./bin/niang np:install        # macOS / Linux
php bin/niang np:install      # Windows (cmd ou PowerShell)

La commande dépose un script np (sur Windows, np.cmd, utilisable depuis cmd comme depuis PowerShell) dans un dossier déjà présent dans votre PATH (auto-détecté, par exemple ~/.local/bin, /opt/homebrew/bin ou /usr/local/bin ; sur Windows, le dossier bin global de Composer). Ce script remonte l'arborescence depuis le dossier courant pour retrouver bin/niang. Résultat : np fonctionne dans n'importe quel projet NiangPro sur la machine, même depuis un sous-dossier, sans rien reconfigurer par projet. Si aucun dossier de votre PATH n'est accessible en écriture, la commande installe np dans ~/.local/bin (Windows : %LOCALAPPDATA%\NiangPro\bin) et affiche la ligne exacte à exécuter une fois pour l'ajouter au PATH.

Configuration

Fichiers config/*.php, chargés automatiquement au démarrage : app.php (nom, debug, providers), security.php (en-têtes HTTP appliqués à toutes les réponses), session.php (durée de vie et drapeaux du cookie de session).

// config/app.php
return ['name' => env('APP_NAME', 'NiangPro')];

config('app.name'); // notation pointée, avec valeur par défaut : config('app.name', 'Défaut')

Cache de configuration (production)

./bin/niang config:cache   # fige config/*.php (et les env() qu'ils contiennent) dans un seul fichier
./bin/niang config:clear   # supprime le cache

Une fois caché, modifier .env ou config/*.php n'a plus d'effet tant que le cache n'est pas vidé — sûr uniquement parce qu'il est explicitement optionnel : ne l'activez qu'en production (./bin/niang optimize le fait pour vous, avec le cache de routes).

Sécurité

  • En-têtes HTTP (CSP, HSTS, X-Frame-Options...) appliqués à toutes les réponses par défaut, configurables dans config/security.php — pas besoin de toucher Application.
  • Cookie de session : HttpOnly toujours actif, SameSite et durée de vie configurables (config/session.php), Secure détecté automatiquement selon HTTPS (ou forcé via SESSION_SECURE_COOKIE en .env).
  • CSRF : comparaison à temps constant (hash_equals), voir la section CSRF plus haut.
  • APP_KEY : générée automatiquement par composer create-project (dans un .env créé depuis .env.example) et par niang new ; sinon niang key:generate. Elle signe les URLs, hache les jetons API et chiffre les cookies — sans elle, ces trois fonctions lèvent une ConfigurationException plutôt que d'utiliser une clé par défaut connue de tous.
  • Cookies chiffrés : Cookie::set('panier', $valeur, $minutes) / Cookie::get('panier') — AES-256-GCM (Niang\Core\Crypt), illisibles et non modifiables par le navigateur, liés à leur nom (impossible de recopier un cookie sous un autre nom) et expirés côté serveur. Crypt::encrypt() / Crypt::decrypt() sont aussi utilisables directement (decrypt() retourne null pour une valeur modifiée).
  • Mots de passe : Argon2id (ou bcrypt si indisponible), voir Hash::make().
  • SQL : toutes les requêtes de l'ORM et du Query Builder passent par des requêtes préparées PDO.
  • Régénération de session : Auth::login()/Auth::logout() appellent Session::regenerate() automatiquement (protection contre la fixation de session).

Conteneur (Container)

Auto-wiring par Reflection, sans configuration : type-hintez une dépendance dans un constructeur ou une méthode de contrôleur, elle est résolue automatiquement (et récursivement).

$container->bind(PaymentGateway::class, StripeGateway::class);                         // interface -> classe
$container->bind(PaymentGateway::class, fn ($c) => new StripeGateway(env('STRIPE_KEY'))); // ou une fabrique
$container->singleton(CacheManager::class);                // une seule instance, créée au premier usage
$container->singleton(Clock::class, SystemClock::class);   // idem, pour une interface
$container->instance(Clock::class, new FrozenClock('2026-01-01'));  // objet déjà construit

// Liaison contextuelle : une implémentation selon la classe qui la demande
$container->when(RefundController::class)->needs(PaymentGateway::class)->give(PaypalGateway::class);

bind() crée une nouvelle instance à chaque résolution ; singleton() et instance() renvoient toujours la même. La liaison contextuelle s'applique au constructeur de la classe comme à l'injection dans ses méthodes (actions de contrôleur).

Erreurs explicites plutôt qu'un plantage silencieux ou un débordement de pile :

Impossible de résoudre [App\Nope] : cette classe n'existe pas.
Impossible de résoudre [App\PaymentGateway] : ce n'est pas une classe instanciable
  (interface ou classe abstraite ?). Enregistrez un binding avec $container->bind(...).
Dépendance circulaire détectée : A -> B -> A
Paramètre manquant : $name (type string) (paramètre de PostController::store()) —
  aucune valeur fournie et pas de valeur par défaut.

PSR-11 et PSR-3

Container implémente Psr\Container\ContainerInterface (get()/has(), alias standard de make()) et ContainerException implémente ContainerExceptionInterface/NotFoundExceptionInterface — du code tiers compatible PSR-11 (ou tapé contre l'interface plutôt que la classe concrète) fonctionne sans adaptation.

Niang\Core\Logger implémente Psr\Log\LoggerInterface et délègue à Niang\Core\Log (la façade statique utilisée ailleurs dans le framework — même fichiers de sortie) :

class ReportGenerator
{
    public function __construct(private \Psr\Log\LoggerInterface $logger)
    {
    }
}

// résolu automatiquement par le container ($container->singleton(LoggerInterface::class, ...)
// est déjà enregistré par Application) :
$container->make(ReportGenerator::class);

Seules des interfaces PSR pures existent dans composer.json (psr/container, psr/log, psr/http-message, psr/http-server-middleware, psr/event-dispatcher) : aucun code d'implémentation, aucune dépendance transitive lourde — le framework reste sans dépendance d'implémentation à l'exécution.

PSR-7 / PSR-15 (brancher un middleware tiers)

Request/Response restent les objets simples de NiangPro — pas d'objets PSR-7 immuables partout, ça casserait l'API actuelle pour un bénéfice qui ne le justifie pas. Mais un middleware PSR-15 tiers (une lib de sécurité, un cache HTTP existant sur Packagist...) reste branchable sans réécrire le framework autour de PSR-7, via un pont :

composer require nyholm/psr7   # implémentation concrète — psr/http-message ne fournit que des interfaces
use Niang\Core\Http\Psr15Adapter;

// Dans ServiceProvider::register() : $this->app->container y est accessible.
$this->app->container->bind(Psr15Adapter::class, fn () => new Psr15Adapter(new UnMiddlewarePsr15Tiers()));
// routes/web.php : s'attache exactement comme un middleware natif.
$router->get('/api/x', [Controller::class, 'index'], [Psr15Adapter::class]);

Niang\Core\Http\Psr7Bridge fait la conversion (toPsrRequest(), toPsrResponse(), fromPsrResponse()), logique pure et testable indépendamment de l'adaptateur. Sans nyholm/psr7 (ou une autre implémentation PSR-7 concrète) installé, Psr7Bridge lève une RuntimeException explicite plutôt qu'une erreur PHP opaque sur une classe manquante.

Limite assumée : un middleware PSR-15 qui modifie la réponse, ou qui court-circuite la requête (ne délègue jamais au handler), fonctionne pleinement. Un middleware qui n'agit que sur des attributs PSR-7 de la requête pour un usage plus en aval ne peut pas les transmettre à NiangPro par ce pont — Request n'a pas de notion d'attributs, lui en ajouter reviendrait à commencer la refonte que ce pont évite justement.

Service Providers

class AppServiceProvider extends ServiceProvider
{
    public function register(): void { /* bindings dans le Container */ }
    public function boot(): void { /* écouteurs d'événements, etc. */ }
}

Déclarez-les dans config/app.php (providers). Tous les register() s'exécutent avant tout boot().

Événements

Un événement est un objet (./bin/niang make:event UserRegistered), écouté par le nom de sa classe :

class UserRegisteredEvent
{
    public function __construct(public readonly array $user) {}
}

Event::listen(UserRegisteredEvent::class, SendWelcomeEmailListener::class);   // classe avec handle()
Event::listen(UserRegisteredEvent::class, function (UserRegisteredEvent $event) {
    Log::info('Nouvel utilisateur : {email}', ['email' => $event->user['email']]);
});

Event::dispatch(new UserRegisteredEvent($user));   // retourne l'événement, modifiable par les écouteurs

Un écouteur enregistré sur une classe parente ou une interface reçoit aussi les événements qui en héritent (après ceux de la classe exacte). Un événement qui étend Niang\Core\Events\StoppableEvent s'arrête dès qu'un écouteur appelle $event->stopPropagation(). Un écouteur ShouldQueue reçoit l'objet par la file (il doit donc être sérialisable). Les événements nommés restent pris en charge : Event::listen('user.registered', ...) puis Event::dispatch('user.registered', $user).

Événements du framework : pour une extension ou un paquet qui doit intervenir à chaque requête sans toucher au code de l'application.

Événement Moment Contenu
Events\ApplicationBooted après le boot() de tous les Service Providers $app
Events\RequestReceived début de handle(), avant maintenance et routeur $request
Events\RouteMatched route trouvée, avant les middlewares $request, $route
Events\ResponsePrepared réponse prête, juste avant l'envoi (encore modifiable) $request, $response
Events\RequestTerminated après l'envoi (avec PHP-FPM, le visiteur n'attend plus) $request, $response
Event::listen(ResponsePrepared::class, fn (ResponsePrepared $e) => $e->response->header('X-Version', '2.1'));

Émis seulement s'ils sont écoutés : aucun coût sinon. Un écouteur qui lève une exception pendant RequestReceived donne une page d'erreur normale. Il n'y a pas d'ApplicationStarting : aucun écouteur ne pourrait être enregistré avant le démarrage des Service Providers.

PSR-14 : injectez Psr\EventDispatcher\EventDispatcherInterface (résolu par le conteneur en Niang\Core\Events\Dispatcher) dans du code qui ne doit pas dépendre de la façade ; mêmes écouteurs.

Découple la logique secondaire (notifications, journalisation, futurs écouteurs) du contrôleur qui déclenche l'action — sans passer par un vrai bus d'événements avec files et retries.

Créer un nouveau projet

Depuis un projet existant, ./bin/niang new mon-app demande le type de site puis lance composer create-project niangpro/niangpro mon-app (à côté du projet courant) : nouvelle APP_KEY, thème installé. Depuis le dépôt du framework, le squelette est construit localement et relié à votre copie du framework, pour essayer une modification dans une vraie application. Sans projet sous la main, utilisez directement composer create-project niangpro/niangpro mon-app (voir Installation) : les deux chemins posent la même question et exécutent le même code.

./bin/niang new mon-app                # demande le type de site (si STDIN est un terminal)
./bin/niang new mon-app --type=ecommerce
NIANG_SITE_TYPE=landing ./bin/niang new mon-app

Ordre de priorité : --type, puis NIANG_SITE_TYPE, puis la question, puis minimal. Un type inconnu est refusé avant toute copie. niang new clone le projet courant : lancé depuis un projet déjà thématisé, il en reprend le thème.

Thèmes de site

Choisir un type de site remplace (et ne fusionne pas) routes/web.php, les vues concernées de resources/views/ et les assets de démonstration, et retire les tests de fonctionnalité de la démo qui ne s'appliquent plus : le thème est livré avec ses propres tests, composer test reste donc vert. /up et /health sont conservés.

  • Contenu : tout le contenu de démonstration (fictif) est dans config/site.php — nom, navigation, services, FAQ, témoignages... Modifiez-le sans toucher aux vues. Le nom se règle aussi avec SITE_NAME dans .env.
  • Design : public/css/niang.css (variables :root, clair/sombre via prefers-color-scheme, police système) puis public/css/theme.css (propre au thème). Aucune webfont ni CDN, icônes en SVG inline (component('components/icon', ['name' => 'check'])), JavaScript vanilla dans public/js/. La CSP par défaut interdit les scripts inline : gardez le JavaScript dans des fichiers.
  • Images : les visuels de démonstration sont des illustrations SVG générées (components/art). Remplacez-les par de vraies photos avec un <img> et son attribut alt.
  • Boutique en ligne — paiement : aucun paiement réel n'est branché. La commande est enregistrée avec le statut pending (en attente de paiement). Le TODO documenté de app/Controllers/CheckoutController.php explique où brancher un prestataire (Stripe, PayPal, Wave...) : créer la session de paiement, rediriger le client, et ne passer la commande à paid que depuis le webhook du prestataire. Les prix sont des entiers en centimes d'euro.

Ajouter votre propre thème

Un thème est un dossier de resources/scaffold/themes/<slug>/ qui reproduit l'arborescence d'un projet (app/, config/, database/, public/, resources/views/, routes/web.php, tests/). Ajouter un dossier suffit : il apparaît dans la question posée à la création d'un projet. Un theme.json facultatif précise :

{
    "label": "Site d'agence",
    "order": 60,
    "remove": ["resources/views/posts"],
    "next_steps": ["./bin/niang serve"]
}

label est le libellé du catalogue, order sa position, remove les chemins du projet supprimés avant la copie et next_steps les commandes suggérées ensuite. resources/scaffold/shared/ (design system, composants, pages d'erreur, contact) est copié dans tous les thèmes.

Trois clés de plus servent aux thèmes qui ont une base de données :

  • modules : briques de resources/scaffold/modules/ installées avec le thème (après shared/, avant le thème, qui peut en remplacer n'importe quel fichier). Le module admin apporte l'espace d'administration : rôle users.role, compte de test (database/seeders/AdminUserSeeder.php, à appeler depuis votre DatabaseSeeder), middleware EnsureUserIsAdmin, layout admin.layouts.app et page Paramètres. Le thème fournit app/Support/AdminMenu.php (les sections de la barre latérale) et ses propres pages ; voir les thèmes ecommerce et blog.
  • setup : commandes niang lancées automatiquement dans le nouveau projet (ex. ["migrate", "db:seed"]) ; celles qui réussissent disparaissent de next_steps.
  • notes : lignes affichées à la fin de la création (ex. les identifiants du compte de test).

Publier votre thème comme paquet Composer

Pour partager un thème sans copier-coller de fichiers, publiez-le comme paquet Composer avec une clé extra.niangpro-theme dans son composer.json, pointant vers le dossier qui reproduit l'arborescence d'un thème (déjà nommé comme le slug voulu — c'est son nom de dossier qui devient le slug) :

{
    "name": "votre-pseudo/theme-agence",
    "extra": {
        "niangpro-theme": "resources/theme/agence"
    }
}

Puis, dans un projet NiangPro :

./bin/niang theme:add votre-pseudo/theme-agence

installe le paquet (composer require --dev — inutile en production, seulement à la création de projets) et copie son dossier de thème dans resources/scaffold/themes/agence/, où il apparaît dans le catalogue exactement comme un thème livré avec le framework — ProjectScaffolder n'a besoin d'aucune modification, un thème restant « juste un dossier ». (Choix : une convention légère plutôt qu'un plugin Composer avec ses propres classes d'installateur — un plugin ajouterait une dépendance de développement et de la complexité que la philosophie du projet ne justifie pas ici ; la convention légère couvre déjà la quasi-totalité des cas, un thème n'ayant besoin d'aucune installation au-delà d'une simple copie de dossier.)

Intégration de frameworks frontend

Le cœur ne bundle rien et n'impose rien ("PHP reste PHP") : voici comment brancher un framework frontend depuis un projet NiangPro, sans que le framework lui-même en dépende.

Alpine.js et htmx (auto-hébergés, sans build)

Aucune étape de build, cohérent avec « zéro CDN » : téléchargez le fichier, mettez-le dans public/js/, incluez-le dans le layout.

curl -L https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js -o public/js/alpine.js
curl -L https://unpkg.com/htmx.org@2.x.x/dist/htmx.min.js -o public/js/htmx.js
<!-- dans resources/views/layouts/app.php -->
<script src="/js/alpine.js" defer></script>
<script src="/js/htmx.js" defer></script>

json_for_html() (packages/core/src/helpers.php) passe des données PHP à Alpine en toute sécurité — sensible en XSS, échappe en deux temps (JSON_HEX_* pour le contenu, puis htmlspecialchars() pour l'attribut HTML lui-même, qui casserait sinon dès que $data est un tableau) :

<div data-props="<?= json_for_html(['count' => 3, 'label' => $label]) ?>"
     x-data="JSON.parse($el.dataset.props)">
    <span x-text="label"></span> (<span x-text="count"></span>)
</div>

Vue / React (via Vite)

Ces frameworks impliquent un vrai outillage de build que NiangPro n'a pas et ne doit pas embarquer. Configurez Vite dans votre projet comme vous le feriez pour n'importe quel backend :

npm install -D vite laravel-vite-plugin   # ou tout autre plugin Vite écrivant un manifest standard
// vite.config.js
export default {
    build: { manifest: true, outDir: 'public/build' },
    server: { origin: 'http://localhost:5173' },
};

npm run build construit dans public/build/ (fichiers hashés, servis comme n'importe quel fichier statique) ; npm run dev démarre le serveur de dev Vite. vite_asset(string $entry): string (packages/http/src/helpers.php) résout l'URL réelle sans jamais casser un lien à chaque build :

<script type="module" src="<?= e(vite_asset('resources/js/app.js')) ?>"></script>

Lit public/build/manifest.json s'il existe (résout l'URL hashée), bascule sur le serveur de dev (http://localhost:5173, ou l'URL lue dans public/hot si présent) sinon. Inerte tant que vous n'appelez pas ce helper : sans Vite configuré, rien dans le framework n'en dépend — un appel explicite sans l'un ou l'autre fichier échoue avec un message clair plutôt qu'un lien cassé silencieux.

Dépôt public

Le code est sur GitHub : https://github.com/NiangPro/niangpro (public, CI activée sur chaque push).

Packagist

Le framework est publié : packagist.org/packages/niangpro/framework (composer require niangpro/framework). En 2.0, le squelette d'application niangpro/niangpro et les 14 paquets niangpro/<nom> sont publiés depuis ce dépôt par .github/workflows/split.yml, dans des dépôts miroirs (voir l'en-tête du workflow pour la configuration à faire une fois : dépôts, jeton SPLIT_TOKEN, Packagist).

Pour publier une nouvelle version : créez un tag (git tag v2.0.0 && git push --tags) — le webhook GitHub → Packagist (configuré une fois pour toutes) met à jour le package automatiquement à chaque push, et le workflow de publication pousse le même tag dans chaque miroir (versions synchronisées). Si le webhook n'est pas configuré : GitHub → repo → Settings → Webhooks → Add webhook, avec comme Payload URL https://packagist.org/api/github?username=VOTRE_PSEUDO_PACKAGIST, content type application/json, et comme secret votre token API Packagist.

Philosophie

  • Aucune dépendance externe à l'exécution (PHPUnit/PHP-CS-Fixer/PHPStan sont en require-dev, jamais livrés en production).
  • Un seul fichier par responsabilité (Router, Request, Response, Container, View, DB).
  • Injection de dépendances automatique via Reflection, sans configuration.
  • Pas de compilation de templates, pas de cache de config à vider en développement : vous modifiez, vous rafraîchissez.