Fennec
Un micro-framework PHP conçu from scratch : routing, injection de dépendances, sécurité et cycle de requête/réponse, sans dépendance externe.
01 Présentation
Fennec est un micro-framework PHP maison, conçu comme vitrine technique plutôt que comme outil de production généraliste. L'objectif n'est pas de rivaliser avec Symfony ou Laravel, mais de démontrer une compréhension approfondie des mécanismes internes d'un framework moderne : routing par attributs, conteneur d'injection de dépendances avec autowiring, pipeline de middlewares, authentification JWT maison, et gestion centralisée des erreurs.
Philosophie
- Zéro dépendance — aucune librairie runtime, pas de Composer (ni pour l'autoload, ni pour quoi que ce soit d'autre).
- API-first / JSON only — pas de rendu HTML côté serveur, une seule forme de réponse (
JsonResponse). - Séparation stricte framework / application —
fennec/src(namespaceFennec\) ne référence jamaisApp\. Le dossierfennec/est pensé pour être extrait tel quel et réutilisé dans un autre projet. - Erreurs explicites — chaque échec prévisible lève une exception typée et documentée (
@throws), jamais un retour silencieux ou une erreur PHP brute.
02 Installation / mise en route
Prérequis
- PHP 8.3+ avec les extensions
pdo_mysql,mbstring,openssl - Un serveur MySQL accessible (Phase 1 : MySQL uniquement)
- Aucun Composer requis
Structure des dossiers
Configuration initiale
Copier .env.local.dist vers .env.local et adapter les valeurs :
APP_ENV=dev
APP_DEBUG=true
APP_NAME="Fennec"
JWT_SECRET=changez-moi-avec-une-valeur-aleatoire-longue
JWT_TTL=3600
DB_HOST=localhost
DB_PORT=3306
DB_NAME=mon_projet
DB_USER=root
DB_PASSWORD=
Le câblage du framework (mapping Fennec\ → fennec/src,
App\ → src) se fait directement dans public/index.php
et bin/console, pas dans un fichier caché — ce sont les deux seuls points
d'entrée documentés du projet.
fennec/ comme base pour un nouveau projet :
lance php bin/console key:generate en tout premier. Un JWT_SECRET
ne doit jamais être partagé entre deux projets — un token valide sur l'un serait valide
sur l'autre.
03 Construire son premier endpoint
Le framework ne contient aucun code métier : tout ce qui suit se passe dans /src
(namespace App\), jamais dans fennec/src.
1. Créer le controller
Un controller est une classe PHP normale — pas d'interface à implémenter, pas d'héritage obligatoire.
<?php
declare(strict_types=1);
namespace App\Controller;
use Fennec\Http\JsonResponse;
use Fennec\Http\Request;
use Fennec\Routing\Route;
final class ProjectController
{
#[Route('/api/projects', methods: ['GET'])]
public function list(Request $request): JsonResponse
{
return JsonResponse::success(['projects' => []]);
}
#[Route('/api/projects/{slug}', methods: ['GET'])]
public function show(Request $request): JsonResponse
{
return JsonResponse::success(['slug' => $request->getRouteParameter('slug')]);
}
}
- Le nom de fichier correspond au nom de classe, et le chemin sous
src/Controller/reflète le namespace. Request $requestest injecté automatiquement — corps JSON, query params et paramètres de route dynamiques sont accessibles via ses accesseurs (détails en § Routing).- Toute autre dépendance (Service, Repository...) se déclare en paramètre de constructeur : le Container la résout par autowiring (détails en § Injection de dépendances).
- Retour obligatoire :
JsonResponse::success()ou::error()— jamais un tableau brut.
2. Déclarer l'accès dans le Firewall
Par défaut, une route absente de config/firewall.php est protégée
(ROLE_USER minimum, fail-closed). Pour rendre /api/projects public :
// config/firewall.php
return [
['pattern' => '/api/projects', 'public' => true, 'role' => null],
// ...
];
Voir § Sécurité pour la syntaxe complète des patterns et la hiérarchie des rôles.
3. Tester en local
php -S localhost:8000 -t public
curl http://localhost:8000/api/projects
Aucune étape supplémentaire : le Router découvre automatiquement le controller par scan (en
développement) ou via le cache compilé une fois php bin/console route:cache
exécuté (voir § Console).
fennec/ : la découverte du controller, le
firewall et le Container fonctionnent uniquement à partir de ce que tu écris dans
/src et config/.
04 Configuration
Le module Fennec\Config parse le fichier .env.local et expose les
valeurs via un objet Config immuable.
Format du fichier
- Paires
CLE=valeur, une par ligne - Commentaires pleine ligne avec
# - Valeurs entre guillemets (
"..."ou'...') : jamais castées, toujours string - Valeurs non citées : cast automatique —
true/false→ bool, numérique → int ou float, sinon string
Utilisation
use Fennec\Config\Config;
$config = Config::fromFile(__DIR__ . '/.env.local');
$config->get('APP_DEBUG'); // bool(true)
$config->get('APP_NAME'); // string("Fennec")
$config->get('MISSING_KEY', 'x'); // "x" (valeur par défaut)
$config->has('APP_ENV'); // bool(true)
CLE=valeur lève une
ConfigParseException explicite plutôt que d'être ignorée silencieusement.
05 Routing
Les routes se déclarent via l'attribut PHP 8 #[Route] sur les méthodes de controller :
use Fennec\Http\JsonResponse;
use Fennec\Http\Request;
use Fennec\Routing\Route;
final class ProjectController
{
#[Route('/api/projects', methods: ['GET'])]
public function list(Request $request): JsonResponse
{
return JsonResponse::success(['projects' => []]);
}
#[Route('/api/projects/{slug}', methods: ['GET'])]
public function show(Request $request): JsonResponse
{
return JsonResponse::success(['slug' => $request->getRouteParameter('slug')]);
}
}
Les segments dynamiques ({slug}) sont extraits et accessibles via
Request::getRouteParameter(). Une route sans correspondance lève une
RouteNotFoundException (404) ; une méthode HTTP non acceptée lève une
MethodNotAllowedException (405, avec la liste des méthodes autorisées).
Cache de routes
Le scan Reflection des controllers a un coût — évitable en production via un cache compilé :
php bin/console route:cache # génère var/cache/routes.php
php bin/console route:list # liste les routes enregistrées, pour le debug
06 Injection de dépendances
Fennec\Container\Container résout automatiquement les dépendances de
constructeur par Reflection (autowiring), pour les types classe uniquement.
use Fennec\Container\Container;
$container = new Container();
// Binding explicite interface -> implémentation, instance fraîche à chaque résolution
$container->bind(LoggerInterface::class, FileLogger::class);
// Singleton : même instance réutilisée après la première résolution
$container->singleton(LoggerInterface::class, FileLogger::class);
// Enregistrer une instance déjà construite (ex. Config, construit avant le Container)
$container->instance(Config::class, $config);
$service = $container->get(MyService::class); // autowiring récursif
Config (typé, donc autowirable) et lit la valeur elle-même — le Container ne
devine jamais un scalaire brut.
Une dépendance circulaire (A → B → A) lève une CircularDependencyException
explicite, jamais une boucle infinie. Un paramètre non résolvable (scalaire sans valeur par
défaut, type union...) lève une UnresolvableParameterException.
07 Sécurité
Firewall
Le fichier config/firewall.php mappe des patterns d'URL à des règles d'accès :
return [
['pattern' => '/api/admin/*', 'public' => false, 'role' => 'ROLE_ADMIN'],
['pattern' => '/api/projects', 'public' => true, 'role' => null],
];
Un pattern se terminant par * matche par préfixe, sinon c'est une correspondance
exacte. Si aucune règle ne correspond, le chemin est traité comme protégé,
ROLE_USER minimum (fail-closed par défaut).
JWT maison (HS256)
Génération et vérification de tokens implémentées from scratch (hash_hmac,
encodage Base64URL, comparaison de signature via hash_equals) :
use Fennec\Security\Jwt\Jwt;
use Fennec\Clock\SystemClock;
$jwt = new Jwt($secret, ttlInSeconds: 3600, clock: new SystemClock());
$token = $jwt->encode(['sub' => 'user-1', 'role' => 'ROLE_ADMIN']);
// "iat" et "exp" ajoutés automatiquement à partir du TTL
$claims = $jwt->decode($token); // InvalidTokenException ou ExpiredTokenException si invalide
Rôles (RBAC)
Hiérarchie fermée à 4 niveaux, portée par l'enum Fennec\Security\Role :
Role::ADMIN->satisfies(Role::USER) renvoie true : un admin
satisfait automatiquement une exigence de rôle inférieur. AuthenticationMiddleware
vérifie le token Bearer (401 si invalide/expiré) et attache le rôle à la requête ;
RoleMiddleware vérifie ensuite que ce rôle satisfait le minimum requis
(401 si aucune authentification, 403 si rôle insuffisant).
08 Database
Fennec\Database\Database est un wrapper PDO minimal : connexion paresseuse
(établie à la première requête, pas au constructeur), requêtes systématiquement préparées,
exceptions PDO traduites en exceptions applicatives. Pas de query builder, pas d'ORM — le
pattern Repository reste une convention côté App\, pas un composant du
framework.
use Fennec\Database\Database;
$db = new Database(
dsn: 'mysql:host=localhost;port=3306;dbname=mon_projet;charset=utf8mb4',
username: 'root',
password: '',
);
// SELECT multiple lignes
$projects = $db->fetchAll('SELECT * FROM projects WHERE status = :status', [
'status' => 'published',
]);
// SELECT une ligne (ou null si aucun résultat)
$project = $db->fetchOne('SELECT * FROM projects WHERE id = :id', ['id' => $id]);
// INSERT / UPDATE / DELETE — retourne le nombre de lignes affectées
$affected = $db->execute('UPDATE projects SET status = :status WHERE id = :id', [
'status' => 'archived',
'id' => $id,
]);
$newId = $db->lastInsertId();
fetchAll(),
fetchOne() et execute() passent toujours par
PDO::prepare() + execute($params) — jamais de concaténation de
valeurs dans le SQL, y compris côté App\.
Une erreur de connexion lève une ConnectionException, une erreur d'exécution
(SQL invalide, contrainte violée...) lève une QueryException avec la requête en
cause. Les deux étendent Fennec\Exception\FennecException sans correspondre à
l'une des quatre exceptions HTTP dédiées (voir § Gestion des erreurs)
— elles remontent donc en 500, détail visible uniquement en mode debug.
09 Middlewares
Un middleware implémente MiddlewareInterface et peut court-circuiter la chaîne :
use Fennec\Http\JsonResponse;
use Fennec\Http\Request;
use Fennec\Middleware\MiddlewareInterface;
final class CorsMiddleware implements MiddlewareInterface
{
public function process(Request $request, \Closure $next): JsonResponse
{
$response = $next($request);
// ajouter des en-têtes CORS à $response ici, avant de la retourner
return $response;
}
}
MiddlewarePipeline exécute une liste ordonnée de middlewares autour d'un handler
final (typiquement l'appel au controller), sans logique mutable — chaque middleware enveloppe
le suivant via une closure $next.
10 Logger
Fennec\Logger\FileLogger écrit une ligne JSON par événement, un fichier par jour
(var/logs/app-{Y-m-d}.log) :
use Fennec\Logger\FileLogger;
use Fennec\Clock\SystemClock;
$logger = new FileLogger(__DIR__ . '/var/logs', new SystemClock());
$logger->info('User logged in', ['userId' => 42]);
$logger->error('Payment failed', ['orderId' => 'ORD-1']);
| Niveau | Usage |
|---|---|
debug | Détails techniques, dev uniquement |
info | Événements normaux (connexion, création...) |
warning | Anomalie non bloquante |
error | Échec d'une opération |
critical | Panne majeure (DB injoignable...) |
Aucune détection de changement de jour n'est nécessaire : le nom de fichier est recalculé à
chaque écriture à partir de l'horloge injectée. Pas de purge automatique en Phase 1 —
php bin/console log:clean --days=30 laisse la main à l'utilisateur.
11 Console
Point d'entrée unique bin/console. Chaque commande implémente CommandInterface :
interface CommandInterface
{
public function getName(): string;
public function getDescription(): string;
public function execute(array $arguments): int;
}
Commandes natives
| Commande | Rôle |
|---|---|
route:cache | Génère le cache de routes compilé |
route:list | Liste les routes enregistrées |
log:clean --days=30 | Supprime les logs plus anciens que N jours |
make:command | Génère le squelette d'une commande applicative |
key:generate | Régénère JWT_SECRET dans .env.local |
Les commandes sont découvertes par scan de répertoire (fennec/src/Console/Command
et src/Console/Command) et résolues via le Container — une commande applicative
peut donc recevoir ses propres dépendances par autowiring, exactement comme un controller.
App\ déclare ses commandes sans jamais modifier le noyau.
12 Gestion des erreurs
Aucune erreur PHP brute n'est jamais renvoyée au client : Fennec\Kernel\Kernel
capture tout \Throwable et le traduit via ExceptionHandler en une
enveloppe JSON stable :
// Succès
{ "success": true, "data": { ... }, "meta": null }
// Erreur
{
"success": false,
"data": null,
"error": { "code": "VALIDATION_ERROR", "message": "...", "details": { "title": "Ce champ est requis." } },
"meta": null
}
| Exception | Code HTTP |
|---|---|
NotFoundException | 404 |
UnauthorizedException | 401 |
ForbiddenException | 403 |
ValidationException | 422 |
MethodNotAllowedException | 405 |
| Toute autre exception | 500 (détail en mode debug uniquement) |
Toutes les exceptions du framework héritent de Fennec\Exception\FennecException.
Personnaliser le mapping pour une exception métier applicative : étendre l'une des quatre
classes ci-dessus (ex. class OutOfStockException extends ForbiddenException {})
— elle sera reconnue automatiquement par le handler, sans y toucher.