niangpro / framework
Un micro-framework PHP ultra simple, plus simple que Laravel : routeur, DI, ORM minimal, auth, tests, sans dépendance à l'exécution.
Requires
- php: >=8.1
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
NiangPro
Un micro-framework PHP ultra simple, plus simple que Laravel : pas de magie, pas de compilation, juste du PHP.
Installation
Pour démarrer un nouveau projet (le package est publié sur Packagist) :
composer create-project niangpro/framework mon-app
cd mon-app
./bin/niang serve
Visitez http://127.0.0.1:8000
Pour contribuer au framework lui-même (cloner ce dépôt directement) :
git clone https://github.com/NiangPro/niangpro.git
cd niangpro
composer install
cp .env.example .env
./bin/niang key:generate
./bin/niang serve
Structure
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)
src/Core/ Le cœur du framework
public/index.php Point d'entrée unique
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'; } User::all(); User::find(1); User::where('email', 'a@b.com'); User::create(['name' => 'Awa']); User::update(1, ['name' => 'Fatou']); User::destroy(1);
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'
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->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.
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.
Limite assumée : ->change() (modifier le type d'une colonne existante) n'est pas encore supporté —
les trois moteurs divergent trop pour une traduction fiable (SQLite ne le permet même pas nativement
sans reconstruire la table). Pour l'instant, gérez ce cas via une nouvelle migration qui recrée la colonne.
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'); } }
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, email, min:n (longueur ou valeur
minimale selon le type), max:n, regex:/motif/, confirmed (compare à {champ}_confirmation).
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.
Pages d'erreur personnalisées
Créez resources/views/errors/404.php et resources/views/errors/500.php : ils remplacent
automatiquement les pages par défaut du framework.
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.
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') ?>
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'
$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
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).
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)
Gate::define('delete-post', fn (?array $user, array $post) => $user && $post['author_id'] === $user['id']);
Dans un contrôleur :
$this->authorize('delete-post', $post); // lève une 403 si refusé Gate::allows('delete-post', $post); // true/false, sans lever d'exception Gate::denies('delete-post', $post); // inverse de allows()
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é');
Fichier (storage/framework/cache/), pas de dépendance à Redis/Memcached.
Jobs différés
./bin/niang queue:work
class SendWelcomeEmailJob extends Job { public function __construct(private string $email) {} public function handle(): void { /* ... */ } } Queue::push(new SendWelcomeEmailJob($email));
File sur fichier (storage/framework/queue/) — pas de démon fourni : lancez queue:work via cron,
ou en boucle, selon vos besoins.
Compression & supervision
Les réponses sont automatiquement compressées en gzip si le client l'accepte et que ça vaut le coût.
GET /up renvoie {"status":"ok","database":true} (200) si la base de données répond, ou
{"status":"degraded","database":false} (503) sinon — à brancher sur votre outil de supervision.
Passage en production
composer install --no-dev --optimize-autoloader
./bin/niang route:cache # ou : ./bin/niang optimize
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).
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 6
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.xmlforceAPP_ENV=testing, ce qui fait charger.env.testing(committé — clé de test, pas un secret) au lieu de.env..env.testingpointeDB_DATABASEsur:memory:: jamaisstorage/database.sqlite.Niang\Core\Testing\TestCasemigre automatiquement cette base en mémoire au premier test qui en a besoin (idempotent, pas de doublon).tests/bootstrap.phprepart d'unstorage/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.
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 cache:clear # vide le cache applicatif ./bin/niang optimize # cache les routes + rappels de prod ./bin/niang new mon-app # crée un nouveau projet à partir de ce squelette ./bin/niang np:install # installe le raccourci global `np` (macOS/Linux)
Raccourci np (optionnel)
Pour taper np serve au lieu de ./bin/niang serve, installez une fois le raccourci global
(macOS/Linux) :
./bin/niang np:install
La commande dépose un script np dans un dossier déjà présent dans votre PATH (auto-détecté, par
exemple ~/.local/bin, /opt/homebrew/bin ou /usr/local/bin) qui 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 vous indique comment en créer un.
Configuration
Fichiers config/*.php, chargés automatiquement au démarrage :
// 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')
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
Event::listen('user.registered', function (array $user) { Log::info('Nouvel utilisateur : {email}', ['email' => $user['email']]); }); Event::dispatch('user.registered', $user);
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 clone ce squelette (sans vendor/, .git/, données
locales), installe les dépendances et génère une nouvelle APP_KEY. Sans projet existant sous la main,
utilisez plutôt composer create-project niangpro/framework mon-app (voir Installation).
Dépôt public
Le code est sur GitHub : https://github.com/NiangPro/niangpro (public, CI activée sur chaque push).
Packagist
Le package est publié : packagist.org/packages/niangpro/framework.
composer create-project niangpro/framework mon-app et composer require niangpro/framework
fonctionnent pour tout le monde.
Pour publier une nouvelle version : créez un tag (git tag v1.1.0 && git push --tags) — le webhook
GitHub → Packagist (configuré une fois pour toutes) met à jour le package automatiquement à chaque push.
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.