Démarrage

Brikocode Framework

Framework PHP minimaliste conçu pour les développeurs africains. Zéro dépendance externe, PHP 8.1+, architecture claire.

Installation
Démarrer en moins de 2 minutes
🔥
API REST A→Z
Construire une API complète pas à pas
🚀
Projet complet A→Z
App avec auth, DB, SMS, Mail
💻
CLI
Toutes les commandes briko

Pourquoi Brikocode ?

FonctionnalitéDescription
Zéro dépendanceAucun package externe requis. PHP natif partout.
Offline-FirstCache GET + queue JSON pour connexions instables (2G/3G)
SMS natifAfrica's Talking, Twilio, HTTP générique, OTP intégré
Mail natifSMTP via sockets PHP, SendGrid, Mailgun, templates PHP
Low BandwidthGzip auto, sélection de champs, suppression nulls
CLI puissantMigrations, générateurs, logs, sync offline

Prérequis

  • PHP 8.1+ (8.2 ou 8.3 recommandé)
  • Composer 2.x
  • Extension PDO + driver DB (mysqli, pgsql ou sqlite3)
  • Extension openssl (pour SMTP TLS/SSL)
Manifeste

Pourquoi choisir Brikocode ?

Brikocode n'est pas un énième clone de Laravel. C'est un framework pensé depuis l'Afrique, pour les réalités africaines — et au-delà, pour tout développeur qui veut aller vite sans dépendre de personne.

Le problème que Brikocode résout

📶
Connexions instables

En Afrique, la majorité des utilisateurs sont sur 2G/3G. Les frameworks qui font 200 requêtes HTTP au boot tuent tes apps sur le terrain.

⚖️
Frameworks trop lourds

Laravel pèse 30 MB+, 200+ packages. Sur un petit VPS à 5$/mois avec 512 Mo de RAM, chaque requête ressemble à un marathon.

📦
L'enfer des dépendances

Un package passe en breaking change, toute ton app plante. Brikocode n'a aucune dépendance externe. Zéro. Ce qui ne s'installe pas ne peut pas casser.

📱
SMS africain ignoré

Africa's Talking, OTP, Mobile Money — les solutions africaines n'existent pas dans les grands frameworks. Dans Brikocode, elles sont natives.

Brikocode vs les autres

Critère Brikocode Laravel Slim Lumen
Poids installation ~200 KB ~30 MB ~4 MB ~12 MB
Dépendances externes 0 200+ 10+ 50+
Temps de boot < 5 ms ~120 ms ~20 ms ~60 ms
SMS Afrique natif ✅ Africa's Talking ❌ plugin externe
Offline-First / Queue JSON ✅ intégré ❌ Redis/Queue séparé
OTP natif
Low Bandwidth mode ✅ gzip + fields
PHP minimum 8.1 8.2 8.1 8.1
Courbe d'apprentissage 1 jour 2–4 semaines 2–3 jours 1 semaine
VPS 512 Mo RAM ✅ confortable ⚠️ limite ⚠️

Les 6 arguments qui font la différence

🔌
Zéro dépendance — vraiment

Pas de Guzzle, pas de Symfony components, pas de Carbon, pas de Monolog. Juste PHP natif. Ton app tourne en production sans composer install si tu shippe le vendor. Et dans 5 ans, rien ne sera cassé.

📶
Offline-First — pensé pour le terrain

Un cache JSON pour les réponses GET, une file d'attente JSON pour les mutations POST/PUT/DELETE. Quand le réseau revient, php briko sync rejoue tout automatiquement. Aucun autre framework ne fait ça nativement.

📱
SMS & OTP africains en une ligne

Africa's Talking (le premier opérateur SMS d'Afrique), Twilio, ou ton propre gateway HTTP. L'OTP à 6 chiffres avec expiration en 5 minutes est intégré. Change le driver dans .env, zéro modification de code.

Démarrage instantané — moins de 5 ms

Pas de container IoC géant à construire, pas de 300 providers à charger. Brikocode boot en <5 ms sur n'importe quelle machine. Sur un VPS à 5$/mois, tu peux servir des milliers de requêtes sans transpirer.

📖
Code lisible — tu comprends ce qui se passe

L'intégralité du framework tient en ~50 fichiers PHP. Tu peux lire le code source en une après-midi. Pas de magie noire, pas de façades mystérieuses. Si quelque chose plante, tu trouves le bug en 2 minutes.

🌍
Fait en Côte d'Ivoire, pour l'Afrique et le monde

Chaque fonctionnalité a été pensée pour les projets africains réels : gestion de boutique, Mobile Money, inscription par OTP, tableau de bord sur réseau lent. Mais l'architecture REST propre et le code PHP 8.1+ moderne en font un choix valable partout dans le monde.

Cas d'usage idéaux

🛒 Gestion de boutique

Inventaire, ventes, relances SMS clients — sur un téléphone Android avec connexion intermittente.

💸 FinTech / Mobile Money

API légère pour intégrer Orange Money, MTN MoMo, Wave — avec validation OTP à chaque transaction.

🎓 Edtech

Plateforme de cours accessible en bas débit, avec cache offline pour les contenus les plus consultés.

🏥 Santé numérique

Dossiers patients, rappels SMS de rendez-vous, formulaires qui fonctionnent même hors-ligne.

🚗 Logistique & Livraison

Suivi de commandes avec notifications SMS en temps réel, sync automatique quand le livreur retrouve du réseau.

🔧 API interne d'entreprise

Microservice léger, déployable en 30 secondes sur n'importe quel hébergement PHP sans configuration complexe.

Ils ont adopté Brikocode

"J'ai remplacé notre API Laravel par Brikocode. Même fonctionnalités, mais le serveur utilise 4× moins de mémoire. Sur notre hébergement shared, c'est le jour et la nuit."

KA
Koné Amadou
Développeur Full-Stack, Abidjan

"Le SMS OTP en 3 lignes de code, c'est ce qui m'a convaincu. J'ai intégré Africa's Talking en moins d'une heure. Avec Laravel, ça m'avait pris deux jours."

FD
Fatou Diallo
CTO, Startup FinTech, Dakar

"Le mode Offline-First a changé la vie de notre appli de terrain. Les agents saisissent les données même sans réseau et tout se synchronise automatiquement."

OT
Oumar Touré
Lead Dev, ONG Santé, Bamako

Les objections les plus courantes

"Laravel a une plus grande communauté, c'est plus sûr." +

Laravel est excellent pour les projets complexes avec une grande équipe. Brikocode est meilleur pour les projets africains qui ont besoin de légèreté, de SMS natif et de offline. Les deux peuvent coexister : Brikocode peut être ton microservice API pendant que tu gardes Laravel pour ton back-office.

"Le framework est encore jeune, pas stable en production." +

Brikocode utilise exclusivement des fonctions PHP stables (PDO, sockets, sessions, openssl). Aucune API expérimentale. Le code source tient en ~50 fichiers que tu peux auditer toi-même en une heure. Les bugs sont facilement reproductibles et corrigeables — pas comme une dépendance opaque dans un vendor de 30 MB.

"Il manque des features avancées (queues Redis, Eloquent full, broadcasting…)" +

Brikocode a sa propre queue JSON offline (no Redis), son propre ORM statique avec relations, son Schema Builder fluent. Pour 95% des projets africains, c'est plus que suffisant. Et si ton projet grandit, rien n'empêche d'installer Brikocode à côté d'un service spécialisé pour les besoins avancés.

"Je connais déjà Slim ou Lumen, pourquoi changer ?" +

Slim et Lumen sont d'excellents micro-frameworks. Mais ni l'un ni l'autre n'a de SMS Africa's Talking natif, d'OTP intégré, de mode offline-first, ni de Low Bandwidth mode. Si ton audience est africaine, Brikocode t'épargne des semaines d'intégration de ces briques.

🔥

Prêt à démarrer en moins de 2 minutes ?

Une commande, et ton projet Brikocode est en ligne.

Terminal
composer create-project brikocode/framework mon-projet
cd mon-projet && php briko env:setup && php briko feu
Démarrage

Installation

Créer un nouveau projet Brikocode en une commande.

Créer un projet

Terminal
composer create-project brikocode/framework mon-projet
cd mon-projet

Configurer l'environnement

Terminal
php briko env:setup

Cela crée le fichier .env depuis .env.example. Ouvre-le et remplis tes valeurs.

Démarrer le serveur

Terminal
php briko feu
# → Serveur lancé sur http://localhost:8000
✅ Prêt ! Ouvre http://localhost:8000 — tu verras la page d'accueil Brikocode.

Installation manuelle (sans Packagist)

Terminal
git clone https://github.com/devKonan/framework mon-projet
cd mon-projet
composer install
php briko env:setup
php briko feu
Démarrage

Structure du projet

Arborescence
mon-projet/
│
├── app/                      ← Ton application
│   ├── routes.php            Définition des routes
│   ├── controllers/          Controllers HTTP
│   ├── models/               Modèles de données
│   ├── mailables/            Classes Mail (Mailable)
│   ├── mails/                Templates HTML d'emails
│   └── views/                Vues HTML
│
├── foundation/               Noyau du framework
│   ├── App.php
│   ├── Env.php
│   ├── Logger.php
│   └── helpers.php           Fonctions globales
│
├── http/                     Couche HTTP
│   ├── Kernel.php
│   ├── Request.php
│   ├── Response.php
│   └── Middleware/
│
├── routing/                  Routeur
│   └── Router.php
│
├── database/                 Base de données
│   ├── Connection.php        PDO singleton
│   ├── QueryBuilder.php      ORM léger
│   ├── DB.php                Facade statique
│   ├── OfflineQueue.php
│   ├── ResponseCache.php
│   └── migrations/           Tes fichiers de migration
│
├── sms/                      Module SMS
├── mail/                     Module Mail
├── console/                  CLI
│
├── storage/
│   ├── logs/                 Logs journaliers
│   ├── cache/                Cache GET offline
│   ├── queue/                File d'attente offline
│   └── otp/                  Codes OTP temporaires
│
├── public/
│   └── index.php             Point d'entrée HTTP
├── briko                     CLI entrypoint
├── .env                      Configuration (ne pas committer)
├── .env.example              Template de config
└── composer.json
Ton code va dans app/. Le reste (http/, database/, mail/, etc.) est le framework — tu n'as normalement pas besoin de le modifier.
Démarrage

