Fennec, le renard mascotte, posé sur le logo PHP

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.

PHP 8.3+ Zéro dépendance API-first JSON MySQL / PDO

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 / applicationfennec/src (namespace Fennec\) ne référence jamais App\. Le dossier fennec/ 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.
Périmètre Phase 1 (ce document) : le noyau du framework. Le projet applicatif qui l'utilisera (« Gestionnaire de projets portfolio ») fait l'objet d'une Phase 2 séparée.

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

racine-projet/ ├── fennec/ │ ├── src/ ← Code source du framework (namespace Fennec\) │ └── documentation/ ← Ce mini-site ├── src/ ← Code applicatif (Phase 2 — Entity, Controller, Service, Repository) ├── config/ ← firewall.php, services.php... ├── public/ │ └── index.php ← Point d'entrée HTTP unique ├── bin/ │ └── console ← Point d'entrée CLI ├── var/ │ ├── cache/ ← Cache de routes compilé │ └── logs/ ← Journaux applicatifs (app-{Y-m-d}.log) ├── .env.local ← Configuration locale (jamais versionnée) └── .env.local.dist ← Exemple versionné

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.

En dupliquant 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.

src/Controller/ProjectController.php
<?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 $request est 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).

Rien à modifier dans 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)
Une ligne qui ne respecte pas le format 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
Paramètres scalaires (string, int...) non typés-classe ne sont jamais résolus par magie. Une classe qui a besoin d'une valeur de configuration reçoit l'objet 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_VISITEUR < ROLE_USER < ROLE_ADMIN < ROLE_SUPERADMIN

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 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.

09 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']);
NiveauUsage
debugDétails techniques, dev uniquement
infoÉvénements normaux (connexion, création...)
warningAnomalie non bloquante
errorÉchec d'une opération
criticalPanne 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.

10 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

CommandeRôle
route:cacheGénère le cache de routes compilé
route:listListe les routes enregistrées
log:clean --days=30Supprime les logs plus anciens que N jours
make:commandGénère le squelette d'une commande applicative
key:generateRé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.

11 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
}
ExceptionCode HTTP
NotFoundException404
UnauthorizedException401
ForbiddenException403
ValidationException422
MethodNotAllowedException405
Toute autre exception500 (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.