Configuration

Toute la configuration passe par le fichier .env à la racine.

.env
# ── Application ──────────────────────────────────────────
APP_NAME=MonApp
APP_ENV=local           # local | production
APP_DEBUG=true
APP_URL=http://localhost:8000
APP_PORT=8000

# ── Logs ─────────────────────────────────────────────────
LOG_LEVEL=DEBUG         # DEBUG | INFO | WARNING | ERROR | CRITICAL

# ── Base de données ──────────────────────────────────────
DB_DRIVER=mysql         # mysql | pgsql | sqlite
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=ma_base
DB_USER=root
DB_PASS=

# SQLite
# DB_DRIVER=sqlite
# DB_PATH=database/db.sqlite

# ── SMS ──────────────────────────────────────────────────
SMS_DRIVER=log          # log | africastalking | twilio | http
SMS_FROM=MonApp

# Africa's Talking (recommandé pour l'Afrique)
AT_USERNAME=sandbox
AT_API_KEY=
AT_SANDBOX=true

# Twilio
TWILIO_SID=
TWILIO_TOKEN=
TWILIO_FROM=

# ── Mail ─────────────────────────────────────────────────
MAIL_DRIVER=log         # log | smtp | sendgrid | mailgun
MAIL_FROM_ADDRESS=noreply@monapp.ci
MAIL_FROM_NAME=MonApp

# SMTP
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_ENCRYPTION=tls     # tls | ssl | none

# SendGrid
SENDGRID_API_KEY=

# Mailgun
MAILGUN_API_KEY=
MAILGUN_DOMAIN=
MAILGUN_REGION=us

Lire les variables dans le code

PHP
// Fonction helper
$appName = env('APP_NAME', 'Brikocode');   // valeur, défaut
$debug   = env('APP_DEBUG', false);

// Classe Env
use Briko\Foundation\Env;
$val = Env::get('APP_URL');
⚠️ Ne jamais committer .env — il contient tes mots de passe et clés API. Le fichier est dans .gitignore par défaut.
HTTP

Routing

Toutes les routes sont définies dans app/routes.php.

Routes de base

app/routes.php
<?php
use App\Controllers\UserController;

// GET
$router->get('/users', [UserController::class, 'index']);

// POST
$router->post('/users', [UserController::class, 'store']);

// PUT
$router->put('/users/{id}', [UserController::class, 'update']);

// PATCH
$router->patch('/users/{id}', [UserController::class, 'patch']);

// DELETE
$router->delete('/users/{id}', [UserController::class, 'destroy']);

// Closure inline
$router->get('/ping', fn() => ['pong' => true, 'ts' => time()]);

Routes dynamiques

Les paramètres {nom} sont automatiquement extraits et accessibles via $request->param().

app/routes.php
$router->get('/users/{id}', [UserController::class, 'show']);
$router->get('/posts/{slug}/comments/{commentId}', [CommentController::class, 'show']);
app/controllers/UserController.php
public function show(Request $request): array
{
    $id = $request->param('id');      // valeur du {id}
    return ['user_id' => $id];
}

Routes avec Middleware

app/routes.php
use Briko\Http\Middleware\Guard;
use Briko\Http\Middleware\OfflineFirst;
use Briko\Http\Middleware\LowBandwidth;

// Un seul middleware
$router->get('/dashboard', [DashboardController::class, 'index'], [Guard::class]);

// Plusieurs middlewares (exécutés dans l'ordre)
$router->post('/orders', [OrderController::class, 'store'], [
    Guard::class,
    OfflineFirst::class,
]);

// Low Bandwidth pour les clients mobiles
$router->get('/api/products', [ProductController::class, 'index'], [LowBandwidth::class]);

Routes pour les vues HTML

Une route qui retourne une page HTML utilise view() dans le controller et appelle Response::html(). La route se définit exactement comme une route JSON — c'est le controller qui décide du format de réponse.

app/routes.php
<?php
use App\Controllers\PageController;
use App\Controllers\UserController;
use App\Middleware\BearerAuth;

// ── Routes HTML (pages) ──────────────────────────────────
$router->get('/',             [PageController::class, 'accueil']);
$router->get('/connexion',    [PageController::class, 'connexion']);
$router->get('/dashboard',    [PageController::class, 'dashboard'],  [BearerAuth::class]);
$router->get('/produits',     [PageController::class, 'produits']);
$router->get('/produits/{id}',[PageController::class, 'produitDetail']);

// ── Routes API JSON ──────────────────────────────────────
$router->post('/api/login',          [UserController::class, 'login']);
$router->get('/api/users',           [UserController::class, 'index'],  [BearerAuth::class]);
$router->post('/api/users',          [UserController::class, 'store'],  [BearerAuth::class]);
$router->put('/api/users/{id}',      [UserController::class, 'update'], [BearerAuth::class]);
Convention : les routes qui commencent par /api/ retournent du JSON. Les autres retournent des vues HTML. Ce n'est pas une règle technique du framework — juste une bonne pratique.
app/controllers/PageController.php
<?php
namespace App\Controllers;

use Briko\Http\Request;
use Briko\Http\Response;

class PageController
{
    // GET /  →  app/views/accueil.php
    public function accueil(Request $request): void
    {
        Response::html(view('accueil', [
            'titre' => 'Accueil',
        ]));
    }

    // GET /connexion  →  app/views/auth/connexion.php
    public function connexion(Request $request): void
    {
        Response::html(view('auth.connexion', [
            'titre' => 'Connexion',
        ]));
    }

    // GET /dashboard  →  app/views/dashboard.php  (route protégée)
    public function dashboard(Request $request): void
    {
        $user = $_REQUEST['_auth_user'];

        $taches = db('tasks')
            ->where('user_id', $user['id'])
            ->orderBy('created_at', 'DESC')
            ->limit(10)
            ->get();

        Response::html(view('dashboard', [
            'titre'  => 'Mon tableau de bord',
            'user'   => $user,
            'taches' => $taches,
        ]));
    }

    // GET /produits  →  app/views/produits/liste.php
    public function produits(Request $request): void
    {
        $produits = db('produits')->where('actif', 1)->orderBy('nom')->get();

        Response::html(view('produits.liste', [
            'titre'    => 'Nos produits',
            'produits' => $produits,
        ]));
    }

    // GET /produits/{id}  →  app/views/produits/detail.php
    public function produitDetail(Request $request): void
    {
        $produit = db('produits')->find($request->param('id'));

        if (!$produit) {
            Response::html(view('erreurs.404', ['titre' => 'Introuvable']), 404);
            return;
        }

        Response::html(view('produits.detail', [
            'titre'   => $produit['nom'],
            'produit' => $produit,
        ]));
    }
}

Les templates PHP correspondants dans app/views/ :

app/views/produits/liste.php
<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="UTF-8">
  <title><?= htmlspecialchars($titre) ?> — <?= env('APP_NAME') ?></title>
</head>
<body>
  <h1><?= htmlspecialchars($titre) ?></h1>

  <?php if (empty($produits)): ?>
    <p>Aucun produit disponible.</p>
  <?php else: ?>
    <ul>
    <?php foreach ($produits as $p): ?>
      <li>
        <a href="/produits/<?= $p['id'] ?>">
          <?= htmlspecialchars($p['nom']) ?>
        </a>
        — <?= number_format($p['prix']) ?> FCFA
      </li>
    <?php endforeach; ?>
    </ul>
  <?php endif; ?>
</body>
</html>
app/views/dashboard.php
<!DOCTYPE html>
<html lang="fr">
<head><meta charset="UTF-8"><title><?= htmlspecialchars($titre) ?></title></head>
<body>
  <h1>Bonjour, <?= htmlspecialchars($user['nom']) ?> !</h1>

  <h2>Mes tâches récentes</h2>
  <?php foreach ($taches as $t): ?>
    <div>
      <strong><?= htmlspecialchars($t['titre']) ?></strong>
      <span><?= $t['statut'] ?></span>
    </div>
  <?php endforeach; ?>

  <!-- Formulaire qui poste vers l'API JSON -->
  <form id="form-tache">
    <input type="text" name="titre" placeholder="Nouvelle tâche">
    <button type="submit">Ajouter</button>
  </form>

  <script>
    // La vue HTML appelle l'API JSON en fetch
    document.getElementById('form-tache').addEventListener('submit', async e => {
      e.preventDefault();
      const titre = e.target.titre.value;
      const token = localStorage.getItem('token');

      await fetch('/api/tasks', {
        method: 'POST',
        headers: {
          'Authorization': 'Bearer ' + token,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ titre }),
      });

      location.reload();
    });
  </script>
</body>
</html>
Pattern hybride : les vues HTML chargent les données initiales côté serveur via view(), puis utilisent fetch() pour appeler les routes API JSON. C'est le pattern le plus courant avec Brikocode.
HTTP

Request

Accéder aux données

PHP
public function store(Request $request): array
{
    // Paramètre de route {id}
    $id = $request->param('id');

    // Input unifié : cherche dans query + body + json
    $name  = $request->input('name');
    $email = $request->input('email', 'defaut@ci.ci');

    // Accès direct
    $query = $request->query;      // tableau $_GET
    $post  = $request->post;       // tableau $_POST
    $files = $request->files;      // tableau $_FILES

    // Corps JSON brut décodé
    $body = $request->body();      // array

    // Tout fusionné (query + post + body + params)
    $all = $request->all();

    return $all;
}

Détection du contexte

PHP
$request->isJson();            // Content-Type: application/json ?
$request->isLowBandwidth();    // ?lb=1 ou X-Low-Bandwidth: 1
$request->acceptsGzip();       // Accept-Encoding: gzip
$request->wantsFields();       // ?fields=id,nom,email
HTTP

Response

Retourner du JSON

Retourner un tableau depuis un controller envoie automatiquement une réponse JSON.

PHP
// Réponse JSON automatique (retour tableau)
public function index(Request $request): array
{
    return ['users' => db('users')->get()];
}

// Avec statut HTTP personnalisé
public function store(Request $request): void
{
    $id = db('users')->insertGetId($request->all());
    Response::json(['id' => $id, 'created' => true], 201);
}

// Erreur
public function show(Request $request): void
{
    $user = db('users')->find($request->param('id'));
    if (!$user) {
        Response::notFound('Utilisateur introuvable');
        return;
    }
    Response::json($user);
}

Réponse HTML

PHP
Response::html('<h1>Bonjour</h1>');
Response::html($html, 200);

Méthodes disponibles

MéthodeDescription
Response::json($data, $status)Réponse JSON
Response::html($html, $status)Réponse HTML
Response::send($content, $status)Détecte automatiquement array=JSON
Response::notFound($message)404 JSON
Response::error($message, $status)Erreur JSON
HTTP

Vues — view()

Le helper view() charge un template PHP depuis app/views/, injecte des variables et retourne le HTML généré.

Différence avec le mail ->view() :
view('page', $data) → charge depuis app/views/ (pages HTML)
mail_to(...)->view('email', $data) → charge depuis app/mails/ (templates email)

Utilisation de base

app/controllers/HomeController.php
<?php
namespace App\Controllers;

use Briko\Http\Request;
use Briko\Http\Response;

class HomeController
{
    public function index(Request $request): void
    {
        Response::html(view('accueil', [
            'titre'     => 'Bienvenue',
            'user'      => ['nom' => 'Aya'],
        ]));
    }
}
app/views/accueil.php
<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="UTF-8">
  <title><?= htmlspecialchars($titre) ?></title>
</head>
<body>
  <h1><?= htmlspecialchars($titre) ?></h1>
  <p>Bonjour, <?= htmlspecialchars($user['nom']) ?> !</p>
</body>
</html>

Vues imbriquées (layouts)

Tu peux composer tes vues avec des includes PHP.

app/views/layout.php
<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="UTF-8">
  <title><?= htmlspecialchars($titre ?? env('APP_NAME')) ?></title>
  <link rel="stylesheet" href="/style.css">
</head>
<body>
  <nav>...navigation...</nav>
  <main>
    <?= $slot ?? '' ?>
  </main>
  <footer>© <?= date('Y') ?> <?= env('APP_NAME') ?></footer>
</body>
</html>
app/views/dashboard.php
<?php
// Capture le contenu de la page
ob_start();
?>
  <h1>Tableau de bord</h1>
  <p>Bienvenue, <?= htmlspecialchars($user['nom']) ?></p>
<?php
$slot = ob_get_clean();

// Inclure le layout
include base_path('app/views/layout.php');
?>

Convention de nommage

Le helper view() supporte les points comme séparateurs de dossier :

PHP
// Charge app/views/admin/users.php
view('admin.users', ['users' => $users]);

// Charge app/views/auth/login.php
view('auth.login');

// Charge app/views/accueil.php
view('accueil', ['titre' => 'Page d\'accueil']);

Retourner vs afficher directement

PHP
// view() retourne le HTML sous forme de string
$html = view('page', $data);

// Response::html() envoie avec le bon Content-Type
Response::html(view('page', $data));

// Si ton controller retourne void, appelle Response::html()
public function show(Request $request): void
{
    $produit = db('produits')->findOrFail($request->param('id'));
    Response::html(view('produits.detail', ['produit' => $produit]));
}

Accès aux helpers dans les vues

Tous les helpers globaux sont disponibles directement dans les fichiers .php de vues :

app/views/accueil.php
<!-- env() disponible dans les vues -->
<title><?= htmlspecialchars(env('APP_NAME')) ?></title>

<!-- base_path() pour les assets -->
<img src="<?= env('APP_URL') ?>/logo.png">

<!-- db() directement dans la vue (déconseillé, préfère passer les données du controller) -->
<?php $stats = db('users')->count(); ?>
<p><?= $stats ?> utilisateurs inscrits</p>
Bonne pratique : charge tes données dans le controller et passe-les en paramètres à view(). Évite les requêtes DB directement dans les vues pour garder ton code maintenable.
HTTP

Middleware

Middlewares inclus

ClasseRôle
GuardExemple d'authentification (à personnaliser)
OfflineFirstCache GET + queue writes si DB hors ligne
LowBandwidthGzip + sélection de champs + strip nulls
HttpLoggerLog automatique de chaque requête HTTP

Créer un middleware

app/controllers/Middleware/AuthMiddleware.php
<?php
namespace App\Middleware;

use Briko\Http\Middleware\MiddlewareInterface;
use Briko\Http\Request;

class AuthMiddleware implements MiddlewareInterface
{
    public function handle(Request $request, callable $next): mixed
    {
        $token = $request->input('token')
               ?? ($_SERVER['HTTP_AUTHORIZATION'] ?? '');

        if (!$this->isValid($token)) {
            return ['error' => 'Non autorisé', '_status' => 401];
        }

        return $next($request);
    }

    private function isValid(string $token): bool
    {
        return $token === env('API_TOKEN', '');
    }
}
app/routes.php
use App\Middleware\AuthMiddleware;

$router->get('/admin/users', [AdminController::class, 'index'], [
    AuthMiddleware::class,
]);
Base de données

Query Builder

ORM fluent construit sur PDO. Supporte MySQL, PostgreSQL et SQLite sans dépendance externe.

SELECT

PHP
// Tous les enregistrements
$users = db('users')->get();

// Avec conditions
$actifs = db('users')
    ->select('id', 'nom', 'email')
    ->where('actif', 1)
    ->where('role', 'admin')
    ->orderBy('nom', 'ASC')
    ->limit(50)
    ->get();

// Un seul résultat
$user = db('users')->where('email', 'aya@ci.ci')->first();

// Par ID
$user = db('users')->find(1);

// findOrFail — lance une exception si absent
$user = db('users')->findOrFail(99);

// Pluck — tableau d'une colonne
$emails = db('users')->where('actif', 1)->pluck('email');

// Compter
$total = db('users')->where('actif', 1)->count();

// Existe ?
$existe = db('users')->where('email', 'test@ci.ci')->exists();

Pagination

PHP
$result = db('users')->where('actif', 1)->paginate(20);
// Retourne :
// {
//   "data": [...],
//   "total": 150,
//   "per_page": 20,
//   "current_page": 1,
//   "last_page": 8
// }

// Page suivante : GET /users?page=2

INSERT

PHP
// Insérer et récupérer l'ID
$id = db('users')->insertGetId([
    'nom'   => 'Aya Koné',
    'email' => 'aya@ci.ci',
    'actif' => 1,
]);

// Insérer sans récupérer l'ID
db('users')->insert([
    'nom'   => 'Kofi',
    'email' => 'kofi@gh.ci',
]);

UPDATE

PHP
$nb = db('users')
    ->where('id', 5)
    ->update(['nom' => 'Aya Koné-Bamba', 'actif' => 0]);

DELETE

PHP
$nb = db('users')->where('id', 5)->delete();

// Supprimer selon plusieurs conditions
db('sessions')->where('user_id', 5)->where('expires_at', '<', time())->delete();

Requêtes brutes

PHP
// SELECT brut
$results = db('users')->raw(
    'SELECT * FROM users WHERE created_at > ?',
    [date('Y-m-d', strtotime('-7 days'))]
);

// Exécution brute (INSERT/UPDATE/DELETE)
db('users')->rawExec(
    'UPDATE users SET score = score + ? WHERE id = ?',
    [10, 42]
);
Base de données

Migrations

Versionne ton schéma de base de données avec des fichiers PHP.

Créer une migration

Place le fichier dans database/migrations/ avec le format YYYY_MM_DD_HHMMSS_description.php :

database/migrations/2024_01_15_120000_create_users_table.php
<?php
return [
    'up' => function (PDO $pdo): void {
        $pdo->exec("
            CREATE TABLE users (
                id         INT AUTO_INCREMENT PRIMARY KEY,
                nom        VARCHAR(100) NOT NULL,
                email      VARCHAR(150) NOT NULL UNIQUE,
                telephone  VARCHAR(20),
                password   VARCHAR(255),
                role       ENUM('user','admin') DEFAULT 'user',
                actif      TINYINT(1) DEFAULT 1,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
        ");
    },
    'down' => function (PDO $pdo): void {
        $pdo->exec("DROP TABLE IF EXISTS users");
    },
];

Commandes

Terminal
# Exécuter les migrations en attente
php briko migrate

# Voir l'état de chaque migration
php briko migrate:status

# Annuler le dernier batch
php briko migrate:rollback

# Tout supprimer et rejouer (dangereux en production !)
php briko migrate:fresh
⚠️ migrate:fresh supprime toutes les tables et les recrée. À utiliser uniquement en développement.
Base de données

Modèles

Générer un modèle

Terminal
php briko fabrique:model User

Crée app/models/User.php :

app/models/User.php
<?php
namespace App\Models;

use Briko\Database\DB;

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

    public static function all(): array
    {
        return DB::table(static::$table)->get();
    }

    public static function find(int|string $id): ?array
    {
        return DB::table(static::$table)->find($id);
    }

    public static function where(string $col, mixed $val): array
    {
        return DB::table(static::$table)->where($col, $val)->get();
    }

    public static function create(array $data): int|string
    {
        return DB::table(static::$table)->insertGetId($data);
    }

    public static function update(int|string $id, array $data): int
    {
        return DB::table(static::$table)->where('id', $id)->update($data);
    }

    public static function delete(int|string $id): int
    {
        return DB::table(static::$table)->where('id', $id)->delete();
    }
}
Services

SMS & OTP

Configuration

.env
SMS_DRIVER=africastalking   # log | africastalking | twilio | http
SMS_FROM=MonApp

# Africa's Talking (recommandé Afrique)
AT_USERNAME=monapp
AT_API_KEY=ma_cle_api
AT_SANDBOX=false            # true en développement

Envoyer un SMS

PHP
use Briko\Sms\SMS;

// Helper global
sms('+225070000000')
    ->message('Votre commande #1042 est confirmée !')
    ->send();

// Multi-destinataires
sms(['+225070000000', '+225090000000'])
    ->message('Nouvelle promotion !')
    ->send();

// Avec expéditeur personnalisé
sms('+225070000000')
    ->from('BOUTIQUE')
    ->message('Bienvenue !')
    ->send();

OTP — Vérification par SMS

PHP
// 1. Générer et envoyer l'OTP
$code = SMS::otp('+225070000000');
// SMS envoyé : "Votre code de vérification : 482931"

// 2. Vérifier le code saisi par l'utilisateur
if (SMS::verifyOtp('+225070000000', $request->input('code'))) {
    // ✅ Numéro vérifié
    return ['verified' => true];
} else {
    // ❌ Code invalide ou expiré
    return ['error' => 'Code invalide'];
}

// Vérifier si un OTP est en attente
$enAttente = SMS::otpPending('+225070000000');

// Annuler un OTP
SMS::cancelOtp('+225070000000');
Durée de vie OTP : 10 minutes par défaut. Max 3 tentatives avant invalidation automatique. Stockage dans storage/otp/ sans base de données.

Drivers disponibles

DriverSMS_DRIVERCouverture
Africa's TalkingafricastalkingCI, GH, SN, KE, NG + 10 pays
TwiliotwilioMondial
HTTP génériquehttpOrange CI, MTN, Moov, Airtel...
Log (dev)logSMS dans les logs, rien envoyé

Tester la config

Terminal
php briko sms:driver              # Voir le driver actif
php briko sms:test +225070000000  # Envoyer un SMS de test
php briko sms:otp +225070000000   # Tester l'OTP
Services

Mail

Configuration

.env
MAIL_DRIVER=smtp
MAIL_FROM_ADDRESS=noreply@monapp.ci
MAIL_FROM_NAME=MonApp

MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USERNAME=ton@gmail.com
MAIL_PASSWORD=ton_mot_de_passe_app   # Mot de passe d'application Google
MAIL_ENCRYPTION=tls

Envoi simple

PHP
use Briko\Mail\Mail;

// HTML direct
Mail::to('client@ci.ci')
    ->subject('Votre commande est confirmée')
    ->html('<h1>Merci pour votre commande !</h1>')
    ->send();

// Helper global
mail_to('client@ci.ci')
    ->subject('Bienvenue !')
    ->html('<p>Ton compte est créé.</p>')
    ->send();

// Multi-destinataires + CC
Mail::to(['a@ci.ci', 'b@ci.ci'])
    ->cc('copie@ci.ci')
    ->subject('Newsletter')
    ->html($html)
    ->send();

Templates PHP

Place tes templates dans app/mails/.

app/mails/bienvenue.php
<!DOCTYPE html>
<html>
<body>
  <h1>Bienvenue, <?= htmlspecialchars($user['nom']) ?> !</h1>
  <p>Ton compte a été créé avec succès.</p>
  <a href="<?= env('APP_URL') ?>">Accéder à mon compte</a>
</body>
</html>
PHP
Mail::to($user['email'])
    ->subject('Bienvenue sur ' . env('APP_NAME'))
    ->view('bienvenue', ['user' => $user])
    ->send();

Mailable (emails réutilisables)

Terminal
php briko fabrique:mail Bienvenue
app/mailables/BienvenueMail.php
<?php
namespace App\Mailables;

use Briko\Mail\Mail;
use Briko\Mail\Mailable;
use Briko\Mail\MailMessage;

class BienvenueMail extends Mailable
{
    public function __construct(private array $user) {}

    public function build(): MailMessage
    {
        return Mail::to($this->user['email'])
            ->subject('Bienvenue !')
            ->view('bienvenue', ['user' => $this->user]);
    }
}
PHP (utilisation)
use App\Mailables\BienvenueMail;

Mail::send(new BienvenueMail($user));

Pièces jointes

PHP
Mail::to($email)
    ->subject('Votre facture')
    ->html($html)
    ->attach(base_path('storage/factures/facture-1042.pdf'))
    ->send();
Services

Logging

Logger global

PHP
// Helper global
logger('Utilisateur connecté', ['user_id' => 5]);
logger('Paiement échoué', ['montant' => 5000, 'code' => 'INSUF'], 'error');
logger('SMS envoyé', ['to' => '+225070000000'], 'info');

// Niveaux : debug | info | warning | error | critical
logger('Message', $context, 'warning');

Logger par canal

PHP
use Briko\Foundation\Logger;

Logger::channel('paiements')->info('Paiement reçu', ['montant' => 25000]);
Logger::channel('api')->warning('Rate limit atteint', ['ip' => '41.190.1.1']);
Logger::channel('mail')->error('Envoi échoué', ['to' => 'test@ci.ci']);

Les logs sont écrits dans storage/logs/YYYY-MM-DD-{canal}.log + un log combiné journalier.

Format des logs

storage/logs/2024-01-15-api.log
{
  "ts": "2024-01-15T14:32:11+00:00",
  "request_id": "a3f9b2c1",
  "level": "INFO",
  "channel": "api",
  "message": "Paiement reçu",
  "elapsed_ms": 42,
  "memory_kb": 1204,
  "context": {"montant": 25000}
}

Commandes CLI

Terminal
php briko logs              # 50 dernières lignes (log combiné)
php briko logs api 100      # 100 dernières lignes du canal "api"
php briko logs:tail         # Suivre en temps réel
php briko logs:tail paiements
php briko logs:clear        # Supprimer tous les logs
Services

Offline-First

Conçu pour les connexions instables. Le middleware OfflineFirst gère automatiquement les pannes de base de données.

Activer

app/routes.php
use Briko\Http\Middleware\OfflineFirst;

$router->get('/users', [UserController::class, 'index'], [OfflineFirst::class]);
$router->post('/orders', [OrderController::class, 'store'], [OfflineFirst::class]);

Comportement

RequêteDB disponibleDB hors ligne
GETRéponse normaleRéponse depuis le cache fichier
POST / PUT / DELETEExécution normaleAjouté dans la file d'attente JSON

Synchronisation

Terminal
php briko sync:status    # Voir les requêtes en attente
php briko sync           # Rejouer les requêtes (une fois la DB revenue)
php briko sync:flush     # Vider la file d'attente
Services

Payment — Mobile Money & Paiements

Module de paiement driver-based : Orange Money, MTN MoMo, Wave, CinetPay, PayDunya, Stripe. Une API unifiée — tu changes le provider dans .env, zéro modification de code.

🟠
Orange Money
CI · SN · CM
🟡
MTN MoMo
Multi-pays MTN
🌊
Wave
CI · SN
🔵
CinetPay
8 pays Afrique
🟢
PayDunya
SN · ML · BF · CI
💜
Stripe
International

Configuration

Dans .env, choisis ton provider et renseigne ses clés. Le code de ton app ne change pas.

.env
PAYMENT_DRIVER=cinetpay   # log | orangemoney | mtnmomo | wave | cinetpay | paydunya | stripe
PAYMENT_CURRENCY=XOF

Orange Money CI

Obtiens tes clés sur developer.orange.com → API Orange Money Web Payment.

.env
PAYMENT_DRIVER=orangemoney
ORANGE_MONEY_CLIENT_ID=ton_client_id
ORANGE_MONEY_CLIENT_SECRET=ton_client_secret
ORANGE_MONEY_MERCHANT_KEY=ta_merchant_key
ORANGE_MONEY_RETURN_URL=https://ton-site.ci/paiement/retour
ORANGE_MONEY_CANCEL_URL=https://ton-site.ci/paiement/annule
ORANGE_MONEY_NOTIF_URL=https://ton-site.ci/webhooks/payment

MTN Mobile Money

Obtiens tes clés sur momodeveloper.mtn.com. En sandbox, crée un API User via le portail.

.env
PAYMENT_DRIVER=mtnmomo
MTN_MOMO_SUBSCRIPTION_KEY=ta_subscription_key
MTN_MOMO_API_USER=uuid_api_user
MTN_MOMO_API_KEY=ta_api_key
MTN_MOMO_ENVIRONMENT=sandbox          # sandbox | production
MTN_MOMO_CURRENCY=XOF
MTN_MOMO_CALLBACK_URL=https://ton-site.ci/webhooks/payment

Wave

Obtiens ta clé sur wave.com/business → API.

.env
PAYMENT_DRIVER=wave
WAVE_API_KEY=ta_cle_secrete
WAVE_SUCCESS_URL=https://ton-site.ci/paiement/succes
WAVE_ERROR_URL=https://ton-site.ci/paiement/erreur
WAVE_CALLBACK_URL=https://ton-site.ci/webhooks/payment

CinetPay — 8 pays africains

Obtiens tes clés sur cinetpay.com → Dashboard → Mes applications. Couvre CI, SN, CM, BF, ML, TG, GN, MG.

.env
PAYMENT_DRIVER=cinetpay
CINETPAY_API_KEY=ta_api_key
CINETPAY_SITE_ID=ton_site_id
CINETPAY_NOTIFY_URL=https://ton-site.ci/webhooks/payment
CINETPAY_RETURN_URL=https://ton-site.ci/paiement/retour

PayDunya — Sénégal, Mali, Burkina, CI

Obtiens tes clés sur paydunya.com/developers.

.env
PAYMENT_DRIVER=paydunya
PAYDUNYA_MASTER_KEY=ta_master_key
PAYDUNYA_PRIVATE_KEY=ta_private_key
PAYDUNYA_TOKEN=ton_token
PAYDUNYA_STORE_NAME=Mon App
PAYDUNYA_ENV=test                     # test | live
PAYDUNYA_RETURN_URL=https://ton-site.ci/paiement/retour
PAYDUNYA_CANCEL_URL=https://ton-site.ci/paiement/annule
PAYDUNYA_CALLBACK_URL=https://ton-site.ci/webhooks/payment

Stripe — International

Obtiens ta clé sur dashboard.stripe.com → Développeurs → Clés API.

.env
PAYMENT_DRIVER=stripe
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...       # optionnel mais recommandé
STRIPE_SUCCESS_URL=https://ton-site.ci/paiement/succes
STRIPE_CANCEL_URL=https://ton-site.ci/paiement/annule

Initier un paiement

L'helper pay() retourne un PaymentMessage fluent. L'API est identique quel que soit le provider.

PHP
$result = pay(5000)                          // montant en FCFA
    ->to('+2250701234567')                   // numéro à débiter (MoMo push) ou client (redirection)
    ->description('Commande #42')
    ->reference('ORDER-42')                  // ID unique, auto-généré si omis
    ->send();

// Providers à redirection (Orange, Wave, CinetPay, PayDunya, Stripe)
if ($result->isPending() && $result->paymentUrl) {
    header('Location: ' . $result->paymentUrl);
    exit;
}

// MTN MoMo (push direct sur le téléphone)
if ($result->isPending()) {
    return ['message' => 'Confirme le paiement sur ton téléphone MTN.'];
}

if ($result->isFailed()) {
    return ['error' => $result->message];
}

Options avancées

PHP
pay(10000)
    ->to('+2250701234567')
    ->email('client@email.ci')        // email du client (Stripe, CinetPay)
    ->name('Koné Amadou')             // nom complet du client
    ->description('Abonnement Pro')
    ->reference('SUB-2026-001')
    ->currency('XOF')                 // défaut : PAYMENT_CURRENCY dans .env
    ->returnUrl('https://...')        // surcharge RETURN_URL du .env
    ->cancelUrl('https://...')        // surcharge CANCEL_URL du .env
    ->callbackUrl('https://...')      // surcharge NOTIFY_URL du .env
    ->meta(['user_id' => 42])         // données personnalisées (stockées dans raw)
    ->send();

PaymentResult

PropriétéTypeDescription
$result->successbooltrue si initiation réussie (même si paiement encore en attente)
$result->statusstringpending | success | failed | cancelled
$result->transactionIdstringID de transaction retourné par le provider
$result->paymentUrl?stringURL de redirection (Orange, Wave, CinetPay, PayDunya, Stripe)
$result->amountintMontant de la transaction
$result->currencystringDevise (XOF, USD, EUR…)
$result->messagestringMessage lisible (succès ou erreur)
$result->rawarrayRéponse brute de l'API provider
PHP — méthodes helper
$result->isOk()      // true si success ou pending (initiation OK)
$result->isPending() // true si en attente de confirmation
$result->isFailed()  // true si échec

Vérifier une transaction

Après une redirection retour ou un délai, vérifie l'état réel de la transaction.

PHP
use Briko\Payment\Payment;

$result = Payment::verify('CP-TXN123456');

if ($result->isOk()) {
    // Mettre à jour la commande en BDD
    db('orders')->where('ref', 'ORDER-42')->update(['status' => 'paid']);
}

Webhooks — callbacks asynchrones

Les providers envoient un POST sur ton URL de notification quand le paiement est confirmé. Le WebhookHandler normalise automatiquement le payload quel que soit le provider.

app/routes.php
use Briko\Payment\WebhookHandler;

$router->post('/webhooks/payment', function ($request) {
    $result = WebhookHandler::handle($request->body());

    if ($result->isOk()) {
        // Commande payée — mets à jour ta BDD
        db('orders')
            ->where('ref', $result->transactionId)
            ->update(['status' => 'paid', 'paid_at' => date('Y-m-d H:i:s')]);

        // Envoie un SMS de confirmation
        sms($request->body()['phone'] ?? '')
            ->send('Paiement reçu ✅. Merci !');
    }

    return ['ok' => true];  // Toujours répondre 200 au provider
});

Vérification signature Stripe

app/routes.php
$router->post('/webhooks/stripe', function ($request) {
    $rawBody = file_get_contents('php://input');
    $sig     = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';

    if (!WebhookHandler::verifyStripeSignature($rawBody, $sig)) {
        http_response_code(400);
        return ['error' => 'Signature invalide'];
    }

    $result = WebhookHandler::handle($request->body(), 'stripe');
    // ... traitement
    return ['ok' => true];
});

Exemple complet — Controller Checkout

app/controllers/CheckoutController.php
all(), [
            'amount' => 'required|numeric|min:100',
            'phone'  => 'required',
        ]);

        if ($v->fails()) {
            Response::json(['errors' => $v->errors()], 422);
            return;
        }

        $orderId = 'ORDER-' . time();

        // Sauvegarder la commande en attente
        db('orders')->insert([
            'ref'    => $orderId,
            'amount' => $request->input('amount'),
            'status' => 'pending',
        ]);

        $result = pay((int) $request->input('amount'))
            ->to($request->input('phone'))
            ->description('Commande ' . $orderId)
            ->reference($orderId)
            ->send();

        if ($result->isFailed()) {
            Response::json(['error' => $result->message], 400);
            return;
        }

        // Provider à redirection (CinetPay, Orange, Wave…)
        if ($result->paymentUrl) {
            Response::json([
                'status'      => 'redirect',
                'payment_url' => $result->paymentUrl,
                'tx_id'       => $result->transactionId,
            ]);
            return;
        }

        // MTN MoMo (push mobile)
        Response::json([
            'status'  => 'pending',
            'message' => $result->message,
            'tx_id'   => $result->transactionId,
        ]);
    }

    // GET /checkout/verify?tx_id=... — vérification manuelle
    public function verify(Request $request): array
    {
        $txId  = $request->input('tx_id');
        $result = Payment::verify($txId);

        if ($result->isOk()) {
            db('orders')->where('ref', $txId)->update(['status' => 'paid']);
        }

        return [
            'status'  => $result->status,
            'message' => $result->message,
        ];
    }

    // POST /webhooks/payment — appelé par le provider
    public function webhook(Request $request): array
    {
        $result = WebhookHandler::handle($request->body());

        if ($result->isOk()) {
            db('orders')
                ->where('ref', $result->transactionId)
                ->update(['status' => 'paid', 'paid_at' => date('Y-m-d H:i:s')]);
        }

        return ['ok' => true];
    }
}
app/routes.php
use App\Controllers\CheckoutController;

$router->post('/checkout',         [CheckoutController::class, 'initiate']);
$router->get('/checkout/verify',   [CheckoutController::class, 'verify']);
$router->post('/webhooks/payment', [CheckoutController::class, 'webhook']);

Commandes CLI Payment

CommandeDescription
php briko payment:driverAffiche le driver actif et vérifie les clés .env
php briko payment:test <tel> [montant]Teste un paiement en sandbox
php briko payment:verify <txId>Vérifie l'état d'une transaction
Terminal
# Vérifier la configuration du driver actif
php briko payment:driver

# Tester un paiement de 2500 XOF (utilise le driver .env)
php briko payment:test +2250701234567 2500

# Vérifier une transaction existante
php briko payment:verify CP-TXN123ABC

Comparatif des providers

ProviderPaysFluxSandboxPortail
Orange Money CI, SN, CM… Redirection developer.orange.com
MTN MoMo Multi-pays MTN Push mobile (async) momodeveloper.mtn.com
Wave CI, SN Redirection wave.com/business
CinetPay CI, SN, CM, BF, ML, TG, GN, MG Redirection cinetpay.com
PayDunya SN, ML, BF, CI Redirection paydunya.com/developers
Stripe International Redirection (page Stripe) dashboard.stripe.com
Log Simulé (logs uniquement) aucun portail requis
Outils

CLI — Console

Toutes les commandes s'exécutent avec php briko <commande>.

Référence complète

CommandeDescription
php briko feuDémarrer le serveur de développement (port 8000)
php briko env:setupCréer .env depuis .env.example
php briko migrateExécuter les migrations en attente
php briko migrate:statusÉtat de chaque migration
php briko migrate:rollbackAnnuler le dernier batch
php briko migrate:freshTout supprimer et rejouer
php briko syncRejouer la file d'attente offline
php briko sync:statusVoir la file d'attente
php briko sync:flushVider la file d'attente
php briko logs [canal] [n]Afficher les N dernières lignes
php briko logs:tail [canal]Suivre les logs en temps réel
php briko logs:clearSupprimer tous les fichiers de log
php briko sms:test <numéro>Envoyer un SMS de test
php briko sms:otp <numéro>Générer et envoyer un OTP
php briko sms:driverAfficher la config SMS active
php briko mail:test <email>Envoyer un email de test
php briko mail:driverAfficher la config Mail active
php briko payment:driverAfficher la config Payment active et vérifier les clés
php briko payment:test <tel> [montant]Tester un paiement en sandbox
php briko payment:verify <txId>Vérifier l'état d'une transaction
php briko fabrique:controller <Nom>Créer un controller REST
php briko fabrique:model <Nom>Créer un modèle
php briko fabrique:mail <Nom>Créer un Mailable + template
php briko fabrique:migration <nom>Créer un fichier de migration
php briko fabrique:seeder <Nom>Créer un Seeder
php briko db:seed [--class=Nom]Exécuter les seeders
php briko helpAfficher l'aide
Outils

Helpers globaux

FonctionDescriptionExemple
env($key, $default)Lire une variable .envenv('APP_NAME')
db($table)Query Builder sur une tabledb('users')->get()
sms($to)Créer un message SMSsms('+225...')->message('...')
mail_to($email)Créer un message Mailmail_to('a@b.ci')->subject('...')
view($template, $data)Rendre un template depuis app/views/view('accueil', ['titre' => 'Home'])
logger($msg, $ctx, $level)Écrire un loglogger('Erreur', [], 'error')
base_path($path)Chemin absolu depuis la racinebase_path('storage/file.pdf')
Tutoriel

🔥 API REST — de A à Z

Construis une API complète de gestion de tâches avec authentification par token, CRUD complet et vérification OTP.

Ce que tu vas construire : une API REST /api/tasks avec authentification, CRUD, pagination, OTP SMS et notifications mail.

1. Créer le projet

1
Installation & configuration
Terminal
composer create-project brikocode/framework taskapi
cd taskapi
php briko env:setup

Édite .env :

.env
APP_NAME=TaskAPI
APP_ENV=local
APP_URL=http://localhost:8000

DB_DRIVER=mysql
DB_HOST=127.0.0.1
DB_NAME=taskapi
DB_USER=root
DB_PASS=

SMS_DRIVER=log
MAIL_DRIVER=log

2. Migrations

2
Créer les tables
database/migrations/2024_01_01_000001_create_users_table.php
<?php
return [
    'up' => function (PDO $pdo): void {
        $pdo->exec("
            CREATE TABLE users (
                id        INT AUTO_INCREMENT PRIMARY KEY,
                nom       VARCHAR(100) NOT NULL,
                email     VARCHAR(150) NOT NULL UNIQUE,
                telephone VARCHAR(20),
                token     VARCHAR(64),
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
        ");
    },
    'down' => fn(PDO $pdo) => $pdo->exec("DROP TABLE IF EXISTS users"),
];
database/migrations/2024_01_01_000002_create_tasks_table.php
<?php
return [
    'up' => function (PDO $pdo): void {
        $pdo->exec("
            CREATE TABLE tasks (
                id          INT AUTO_INCREMENT PRIMARY KEY,
                user_id     INT NOT NULL,
                titre       VARCHAR(200) NOT NULL,
                description TEXT,
                statut      ENUM('todo','en_cours','fait') DEFAULT 'todo',
                priorite    TINYINT DEFAULT 1,
                created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
            )
        ");
    },
    'down' => fn(PDO $pdo) => $pdo->exec("DROP TABLE IF EXISTS tasks"),
];
Terminal
php briko migrate

3. Middleware d'authentification

3
Token Bearer
app/Middleware/BearerAuth.php
<?php
namespace App\Middleware;

use Briko\Http\Middleware\MiddlewareInterface;
use Briko\Http\Request;

class BearerAuth implements MiddlewareInterface
{
    public function handle(Request $request, callable $next): mixed
    {
        $header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
        $token  = str_replace('Bearer ', '', $header);

        if (!$token) {
            return ['error' => 'Token manquant', '_status' => 401];
        }

        $user = db('users')->where('token', $token)->first();
        if (!$user) {
            return ['error' => 'Token invalide', '_status' => 401];
        }

        // Injecte l'utilisateur dans la requête
        $_REQUEST['_auth_user'] = $user;

        return $next($request);
    }
}

4. Controllers

4
AuthController + TaskController
Terminal
php briko fabrique:controller AuthController
php briko fabrique:controller TaskController
app/controllers/AuthController.php
<?php
namespace App\Controllers;

use Briko\Http\Request;
use Briko\Sms\SMS;

class AuthController
{
    // POST /api/register
    public function register(Request $request): array
    {
        $data = $request->all();

        if (empty($data['email']) || empty($data['nom'])) {
            return ['error' => 'nom et email requis', '_status' => 422];
        }

        if (db('users')->where('email', $data['email'])->exists()) {
            return ['error' => 'Email déjà utilisé', '_status' => 409];
        }

        $token = bin2hex(random_bytes(32));
        $id    = db('users')->insertGetId([
            'nom'       => $data['nom'],
            'email'     => $data['email'],
            'telephone' => $data['telephone'] ?? null,
            'token'     => $token,
        ]);

        // Email de bienvenue
        mail_to($data['email'])
            ->subject('Bienvenue sur ' . env('APP_NAME'))
            ->html('<h2>Bienvenue, ' . htmlspecialchars($data['nom']) . ' !</h2>')
            ->send();

        return ['id' => $id, 'token' => $token, '_status' => 201];
    }

    // POST /api/otp/send
    public function sendOtp(Request $request): array
    {
        $phone = $request->input('telephone');
        if (!$phone) return ['error' => 'téléphone requis', '_status' => 422];

        SMS::otp($phone);
        return ['message' => 'OTP envoyé'];
    }

    // POST /api/otp/verify
    public function verifyOtp(Request $request): array
    {
        $phone = $request->input('telephone');
        $code  = $request->input('code');

        if (SMS::verifyOtp($phone, $code)) {
            return ['verified' => true];
        }
        return ['error' => 'Code invalide ou expiré', '_status' => 400];
    }
}
app/controllers/TaskController.php
<?php
namespace App\Controllers;

use Briko\Http\Request;

class TaskController
{
    private function authUser(): array
    {
        return $_REQUEST['_auth_user'];
    }

    // GET /api/tasks
    public function index(Request $request): array
    {
        $user = $this->authUser();
        return db('tasks')
            ->where('user_id', $user['id'])
            ->orderBy('created_at', 'DESC')
            ->paginate(20);
    }

    // GET /api/tasks/{id}
    public function show(Request $request): array
    {
        $user = $this->authUser();
        $task = db('tasks')
            ->where('id', $request->param('id'))
            ->where('user_id', $user['id'])
            ->first();

        if (!$task) return ['error' => 'Tâche introuvable', '_status' => 404];
        return $task;
    }

    // POST /api/tasks
    public function store(Request $request): array
    {
        $user = $this->authUser();
        $data = $request->all();

        if (empty($data['titre'])) {
            return ['error' => 'Le titre est requis', '_status' => 422];
        }

        $id = db('tasks')->insertGetId([
            'user_id'     => $user['id'],
            'titre'       => $data['titre'],
            'description' => $data['description'] ?? null,
            'priorite'    => $data['priorite'] ?? 1,
        ]);

        return ['id' => $id, 'created' => true, '_status' => 201];
    }

    // PUT /api/tasks/{id}
    public function update(Request $request): array
    {
        $user   = $this->authUser();
        $taskId = $request->param('id');
        $task   = db('tasks')->where('id', $taskId)->where('user_id', $user['id'])->first();

        if (!$task) return ['error' => 'Tâche introuvable', '_status' => 404];

        $data = array_filter($request->all(), fn($v) => $v !== null);
        unset($data['user_id']);

        db('tasks')->where('id', $taskId)->update($data);
        return ['updated' => true];
    }

    // DELETE /api/tasks/{id}
    public function destroy(Request $request): array
    {
        $user   = $this->authUser();
        $taskId = $request->param('id');
        $task   = db('tasks')->where('id', $taskId)->where('user_id', $user['id'])->first();

        if (!$task) return ['error' => 'Tâche introuvable', '_status' => 404];

        db('tasks')->where('id', $taskId)->delete();
        return ['deleted' => true];
    }
}

5. Routes

5
Définir les routes
app/routes.php
<?php
use App\Controllers\AuthController;
use App\Controllers\TaskController;
use App\Middleware\BearerAuth;

/** @var \Briko\Routing\Router $router */

// Auth (publiques)
$router->post('/api/register',    [AuthController::class, 'register']);
$router->post('/api/otp/send',    [AuthController::class, 'sendOtp']);
$router->post('/api/otp/verify',  [AuthController::class, 'verifyOtp']);

// Tasks (protégées)
$router->get('/api/tasks',        [TaskController::class, 'index'],   [BearerAuth::class]);
$router->get('/api/tasks/{id}',   [TaskController::class, 'show'],    [BearerAuth::class]);
$router->post('/api/tasks',       [TaskController::class, 'store'],   [BearerAuth::class]);
$router->put('/api/tasks/{id}',   [TaskController::class, 'update'],  [BearerAuth::class]);
$router->delete('/api/tasks/{id}',[TaskController::class, 'destroy'], [BearerAuth::class]);

6. Tester l'API

6
curl / Postman
Terminal
# Lancer le serveur
php briko feu

# S'inscrire
curl -X POST http://localhost:8000/api/register \
  -H "Content-Type: application/json" \
  -d '{"nom":"Aya Koné","email":"aya@ci.ci","telephone":"+225070000000"}'
# → {"id":1,"token":"abc123..."}

# Créer une tâche
curl -X POST http://localhost:8000/api/tasks \
  -H "Authorization: Bearer abc123..." \
  -H "Content-Type: application/json" \
  -d '{"titre":"Finir le rapport","priorite":2}'
# → {"id":1,"created":true}

# Lister les tâches
curl http://localhost:8000/api/tasks \
  -H "Authorization: Bearer abc123..."

# Mettre à jour le statut
curl -X PUT http://localhost:8000/api/tasks/1 \
  -H "Authorization: Bearer abc123..." \
  -H "Content-Type: application/json" \
  -d '{"statut":"fait"}'

7. Ajouter une interface HTML

7
Routes HTML + vues pour l'interface utilisateur

L'API JSON reste intacte. On ajoute des routes HTML qui affichent des pages utilisant cette API via fetch().

app/routes.php — ajout des routes HTML
use App\Controllers\PageController;

// Routes HTML (nouvelles)
$router->get('/',         [PageController::class, 'accueil']);
$router->get('/app',      [PageController::class, 'appShell'], [BearerAuth::class]);

// Routes API (existantes, inchangées)
$router->post('/api/register',    [AuthController::class, 'register']);
// ...
app/controllers/PageController.php
<?php
namespace App\Controllers;

use Briko\Http\Request;
use Briko\Http\Response;

class PageController
{
    // Page d'accueil avec formulaire de connexion
    public function accueil(Request $request): void
    {
        Response::html(view('accueil', [
            'titre' => 'TaskAPI — Gérez vos tâches',
        ]));
    }

    // Shell de l'application (chargé une fois après login)
    public function appShell(Request $request): void
    {
        $user = $_REQUEST['_auth_user'];
        Response::html(view('app', [
            'titre' => 'Mes tâches',
            'user'  => $user,
        ]));
    }
}
app/views/accueil.php
<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="UTF-8">
  <title><?= htmlspecialchars($titre) ?></title>
  <style>
    body { font-family: sans-serif; max-width: 400px; margin: 60px auto; padding: 0 1rem; }
    input, button { width: 100%; padding: .6rem; margin: .4rem 0; }
    button { background: #ff6600; color: #fff; border: none; cursor: pointer; }
  </style>
</head>
<body>
  <h1><?= htmlspecialchars($titre) ?></h1>

  <form id="form-register">
    <h2>Créer un compte</h2>
    <input type="text"  name="nom"   placeholder="Ton nom">
    <input type="email" name="email" placeholder="Email">
    <button type="submit">S'inscrire</button>
    <p id="msg-register"></p>
  </form>

  <script>
    document.getElementById('form-register').addEventListener('submit', async e => {
      e.preventDefault();
      const data = Object.fromEntries(new FormData(e.target));
      const res  = await fetch('/api/register', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data),
      });
      const json = await res.json();
      if (json.token) {
        localStorage.setItem('token', json.token);
        location.href = '/app';   // redirige vers la vue app
      } else {
        document.getElementById('msg-register').textContent = json.error;
      }
    });
  </script>
</body>
</html>
app/views/app.php
<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="UTF-8">
  <title><?= htmlspecialchars($titre) ?></title>
  <style>
    body { font-family: sans-serif; max-width: 600px; margin: 40px auto; padding: 0 1rem; }
    .task { padding: .5rem; border-bottom: 1px solid #eee; display: flex; gap: .5rem; }
    .task.fait { opacity: .5; text-decoration: line-through; }
    input[type=text] { flex: 1; padding: .5rem; }
    button { padding: .5rem 1rem; background: #ff6600; color: #fff; border: none; cursor: pointer; }
  </style>
</head>
<body>
  <h1>Bonjour, <?= htmlspecialchars($user['nom']) ?> ! 👋</h1>

  <form id="form-add">
    <input type="text" id="titre" placeholder="Nouvelle tâche..." required>
    <button type="submit">Ajouter</button>
  </form>

  <div id="tasks">Chargement...</div>

  <script>
    const token = localStorage.getItem('token');
    if (!token) location.href = '/';

    // Charger les tâches via l'API
    async function loadTasks() {
      const res   = await fetch('/api/tasks', {
        headers: { 'Authorization': 'Bearer ' + token }
      });
      const json  = await res.json();
      const tasks = json.data ?? json;
      document.getElementById('tasks').innerHTML = tasks.map(t => `
        <div class="task ${t.statut === 'fait' ? 'fait' : ''}">
          <span>${t.titre}</span>
          <button onclick="updateTask(${t.id}, 'fait')">✓</button>
          <button onclick="deleteTask(${t.id})">✕</button>
        </div>
      `).join('');
    }

    // Ajouter une tâche
    document.getElementById('form-add').addEventListener('submit', async e => {
      e.preventDefault();
      const titre = document.getElementById('titre').value;
      await fetch('/api/tasks', {
        method: 'POST',
        headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
        body: JSON.stringify({ titre }),
      });
      document.getElementById('titre').value = '';
      loadTasks();
    });

    async function updateTask(id, statut) {
      await fetch('/api/tasks/' + id, {
        method: 'PUT',
        headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
        body: JSON.stringify({ statut }),
      });
      loadTasks();
    }

    async function deleteTask(id) {
      await fetch('/api/tasks/' + id, {
        method: 'DELETE',
        headers: { 'Authorization': 'Bearer ' + token },
      });
      loadTasks();
    }

    loadTasks();
  </script>
</body>
</html>
🎉 Application complète ! Routes HTML pour les pages + routes API JSON pour les données. Les vues HTML utilisent view() côté serveur et fetch() pour appeler l'API. Aucune dépendance JS externe.
Tutoriel

🚀 Projet complet — de A à Z

Une application de gestion de commandes avec auth SMS OTP, produits, commandes, emails de confirmation et mode offline.

Ce que tu vas construire : API e-commerce — catalogue produits, panier, commandes, vérification OTP, emails de confirmation, mode offline-first.

1. Setup

1
Création du projet
Terminal
composer create-project brikocode/framework boutique
cd boutique
php briko env:setup
.env
APP_NAME=Boutique
DB_NAME=boutique
SMS_DRIVER=africastalking
AT_USERNAME=boutique
AT_API_KEY=ta_cle_api
AT_SANDBOX=false
MAIL_DRIVER=smtp
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USERNAME=ta@gmail.com
MAIL_PASSWORD=mot_de_passe_app

2. Base de données

2
3 tables : users, produits, commandes
database/migrations/2024_01_01_000001_create_users_table.php
return [
    'up' => function(PDO $pdo) {
        $pdo->exec("CREATE TABLE users (
            id         INT AUTO_INCREMENT PRIMARY KEY,
            nom        VARCHAR(100) NOT NULL,
            telephone  VARCHAR(20) NOT NULL UNIQUE,
            email      VARCHAR(150),
            token      VARCHAR(64),
            verifie    TINYINT DEFAULT 0,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )");
    },
    'down' => fn($p) => $p->exec("DROP TABLE IF EXISTS users"),
];
database/migrations/2024_01_01_000002_create_produits_table.php
return [
    'up' => function(PDO $pdo) {
        $pdo->exec("CREATE TABLE produits (
            id         INT AUTO_INCREMENT PRIMARY KEY,
            nom        VARCHAR(200) NOT NULL,
            prix       DECIMAL(10,0) NOT NULL,
            stock      INT DEFAULT 0,
            categorie  VARCHAR(100),
            actif      TINYINT DEFAULT 1
        )");
    },
    'down' => fn($p) => $p->exec("DROP TABLE IF EXISTS produits"),
];
database/migrations/2024_01_01_000003_create_commandes_table.php
return [
    'up' => function(PDO $pdo) {
        $pdo->exec("CREATE TABLE commandes (
            id         INT AUTO_INCREMENT PRIMARY KEY,
            user_id    INT NOT NULL,
            produit_id INT NOT NULL,
            quantite   INT DEFAULT 1,
            total      DECIMAL(10,0),
            statut     ENUM('en_attente','payee','livree') DEFAULT 'en_attente',
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            FOREIGN KEY (user_id) REFERENCES users(id),
            FOREIGN KEY (produit_id) REFERENCES produits(id)
        )");
    },
    'down' => fn($p) => $p->exec("DROP TABLE IF EXISTS commandes"),
];
Terminal
php briko migrate

3. Auth OTP par SMS

3
Inscription + vérification numéro
app/controllers/AuthController.php
<?php
namespace App\Controllers;

use Briko\Http\Request;
use Briko\Sms\SMS;

class AuthController
{
    // POST /auth/register — étape 1 : enregistrement
    public function register(Request $request): array
    {
        $tel = $request->input('telephone');
        $nom = $request->input('nom');
        if (!$tel || !$nom) return ['error' => 'nom et telephone requis', '_status' => 422];

        $user = db('users')->where('telephone', $tel)->first();
        if (!$user) {
            db('users')->insert(['nom' => $nom, 'telephone' => $tel]);
        }

        SMS::otp($tel);  // Envoie le code
        return ['message' => 'Code OTP envoyé au ' . $tel];
    }

    // POST /auth/verify — étape 2 : vérification OTP
    public function verify(Request $request): array
    {
        $tel  = $request->input('telephone');
        $code = $request->input('code');

        if (!SMS::verifyOtp($tel, $code)) {
            return ['error' => 'Code invalide ou expiré', '_status' => 400];
        }

        $token = bin2hex(random_bytes(32));
        db('users')->where('telephone', $tel)->update([
            'token'   => $token,
            'verifie' => 1,
        ]);

        $user = db('users')->where('telephone', $tel)->first();

        // Mail de bienvenue si email renseigné
        if (!empty($user['email'])) {
            mail_to($user['email'])
                ->subject('Bienvenue sur ' . env('APP_NAME'))
                ->view('bienvenue', ['user' => $user])
                ->send();
        }

        return ['token' => $token, 'user' => $user];
    }
}

4. Catalogue & Commandes

4
ProduitController + CommandeController
app/controllers/ProduitController.php
<?php
namespace App\Controllers;

use Briko\Http\Request;

class ProduitController
{
    // GET /produits — liste avec mode offline-first
    public function index(Request $request): array
    {
        return db('produits')
            ->where('actif', 1)
            ->orderBy('nom')
            ->paginate(20);
    }

    // GET /produits/{id}
    public function show(Request $request): array
    {
        $p = db('produits')->find($request->param('id'));
        if (!$p) return ['error' => 'Produit introuvable', '_status' => 404];
        return $p;
    }
}

app/controllers/CommandeController.php
<?php
namespace App\Controllers;

use Briko\Http\Request;

class CommandeController
{
    private function authUser(): array
    {
        return $_REQUEST['_auth_user'];
    }

    // POST /commandes
    public function store(Request $request): array
    {
        $user   = $this->authUser();
        $data   = $request->all();
        $qte    = max(1, (int)($data['quantite'] ?? 1));

        $produit = db('produits')->find($data['produit_id'] ?? 0);
        if (!$produit) return ['error' => 'Produit introuvable', '_status' => 404];
        if ($produit['stock'] < $qte) return ['error' => 'Stock insuffisant', '_status' => 400];

        $total = $produit['prix'] * $qte;

        $id = db('commandes')->insertGetId([
            'user_id'    => $user['id'],
            'produit_id' => $produit['id'],
            'quantite'   => $qte,
            'total'      => $total,
        ]);

        // Décrémenter le stock
        db('produits')->where('id', $produit['id'])
            ->update(['stock' => $produit['stock'] - $qte]);

        // SMS de confirmation
        sms($user['telephone'])
            ->message("Commande #{$id} confirmée ! Total : {$total} FCFA")
            ->send();

        // Email si disponible
        if (!empty($user['email'])) {
            mail_to($user['email'])
                ->subject("Commande #{$id} confirmée")
                ->html("
                    <h2>Commande #{$id} confirmée !</h2>
                    <p>Produit : {$produit['nom']}</p>
                    <p>Quantité : {$qte}</p>
                    <p><strong>Total : {$total} FCFA</strong></p>
                ")
                ->send();
        }

        return ['id' => $id, 'total' => $total, '_status' => 201];
    }

    // GET /commandes
    public function index(Request $request): array
    {
        $user = $this->authUser();
        return db('commandes')->where('user_id', $user['id'])
            ->orderBy('created_at', 'DESC')->paginate(20);
    }
}

5. Routes finales

5
Routes avec middlewares
app/routes.php
<?php
use App\Controllers\AuthController;
use App\Controllers\ProduitController;
use App\Controllers\CommandeController;
use App\Middleware\BearerAuth;
use Briko\Http\Middleware\OfflineFirst;
use Briko\Http\Middleware\LowBandwidth;

/** @var \Briko\Routing\Router $router */

// Auth
$router->post('/auth/register', [AuthController::class, 'register']);
$router->post('/auth/verify',   [AuthController::class, 'verify']);

// Produits (publics + offline-first + low bandwidth)
$router->get('/produits',      [ProduitController::class, 'index'], [OfflineFirst::class, LowBandwidth::class]);
$router->get('/produits/{id}', [ProduitController::class, 'show'],  [OfflineFirst::class]);

// Commandes (protégées)
$router->post('/commandes',    [CommandeController::class, 'store'], [BearerAuth::class]);
$router->get('/commandes',     [CommandeController::class, 'index'], [BearerAuth::class]);

6. Test complet

6
Scénario de bout en bout
Terminal
php briko feu

# 1. S'inscrire et recevoir l'OTP
curl -X POST http://localhost:8000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"nom":"Aya","telephone":"+225070000000"}'

# 2. Vérifier l'OTP (vérifie les logs si SMS_DRIVER=log)
php briko logs            # → voir le code OTP

curl -X POST http://localhost:8000/auth/verify \
  -H "Content-Type: application/json" \
  -d '{"telephone":"+225070000000","code":"482931"}'
# → {"token":"abc...","user":{...}}

# 3. Voir les produits
curl http://localhost:8000/produits

# 4. Passer une commande
curl -X POST http://localhost:8000/commandes \
  -H "Authorization: Bearer abc..." \
  -H "Content-Type: application/json" \
  -d '{"produit_id":1,"quantite":2}'
# → SMS + email de confirmation envoyés !

7. Pages HTML avec vues

7
Interface web : catalogue, panier, inscription OTP

On ajoute des routes HTML pour les pages publiques de la boutique. Les données sont chargées côté serveur et le paiement/commande appelle l'API JSON.

app/routes.php — routes HTML ajoutées
use App\Controllers\PageController;

// Pages HTML publiques
$router->get('/',                  [PageController::class, 'accueil']);
$router->get('/boutique',          [PageController::class, 'catalogue']);
$router->get('/boutique/{id}',     [PageController::class, 'produit']);
$router->get('/inscription',       [PageController::class, 'inscription']);
$router->get('/mon-compte',        [PageController::class, 'monCompte'],  [BearerAuth::class]);

// API JSON (existantes)
$router->post('/auth/register',    [AuthController::class, 'register']);
$router->post('/auth/verify',      [AuthController::class, 'verify']);
$router->get('/produits',          [ProduitController::class, 'index'],    [OfflineFirst::class]);
$router->post('/commandes',        [CommandeController::class, 'store'],   [BearerAuth::class]);
app/controllers/PageController.php
<?php
namespace App\Controllers;

use Briko\Http\Request;
use Briko\Http\Response;

class PageController
{
    public function accueil(Request $request): void
    {
        // Données chargées côté serveur, passées à la vue
        $produits = db('produits')->where('actif', 1)->limit(6)->get();

        Response::html(view('accueil', [
            'titre'    => 'Boutique',
            'produits' => $produits,
        ]));
    }

    public function catalogue(Request $request): void
    {
        $produits = db('produits')
            ->where('actif', 1)
            ->orderBy('nom')
            ->paginate(12);

        Response::html(view('boutique.catalogue', [
            'titre'    => 'Tous nos produits',
            'produits' => $produits['data'],
            'pages'    => $produits,
        ]));
    }

    public function produit(Request $request): void
    {
        $produit = db('produits')->find($request->param('id'));
        if (!$produit) {
            Response::html(view('erreurs.404', ['titre' => 'Produit introuvable']), 404);
            return;
        }

        Response::html(view('boutique.produit', [
            'titre'   => $produit['nom'],
            'produit' => $produit,
        ]));
    }

    public function inscription(Request $request): void
    {
        Response::html(view('auth.inscription', [
            'titre' => 'Créer mon compte',
        ]));
    }

    public function monCompte(Request $request): void
    {
        $user       = $_REQUEST['_auth_user'];
        $commandes  = db('commandes')
            ->where('user_id', $user['id'])
            ->orderBy('created_at', 'DESC')
            ->get();

        Response::html(view('compte.index', [
            'titre'     => 'Mon compte',
            'user'      => $user,
            'commandes' => $commandes,
        ]));
    }
}
app/views/auth/inscription.php — formulaire OTP en 2 étapes
<!DOCTYPE html>
<html lang="fr">
<head><meta charset="UTF-8"><title><?= htmlspecialchars($titre) ?></title></head>
<body>
  <h1><?= htmlspecialchars($titre) ?></h1>

  <!-- Étape 1 : saisie du numéro -->
  <form id="step1">
    <input type="text"  name="nom"       placeholder="Ton nom" required>
    <input type="tel"   name="telephone" placeholder="+225070000000" required>
    <input type="email" name="email"     placeholder="Email (optionnel)">
    <button type="submit">Recevoir mon code SMS</button>
    <p id="err1" style="color:red"></p>
  </form>

  <!-- Étape 2 : vérification OTP (cachée au départ) -->
  <form id="step2" style="display:none">
    <p>Code envoyé au <span id="tel-display"></span></p>
    <input type="text" name="code" placeholder="Code à 6 chiffres" maxlength="6" required>
    <button type="submit">Vérifier</button>
    <p id="err2" style="color:red"></p>
  </form>

  <script>
    let phoneNum = '';

    // Étape 1 : envoyer l'OTP via l'API
    document.getElementById('step1').addEventListener('submit', async e => {
      e.preventDefault();
      const data = Object.fromEntries(new FormData(e.target));
      phoneNum   = data.telephone;

      const res  = await fetch('/auth/register', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data),
      });
      const json = await res.json();

      if (res.ok) {
        document.getElementById('step1').style.display = 'none';
        document.getElementById('step2').style.display = 'block';
        document.getElementById('tel-display').textContent = phoneNum;
      } else {
        document.getElementById('err1').textContent = json.error;
      }
    });

    // Étape 2 : vérifier l'OTP
    document.getElementById('step2').addEventListener('submit', async e => {
      e.preventDefault();
      const code = e.target.code.value;

      const res  = await fetch('/auth/verify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ telephone: phoneNum, code }),
      });
      const json = await res.json();

      if (json.token) {
        localStorage.setItem('token', json.token);
        location.href = '/mon-compte';   // → vue HTML mon-compte
      } else {
        document.getElementById('err2').textContent = json.error;
      }
    });
  </script>
</body>
</html>
app/views/boutique/produit.php — page produit avec bouton commander
<!DOCTYPE html>
<html lang="fr">
<head><meta charset="UTF-8"><title><?= htmlspecialchars($titre) ?></title></head>
<body>
  <a href="/boutique">← Retour</a>

  <h1><?= htmlspecialchars($produit['nom']) ?></h1>
  <p>Prix : <strong><?= number_format($produit['prix']) ?> FCFA</strong></p>
  <p>Stock : <?= $produit['stock'] ?> disponible(s)</p>

  <form id="form-commande">
    <input type="hidden" name="produit_id" value="<?= $produit['id'] ?>">
    <label>Quantité :
      <input type="number" name="quantite" value="1" min="1" max="<?= $produit['stock'] ?>">
    </label>
    <button type="submit">Commander</button>
    <p id="msg"></p>
  </form>

  <script>
    document.getElementById('form-commande').addEventListener('submit', async e => {
      e.preventDefault();
      const token = localStorage.getItem('token');
      if (!token) { location.href = '/inscription'; return; }

      const data = Object.fromEntries(new FormData(e.target));
      const res  = await fetch('/commandes', {
        method: 'POST',
        headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
        body: JSON.stringify({ produit_id: +data.produit_id, quantite: +data.quantite }),
      });
      const json = await res.json();

      document.getElementById('msg').textContent = res.ok
        ? '✅ Commande #' + json.id + ' confirmée ! Total : ' + json.total + ' FCFA'
        : '❌ ' + json.error;
    });
  </script>
</body>
</html>
🏆 Projet complet ! Pages HTML chargées côté serveur avec view() + API JSON pour les actions (commande, auth OTP). Architecture hybride propre — aucun framework JS requis.