astral-php / astral
Astral MVC — Framework PHP 8.x minimaliste, moderne et orienté objet
Requires
- php: ^8.0
- phpmailer/phpmailer: ^7.0
- vlucas/phpdotenv: ^5.6
Requires (Dev)
- phpunit/phpunit: ^9.6
README
Micro-framework MVC orienté objet, compatible PHP 8.0 → 8.4.
Dépendances de production : vlucas/phpdotenv, phpmailer/phpmailer.
Minimaliste par design, puissant par convention.
Écosystème Composer (optionnel)
Des packages officiels complètent le cœur du framework sans l’alourdir. Ils s’installent uniquement si tu en as besoin.
| Package | Rôle | Dépôt |
|---|---|---|
astral-php/astral-form |
Formulaires (FormBuilder, erreurs Validator, variable $form dans les vues) |
astral-php/astral-form |
astral-php/astral-vite |
Assets Vite + Tailwind, $vite->tags() dans le layout |
astral-php/astral-vite |
Organisation GitHub : github.com/astral-php.
Structure
astral-mvc/
├── app/
│ ├── Controllers/ # Contrôleurs applicatifs (AuthController, UserController…)
│ ├── Dao/ # Data Access Objects métier
│ ├── Events/ # Événements applicatifs (UserRegistered, UserLoggedIn…)
│ ├── Listeners/ # Listeners d'événements (SendWelcomeEmail, LogUserActivity…)
│ ├── Models/ # Entités / modèles de données (User…)
│ └── Providers/
│ └── AppServiceProvider.php # ← DAOs + contrôleurs + listeners applicatifs
├── config/
│ ├── app.php # Config générale (env, debug, timezone…)
│ ├── database.php # Basculer SQLite ↔ MySQL ici
│ ├── dependencies.php # ← Liste des Service Providers
│ └── routes.php # ← Déclaration de toutes les routes
├── database/
│ └── migrations/ # Fichiers de migration (YYYY_MM_DD_HHMMSS_nom.php)
├── public/
│ ├── .htaccess # Réécriture Apache
│ └── index.php # Bootstrap minimal (ne pas modifier)
├── src/
│ ├── Core/
│ │ ├── Application.php # Chef d'orchestre du démarrage
│ │ ├── Cache.php # Cache fichier (TTL, remember, flush)
│ │ ├── ServiceProviderInterface.php # Contrat des providers
│ │ ├── Providers/
│ │ │ ├── FrameworkServiceProvider.php # Session, Logger, Cache, View…
│ │ │ └── DatabaseServiceProvider.php # PDO
│ │ ├── Container.php # Conteneur DI (autowiring + singletons)
│ │ ├── CsrfGuard.php # Protection CSRF (token de session)
│ │ ├── Logger.php # Logger fichier journalier
│ │ ├── Mailer/
│ │ │ └── Mailer.php # Envoi e-mail (SMTP via PHPMailer)
│ │ ├── Request.php # Requête HTTP (JSON, verb spoofing)
│ │ ├── Router.php # Routeur (routes + middleware + groupes)
│ │ ├── Session.php # Session + messages flash
│ │ ├── Validator.php # Validation des données
│ │ ├── View.php # Moteur de rendu → retourne string
│ │ ├── Console/
│ │ │ ├── CommandInterface.php
│ │ │ ├── Console.php # Dispatcher + helpers ANSI
│ │ │ └── Commands/
│ │ │ ├── ClearCacheCommand.php
│ │ │ ├── MigrateCommand.php # migrate
│ │ │ ├── MigrateRollbackCommand.php # migrate:rollback
│ │ │ ├── MigrateStatusCommand.php # migrate:status
│ │ │ └── MakeMigrationCommand.php # make:migration
│ │ ├── Auth/
│ │ │ ├── Auth.php # Service auth (login, logout, check, is, can)
│ │ │ ├── Role.php # Constantes ADMIN | USER | GUEST
│ │ │ └── Middleware/
│ │ │ ├── AuthMiddleware.php # Connecté ou → /login
│ │ │ ├── AdminMiddleware.php # Admin ou → 403
│ │ │ └── GuestMiddleware.php # Invité ou → /
│ │ ├── Exception/
│ │ │ ├── AuthorizationException.php # 403
│ │ │ ├── CsrfException.php
│ │ │ ├── NotFoundException.php
│ │ │ └── ValidationException.php
│ │ ├── Http/
│ │ │ ├── Response.php # Réponse HTML (testable)
│ │ │ ├── JsonResponse.php # Réponse JSON
│ │ │ └── RedirectResponse.php
│ │ └── Middleware/
│ │ ├── CsrfMiddleware.php
│ │ └── MiddlewareInterface.php
│ ├── Controller/
│ │ └── AbstractController.php # render/redirect/json → Response
│ └── Database/
│ ├── AbstractDao.php # CRUD générique PDO + pagination
│ ├── Connection.php # Singleton PDO (SQLite / MySQL)
│ └── Migration/
│ ├── Migration.php # Classe abstraite de base (up/down)
│ └── Migrator.php # Moteur : run, rollback, status
├── bin/
│ └── console # Point d'entrée CLI
├── storage/
│ ├── cache/ # Cache fichier (auto-créé)
│ └── logs/ # Logs journaliers (auto-créé)
├── .env # Variables d'environnement (non versionné)
├── .env.example # Template à copier
├── tests/ # Tests PHPUnit
├── views/
│ ├── partials/ # Partials réutilisables (flash, field-error, pagination…)
│ ├── auth/ # login.php, register.php, forgot-password…
│ ├── docs/ # Documentation en ligne
│ ├── errors/ # Pages d'erreur (403, 404, 500)
│ ├── home/
│ ├── layout/ # Layout principal (Tailwind CDN)
│ ├── profile/
│ └── user/
├── composer.json
└── phpunit.xml
Installation
composer install
cp .env.example .env # puis adaptez les valeurs
Démarrage rapide (Laragon / Apache)
- Copier le projet dans
laragon/www/mvc/ - Accéder à
http://mvc.testouhttp://localhost/mvc - La base SQLite et le dossier
storage/logs/sont créés automatiquement
Démarrer un projet from scratch
Astral MVC est livré avec un module d'exemple complet (Article / Category) qui démontre les ORM léger (hasMany, belongsTo), l'API REST JSON et les migrations.
Si vous souhaitez repartir d'une base vierge pour construire votre propre application, supprimez les fichiers suivants :
1. Modèles et DAOs d'exemple
rm app/Models/Article.php rm app/Models/Category.php rm app/Dao/ArticleDao.php rm app/Dao/CategoryDao.php
2. Contrôleurs API d'exemple
rm app/Controllers/Api/ArticleApiController.php
rm app/Controllers/Api/CategoryApiController.php
rmdir app/Controllers/Api # si le dossier est vide
3. Migrations d'exemple
rm database/migrations/2026_03_11_000002_create_categories_table.php rm database/migrations/2026_03_11_000003_create_articles_table.php
4. Nettoyer AppServiceProvider
Dans app/Providers/AppServiceProvider.php, retirez :
// Imports à supprimer use App\Controllers\Api\ArticleApiController; use App\Controllers\Api\CategoryApiController; use App\Dao\ArticleDao; use App\Dao\CategoryDao; // Bindings à supprimer (section DAOs) $container->singleton(ArticleDao::class, ...); $container->singleton(CategoryDao::class, ...); // Bindings à supprimer (section Contrôleurs API) $container->bind(ArticleApiController::class, ...); $container->bind(CategoryApiController::class, ...);
5. Nettoyer config/routes.php
Retirez le groupe /api/v1 et ses imports :
// Imports à supprimer use App\Controllers\Api\ArticleApiController; use App\Controllers\Api\CategoryApiController; // Groupe de routes à supprimer $router->group('/api/v1', function (Router $r): void { $r->get('/articles', ...); // ... }, [CorsMiddleware::class, BearerTokenMiddleware::class]);
Note : Les middlewares
CorsMiddlewareetBearerTokenMiddleware, ainsi que la classeApiResponseetAbstractApiController, font partie du framework (src/Core/) — vous pouvez les réutiliser pour vos propres modules API sans les supprimer.
Variables d'environnement (.env)
Copiez .env.example en .env et adaptez les valeurs. Le fichier .env
ne doit jamais être versionné (déjà dans .gitignore).
APP_NAME="ASTRAL-MVC" APP_ENV=development # development | production APP_DEBUG=true APP_TIMEZONE=Europe/Paris DB_DRIVER=sqlite DB_DATABASE=database/app.sqlite # MySQL # DB_DRIVER=mysql # DB_HOST=127.0.0.1 # DB_PORT=3306 # DB_DATABASE=mvc_db # DB_USERNAME=root # DB_PASSWORD=
Les fichiers config/app.php et config/database.php lisent $_ENV avec
des valeurs de fallback. En production, les variables peuvent aussi être
injectées directement par le serveur (.env non requis).
Architecture du démarrage
public/index.php
└── Application::run()
├── loadDotEnv() — charge .env via vlucas/phpdotenv
├── Logger — disponible dès le début
├── bootEnvironment() — timezone, affichage d'erreurs
├── ensureDatabase() — crée le dossier SQLite si absent
├── loadDependencies() ← config/dependencies.php
├── Session::start() — avant tout rendu
├── View::share() — $session et $csrf dans toutes les vues
├── loadRoutes() ← config/routes.php
└── dispatch() — pipeline middleware → contrôleur
→ Response::send()
| Fichier à modifier | Quand |
|---|---|
app/Providers/AppServiceProvider.php |
Ajouter un DAO ou un contrôleur |
config/dependencies.php |
Ajouter / retirer un Service Provider |
config/routes.php |
Ajouter / modifier / supprimer une route |
config/app.php |
Changer l'environnement, le debug, la timezone |
config/database.php |
Changer de driver ou de base de données |
public/index.php,src/Core/Application.phpet les providerssrc/Core/Providers/ne sont jamais modifiés.
Fonctionnalités
Session & Messages flash
// Injecter Session dans un contrôleur public function __construct(View $view, Session $session) { … } // Enregistrer un message flash (avant redirect) $this->session->flash('success', 'Utilisateur créé avec succès.'); $this->redirect('/users'); // Dans la vue suivante (lu une seule fois) <?php if ($session->hasFlash('success')): ?> <p><?= htmlspecialchars($session->getFlash('success')) ?></p> <?php endif; ?>
Validation
// Dans un contrôleur (méthode héritée de AbstractController) $v = $this->validate($request->body, [ 'name' => 'required|min:2|max:100', 'email' => 'required|email', 'age' => 'integer|min:0|max:120', ]); if ($v->fails()) { // Réafficher le formulaire avec les erreurs $this->render('users/create', ['errors' => $v->errors()]); return; }
Règles disponibles :
| Règle | Description |
|---|---|
required |
Champ non vide |
min:N |
Longueur ≥ N (chaîne) ou valeur ≥ N (nombre) |
max:N |
Longueur ≤ N (chaîne) ou valeur ≤ N (nombre) |
email |
Adresse e-mail valide |
integer |
Entier |
numeric |
Valeur numérique |
alpha |
Lettres uniquement |
url |
URL valide |
confirmed |
Doit correspondre au champ {field}_confirmation |
in:a,b,c |
Valeur parmi la liste |
Protection CSRF
Le token est partagé automatiquement dans toutes les vues via $csrf.
// Dans chaque formulaire POST/PUT/DELETE <form method="POST" action="/users"> <?= $csrf->field() ?> <!-- génère <input type="hidden" name="_token" value="…"> --> … </form>
La vérification se fait via CsrfMiddleware à appliquer aux routes concernées :
// config/routes.php $router->post('/users', UserController::class, 'store') ->middleware(CsrfMiddleware::class); // ou sur un groupe entier use Core\Middleware\CsrfMiddleware; $router->group('', function (Router $r) { $r->post('/users', UserController::class, 'store'); $r->post('/users/:id/delete', UserController::class, 'destroy'); }, [CsrfMiddleware::class]);
Partials (vues réutilisables)
Le moteur de vues expose renderPartial() et un alias court partial() pour inclure des sous-vues (sans layout). La variable $viewEngine est partagée dans toutes les vues et le layout (nom dédié pour éviter tout conflit avec une donnée view), ce qui permet d’inclure des partials depuis n’importe quel template.
Convention : placer les partials dans views/partials/ (ex. partials/flash.php, partials/field-error.php).
Depuis une vue ou le layout :
<?= $viewEngine->partial('partials/flash') ?> <?= $viewEngine->partial('partials/field-error', ['field' => 'email', 'errors' => $errors ?? []]) ?> <?= $viewEngine->partial('partials/pagination', ['current' => $current, 'pages' => $pages, 'baseUrl' => '/users']) ?>
Partials fournis (Tailwind CSS) :
| Partial | Rôle | Variables |
|---|---|---|
partials/flash |
Messages flash success/error | $session (partagée) |
partials/validation-errors |
Liste globale d’erreurs de validation | $errors (optionnel) |
partials/field-error |
Message d’erreur sous un champ | $field, $errors |
partials/pagination |
Liens Précédent / numéros / Suivant | $current, $pages, $baseUrl, $mode (optionnel) |
Pagination — 3 modes ($mode optionnel, défaut numbers) : simple (Précédent / Suivant), numbers (toutes les pages), elastic (fenêtre glissante 1 … 5 6 7 … 42 pour gros volumes).
<?= $viewEngine->partial('partials/pagination', ['current' => $current, 'pages' => $pages, 'baseUrl' => '/users', 'mode' => 'elastic']) ?>
En contrôleur, $this->view->renderPartial('nom/vue', $data) retourne le HTML sans layout (utile pour des réponses AJAX ou fragments). Dans les vues, utilisez toujours $viewEngine->partial(...) pour inclure un partial.
Middleware
Créer un middleware dans app/Middleware/ :
namespace App\Middleware; use Core\Middleware\MiddlewareInterface; use Core\Request; use Core\Session; final class AuthMiddleware implements MiddlewareInterface { public function __construct(private Session $session) {} public function handle(Request $request, callable $next): void { if (!$this->session->has('user_id')) { header('Location: /login'); exit; } $next(); } }
Enregistrer dans config/dependencies.php :
$container->bind(AuthMiddleware::class, fn(Container $c) => new AuthMiddleware( $c->make(Session::class), ));
Appliquer dans config/routes.php :
$router->group('/admin', function (Router $r) { $r->get('/dashboard', AdminController::class, 'index'); $r->get('/users', AdminController::class, 'users'); }, [AuthMiddleware::class, CsrfMiddleware::class]);
Routes — verbes HTTP complets & groupes
// config/routes.php — tous les verbes HTTP $router->get('/users', UserController::class, 'index'); $router->post('/users', UserController::class, 'store'); $router->put('/users/:id', UserController::class, 'update'); $router->patch('/users/:id', UserController::class, 'patch'); $router->delete('/users/:id', UserController::class, 'destroy'); // Spoofing HTML (forms ne supportent que GET/POST) <form method="POST" action="/users/42"> <input type="hidden" name="_method" value="DELETE"> <?= $csrf->field() ?> </form> // Groupes avec préfixe $router->group('/api/v1', function (Router $r) { $r->get('/users', ApiUserController::class, 'index'); $r->post('/users', ApiUserController::class, 'store'); });
Logger
Les erreurs 500 sont loguées automatiquement dans storage/logs/YYYY-MM-DD.log.
// Injecter Logger dans un service ou contrôleur public function __construct(private Logger $logger) {} $this->logger->info('Utilisateur créé', ['id' => $userId]); $this->logger->warning('Tentative suspecte', ['ip' => $_SERVER['REMOTE_ADDR']]); $this->logger->error('Connexion BDD échouée', ['driver' => 'mysql']);
Format d'une ligne de log :
[2026-03-09 14:32:01] ERROR: Connexion BDD échouée {"driver":"mysql"}
Pagination
// Dans un contrôleur $page = (int) $request->query('page', 1); $result = $this->userDao->paginate(page: $page, perPage: 15); // $result contient : // [ // 'data' => [...], // enregistrements de la page // 'total' => 150, // nombre total d'enregistrements // 'pages' => 10, // nombre total de pages // 'current' => 2, // page courante // 'per_page' => 15, // ] $this->render('users/index', $result);
// Dans la vue — navigation simple <?php for ($i = 1; $i <= $pages; $i++): ?> <a href="/users?page=<?= $i ?>" <?= $i === $current ? 'class="active"' : '' ?>> <?= $i ?> </a> <?php endfor; ?>
Objet Response (actions testables)
Les méthodes render(), redirect() et json() retournent désormais
des objets Response. L'envoi HTTP n'est déclenché qu'à la fin par le Router,
ce qui permet de tester les contrôleurs sans sortie réelle.
// Tester une action sans HTTP $response = $controller->index(); assert($response->getStatus() === 200); assert(str_contains($response->getContent(), 'Liste des utilisateurs'));
// Dans un contrôleur — retourner la réponse (plus d'exit) public function store(): Response { // … return $this->render('user/create', ['errors' => $v->errors()]); // ou return $this->redirect('/users'); // ou return $this->json(['id' => $id], 201); }
Cache fichier
// Injecter Cache dans un contrôleur ou service public function __construct(private Cache $cache) {} // Mettre en cache 1 heure $users = $this->cache->remember('users.all', 3600, fn() => $this->userDao->findAll()); // Invalider après une modification $this->cache->forget('users.all'); // Tout vider $this->cache->flush();
Console CLI
# Lister les commandes disponibles php bin/console list # ── Scaffolding ───────────────────────────────────────── # Module complet en mode interactif (guidé) php bin/console make:module # Module complet en mode direct (Model + DAO + Controller + Migration) php bin/console make:module Article php bin/console make:module Article --api # contrôleur JSON php bin/console make:module Article --no-migrate # sans migration # Fichiers individuels php bin/console make:model Article php bin/console make:dao Article php bin/console make:controller Article # contrôleur vide php bin/console make:controller Article --resource # CRUD complet php bin/console make:controller Article --api # JSON REST php bin/console make:migration create_articles_table # ── Migrations ────────────────────────────────────────── php bin/console migrate php bin/console migrate:status php bin/console migrate:rollback # ── Cache ─────────────────────────────────────────────── php bin/console cache:clear
Ajouter une commande applicative :
// app/Console/SeedUsersCommand.php namespace App\Console; use Core\Console\CommandInterface; use Core\Console\Console; final class SeedUsersCommand implements CommandInterface { public function getName(): string { return 'db:seed'; } public function getDescription(): string { return 'Insère des données de test'; } public function execute(array $args, Console $console): int { // … insérer des données … $console->success('Base de données peuplée.'); return 0; } }
// bin/console — enregistrer la commande $console->register(new App\Console\SeedUsersCommand());
Service Providers
config/dependencies.php déclare un tableau de providers — plus de Closure plate.
Application instancie chaque provider et appelle register() dans l'ordre du tableau.
// config/dependencies.php return [ FrameworkServiceProvider::class, // Session, Logger, Cache, Request, View, CSRF DatabaseServiceProvider::class, // PDO AppServiceProvider::class, // DAOs, Contrôleurs ];
Ajouter un groupe de services (ex: envoi d'e-mails) :
// app/Providers/MailServiceProvider.php namespace App\Providers; use Core\Container; use Core\ServiceProviderInterface; final class MailServiceProvider implements ServiceProviderInterface { public function register(Container $container, array $appConfig, array $dbConfig): void { $container->singleton(Mailer::class, fn() => new Mailer( host: $_ENV['MAIL_HOST'] ?? 'localhost', port: (int) ($_ENV['MAIL_PORT'] ?? 587), )); } }
// config/dependencies.php — ajouter à la liste return [ FrameworkServiceProvider::class, DatabaseServiceProvider::class, AppServiceProvider::class, App\Providers\MailServiceProvider::class, // ← nouveau ];
Les providers du framework (
src/Core/Providers/) ne sont jamais modifiés. Vos ajouts se font exclusivement dansapp/Providers/.
JSON / API REST
// Corps JSON automatiquement décodé (Content-Type: application/json) $data = $request->body; // tableau associatif // Répondre en JSON $this->json(['id' => 1, 'name' => 'Alice'], 201); // Vérifier le type de requête $request->isJson(); // true si Content-Type: application/json $request->isXhr(); // true si X-Requested-With: XMLHttpRequest
Basculer vers MySQL
Éditer .env (recommandé) :
DB_DRIVER=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=mvc_db DB_USERNAME=root DB_PASSWORD= DB_CHARSET=utf8mb4
Relations entre entités — ORM léger
Philosophie Astral : pas de lazy-loading, pas de magie. Les relations sont des appels explicites dans les DAOs.
AbstractDao expose deux helpers protected que les DAOs concrets peuvent appeler :
hasMany() — relation 1→N
// Dans un DAO concret /** @return list<Article> */ protected function articlesOf(int $categoryId): array { return $this->hasMany( relatedClass: Article::class, table: 'articles', foreignKey: 'category_id', localId: $categoryId, orderBy: 'created_at', direction: 'DESC', ); }
belongsTo() — relation N→1
// Dans un DAO concret public function categoryOf(Article $article): ?Category { return $this->belongsTo( relatedClass: Category::class, table: 'categories', foreignId: $article->category_id, ); }
Chargement combiné
Les DAOs exposent des méthodes de "chargement avec relation" pour éviter de multiples appels :
// Charger une catégorie ET ses articles $result = $categoryDao->findWithArticles(categoryId: 3); // $result['category'] → Category // $result['articles'] → list<Article> // Charger un article ET sa catégorie $result = $articleDao->findWithCategory(articleId: 42); // $result['article'] → Article // $result['category'] → Category|null
Exemple complet : module Article
// app/Dao/ArticleDao.php — relation belongs-to final class ArticleDao extends AbstractDao { protected function getTable(): string { return 'articles'; } protected function getModelClass(): string { return Article::class; } /** Retourne les articles d'une catégorie */ public function findByCategory(int $categoryId): array { return $this->hasMany( relatedClass: Article::class, table: 'articles', foreignKey: 'category_id', localId: $categoryId, orderBy: 'created_at', direction: 'DESC', ); } /** Charge la catégorie parente d'un article */ public function categoryOf(Article $article): ?Category { return $this->belongsTo( relatedClass: Category::class, table: 'categories', foreignId: $article->category_id, ); } }
Règles et conventions
| Règle | Raison |
|---|---|
| Pas de lazy-loading | Rend les requêtes visibles et prévisibles |
Méthodes nommées <entité>Of($parent) |
Ex: articlesOf($categoryId), categoryOf($article) |
belongsTo retourne null si foreignId = 0 |
Sécurité si la clé étrangère est nulle |
findWith*() pour chargement combiné |
Pratique pour les contrôleurs |
Migrations fournies
php bin/console migrate
# Applique : create_categories_table, create_articles_table
Authentification & Rôles
Rôles disponibles
use Core\Auth\Role; Role::ADMIN // 'admin' Role::USER // 'user' Role::GUEST // 'guest' (non connecté)
Service Auth
Disponible dans tous les contrôleurs via le conteneur, et dans toutes les vues
via la variable $auth partagée automatiquement.
// Contrôleur $this->auth->login($user); // connecte et régénère la session $this->auth->logout(); // déconnecte $this->auth->check(); // bool : connecté ? $this->auth->guest(); // bool : non connecté ? $this->auth->is(Role::ADMIN); // bool : rôle exact $this->auth->can(Role::ADMIN, Role::USER); // bool : l'un de ces rôles $this->auth->id(); // int|null $this->auth->name(); // string $this->auth->email(); // string $this->auth->role(); // string (Role::GUEST si non connecté)
// Vue (variable $auth partagée globalement) <?php if ($auth->check()): ?> Bienvenue, <?= htmlspecialchars($auth->name()) ?> ! <?php endif ?> <?php if ($auth->is(\Core\Auth\Role::ADMIN)): ?> <a href="/admin">Administration</a> <?php endif ?>
Middleware Auth
Trois middleware intégrés, utilisables dans config/routes.php :
| Middleware | Comportement |
|---|---|
AuthMiddleware |
Redirige vers /login si non connecté |
AdminMiddleware |
Redirige vers /login si non connecté ; lève AuthorizationException (403) si non admin |
GuestMiddleware |
Redirige vers / si déjà connecté (pour /login, /register) |
use Core\Auth\Middleware\AdminMiddleware; use Core\Auth\Middleware\AuthMiddleware; use Core\Auth\Middleware\GuestMiddleware; // Route individuelle $router->get('/profile', UserController::class, 'profile') ->middleware(AuthMiddleware::class); // Groupe protégé par authentification $router->group('', function (Router $r): void { $r->get('/dashboard', DashboardController::class, 'index'); $r->get('/settings', SettingsController::class, 'index'); }, [AuthMiddleware::class]); // Zone admin $router->group('/admin', function (Router $r): void { $r->get('/dashboard', AdminController::class, 'index'); $r->get('/users', AdminController::class, 'users'); }, [AdminMiddleware::class]); // Routes invités seulement $router->get('/login', AuthController::class, 'loginForm')->middleware(GuestMiddleware::class); $router->post('/login', AuthController::class, 'login')->middleware(GuestMiddleware::class); $router->get('/register', AuthController::class, 'registerForm')->middleware(GuestMiddleware::class); $router->post('/register', AuthController::class, 'register')->middleware(GuestMiddleware::class);
Gestion des rôles dans UserDao
// Créer un admin $userDao->createUser('Alice', 'alice@example.com', 'secret', Role::ADMIN); // Promouvoir un utilisateur existant $userDao->promote(userId: 5, role: Role::ADMIN); // Lister tous les admins $admins = $userDao->findByRole(Role::ADMIN);
Interface d'administration des rôles
L'application embarque une interface dédiée accessible aux administrateurs :
| Méthode | URI | Action | Middleware |
|---|---|---|---|
| GET | /admin/users |
Tableau de bord des rôles | AdminMiddleware |
| POST | /admin/users/:id/role |
Changer le rôle d'un utilisateur | AdminMiddleware |
Contrôleur : App\Controllers\Admin\UserController
Règles de sécurité intégrées :
- Un admin ne peut pas modifier son propre rôle.
- Le dernier administrateur ne peut pas être rétrogradé (protection anti-lockout).
- Seuls les rôles définis dans
Role::all()sont acceptés.
Événement dispatché après chaque changement :
// app/Events/RoleChanged.php final class RoleChanged implements EventInterface { public function __construct( public User $user, public string $oldRole, public string $newRole, public int $changedBy, ) {} }
Listener fourni : App\Listeners\LogRoleChange — journalise l'action via Logger.
Modèle User — helpers de rôle
$user->isAdmin(); // bool $user->isUser(); // bool $user->hasRole(Role::ADMIN, Role::USER); // bool : l'un de ces rôles
Routes d'authentification (incluses par défaut)
| Méthode | URI | Action | Middleware |
|---|---|---|---|
| GET | /login |
Formulaire de connexion | GuestMiddleware |
| POST | /login |
Traitement de connexion | GuestMiddleware |
| POST | /logout |
Déconnexion | — |
| GET | /register |
Formulaire d'inscription | GuestMiddleware |
| POST | /register |
Traitement d'inscription | GuestMiddleware |
Routes disponibles (application de démonstration)
| Méthode | URI | Action | Middleware |
|---|---|---|---|
| GET | / |
Page d'accueil | — |
| GET | /login |
Formulaire de connexion | GuestMiddleware |
| POST | /login |
Traitement connexion | GuestMiddleware |
| POST | /logout |
Déconnexion | — |
| GET | /register |
Formulaire d'inscription | GuestMiddleware |
| POST | /register |
Inscription | GuestMiddleware |
| GET | /users |
Liste des utilisateurs | AuthMiddleware |
| GET | /users/create |
Formulaire de création | AuthMiddleware |
| GET | /users/:id |
Fiche d'un utilisateur | AuthMiddleware |
| POST | /users |
Créer un utilisateur | AuthMiddleware |
| POST | /users/:id/delete |
Supprimer un utilisateur | AuthMiddleware |
Tests
# Suite complète (178 tests) vendor/bin/phpunit # Par groupe vendor/bin/phpunit --testsuite Core vendor/bin/phpunit --testsuite Database # Fichier individuel vendor/bin/phpunit tests/Core/ValidatorTest.php
Couverture actuelle :
| Fichier | Ce qui est testé |
|---|---|
tests/Core/ValidatorTest.php |
10 règles de validation, combinaisons, errors()/first() |
tests/Core/ContainerTest.php |
bind, singleton, instance, has, make, autowiring |
tests/Core/RequestTest.php |
Méthode HTTP, verb spoofing, URI, input()/query()/header() |
tests/Core/SessionTest.php |
get/set/has/forget, flash messages, pullAllFlashes |
tests/Core/CacheTest.php |
get/set/has/forget/flush, remember(), expiration TTL |
tests/Core/Http/ResponseTest.php |
Response, JsonResponse, RedirectResponse |
tests/Core/EventDispatcherTest.php |
listen, dispatch, subscribe, hasListeners, ordre, exceptions |
tests/Core/RouterTest.php |
Route statique, route non trouvée |
tests/Database/AbstractDaoTest.php |
Insert, findAll, update, delete, authenticate |
tests/Database/MigratorTest.php |
run, rollback, status, batches, orphan detection |
Events & Listeners
Le système d'événements découple les effets de bord des contrôleurs.
EventDispatcher est synchrone et zéro dépendance.
Créer un événement
// app/Events/OrderPlaced.php final class OrderPlaced implements EventInterface { public function __construct( public Order $order, public User $customer, ) {} }
Créer un listener
// app/Listeners/SendOrderConfirmation.php final class SendOrderConfirmation implements ListenerInterface { public function __construct(private Mailer $mailer) {} public function handle(EventInterface $event): void { assert($event instanceof OrderPlaced); $this->mailer->send($event->customer->email, 'Commande reçue', '…'); } }
Enregistrer dans AppServiceProvider
$dispatcher->listen(OrderPlaced::class, SendOrderConfirmation::class);
Dispatcher depuis un contrôleur
$this->dispatcher->dispatch(new OrderPlaced($order, $user));
Subscriber (regroupement de listeners)
final class ShopSubscriber implements SubscriberInterface { public function subscribe(EventDispatcher $dispatcher): void { $dispatcher->listen(OrderPlaced::class, SendOrderConfirmation::class); $dispatcher->listen(OrderShipped::class, SendShippingNotification::class); } } // Dans AppServiceProvider : $dispatcher->subscribe(ShopSubscriber::class);
Événements fournis
| Événement | Déclenché par | Listeners actifs |
|---|---|---|
UserRegistered |
AuthController::register() |
SendWelcomeEmail, LogUserActivity |
UserLoggedIn |
AuthController::login() |
LogUserActivity |
Migrations de base de données
Le système de migrations versionne le schéma de base de données via des fichiers PHP
dans database/migrations/. Chaque migration est un batch identifié en base dans la
table migrations (créée automatiquement, SQLite et MySQL compatibles).
Convention de nommage
YYYY_MM_DD_HHMMSS_nom_en_snake_case.php → classe StudlyCase du suffixe
2026_03_11_100000_create_articles_table.php → CreateArticlesTable
Commandes
# 1. Générer un fichier de migration php bin/console make:migration create_articles_table # → database/migrations/2026_03_11_100000_create_articles_table.php # 2. Voir l'état de toutes les migrations php bin/console migrate:status # 3. Appliquer les migrations en attente (nouveau batch) php bin/console migrate # 4. Annuler le dernier batch php bin/console migrate:rollback
Anatomie d'un fichier de migration
<?php declare(strict_types=1); use Database\Migration\Migration; class CreateArticlesTable extends Migration { public function up(\PDO $pdo): void { $driver = $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME); if ($driver === 'sqlite') { $pdo->exec(<<<'SQL' CREATE TABLE IF NOT EXISTS articles ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, content TEXT NOT NULL, user_id INTEGER NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ) SQL); } else { $pdo->exec(<<<'SQL' CREATE TABLE IF NOT EXISTS `articles` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `title` VARCHAR(255) NOT NULL, `content` TEXT NOT NULL, `user_id` INT UNSIGNED NOT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 SQL); } } public function down(\PDO $pdo): void { $pdo->exec('DROP TABLE IF EXISTS articles'); } }
Résultat de migrate:status
+---------------------------------------------------+---------+-------+---------------------+
| Migration | Statut | Batch | Exécutée le |
+---------------------------------------------------+---------+-------+---------------------+
| 2026_03_11_000001_create_users_table.php | applied | 1 | 2026-03-11 10:00:00 |
| 2026_03_11_100000_create_articles_table.php | pending | - | - |
+---------------------------------------------------+---------+-------+---------------------+
| Statut | Signification |
|---|---|
applied |
Migration exécutée en base |
pending |
Fichier présent, pas encore appliqué |
orphan |
Enregistrement en base mais fichier supprimé |
API REST JSON
Astral embarque une couche API complète, activée via des routes /api/v1/* protégées par CORS et Bearer Token.
Format de réponse uniforme
// Succès (200) { "data": { "id": 1, "title": "Mon article" } } // Liste paginée (200) { "data": [...], "meta": { "total": 42, "page": 1, "per_page": 15, "pages": 3 } } // Créé (201) { "data": { "id": 5, "title": "Nouveau" } } // Supprimé (204) — corps vide // Erreur de validation (422) { "error": { "code": "VALIDATION_ERROR", "message": "...", "details": { "title": ["..."] } } } // Introuvable (404) { "error": { "code": "NOT_FOUND", "message": "Article #99 introuvable." } } // Non authentifié (401) { "error": { "code": "UNAUTHORIZED", "message": "Token manquant ou invalide." } }
Configuration
Dans .env :
API_KEY=change-me-generate-a-secure-key # Générer : php -r "echo bin2hex(random_bytes(32));"
Appels depuis un client
# Lister les articles (paginé) curl -H "Authorization: Bearer <api_key>" \ http://localhost/api/v1/articles?page=1 # Détail d'un article avec sa catégorie curl -H "Authorization: Bearer <api_key>" \ http://localhost/api/v1/articles/1 # Créer un article curl -X POST \ -H "Authorization: Bearer <api_key>" \ -H "Content-Type: application/json" \ -d '{"title":"Mon article","slug":"mon-article","body":"...","category_id":1}' \ http://localhost/api/v1/articles # Catégories avec articles publiés curl -H "Authorization: Bearer <api_key>" \ "http://localhost/api/v1/categories/1?with_articles=1&status=published"
Routes disponibles
| Méthode | URI | Action |
|---|---|---|
| GET | /api/v1/articles |
Liste paginée (?page=N&status=published) |
| GET | /api/v1/articles/:id |
Détail + catégorie liée |
| POST | /api/v1/articles |
Créer (JSON body) |
| PUT | /api/v1/articles/:id |
Modifier (JSON body partiel) |
| DELETE | /api/v1/articles/:id |
Supprimer → 204 |
| GET | /api/v1/categories |
Liste toutes les catégories |
| GET | /api/v1/categories/:id |
Détail (?with_articles=1) |
Créer un contrôleur API
// app/Controllers/Api/PostApiController.php final class PostApiController extends AbstractApiController { public function __construct( private PostDao $postDao, private Request $request, ) {} public function index(): JsonResponse { $result = $this->postDao->paginate(page: 1, perPage: 15); return $this->paginated( items: array_map(fn($p) => $p->toArray(), $result['data']), pagination: $result, ); } public function show(string $id): JsonResponse { $post = $this->postDao->findById((int) $id); return $post !== null ? $this->success($post->toArray()) : $this->notFound("Post #{$id} introuvable."); } public function store(): JsonResponse { $v = $this->validate((array) $this->request->post(), [ 'title' => 'required|min:3', 'body' => 'required', ]); if ($v->fails()) { return $this->validationError($v->errors()); } $id = $this->postDao->insert([...(array) $this->request->post(), 'created_at' => date('Y-m-d H:i:s')]); return $this->created($this->postDao->findById($id)?->toArray() ?? []); } }
Middlewares
| Middleware | Rôle |
|---|---|
CorsMiddleware |
En-têtes Access-Control-*, gestion des requêtes OPTIONS preflight |
BearerTokenMiddleware |
Vérifie Authorization: Bearer <token> vs API_KEY |
Scaffolding — Générateur de code
Le générateur crée automatiquement le squelette d'un module complet (Model, DAO, Controller, Migration).
Mode interactif (recommandé)
php bin/console make:module
Le prompt guide étape par étape :
✦ Astral MVC — Générateur de module
? Nom de l'entité (PascalCase) [Article] : Article
? Que souhaitez-vous générer ?
▶ [a] Tout (Model + DAO + Controller + Migration)
[m] Model seulement
[d] DAO seulement
[c] Controller seulement
[g] Migration seulement
Votre choix [a] :
? Type de contrôleur
▶ [resource] Web CRUD (HTML + vues)
[api] API REST (JSON)
[empty] Vide (à compléter)
Votre choix [resource] :
? Confirmer la génération ? [O/n] :
✓ app/Models/Article.php
✓ app/Dao/ArticleDao.php
✓ app/Controllers/ArticleController.php
✓ database/migrations/…_create_articles_table.php
Mode direct
# Tout générer d'un coup (web CRUD) php bin/console make:module Article # Contrôleur API JSON php bin/console make:module Article --api # Sans migration (table déjà existante) php bin/console make:module Article --no-migrate # Fichiers individuels php bin/console make:model Article php bin/console make:dao Article php bin/console make:controller Article --resource php bin/console make:controller Article --api
Ce que génère chaque commande
| Commande | Fichier généré | Description |
|---|---|---|
make:model Article |
app/Models/Article.php |
Modèle anémique avec propriétés typées |
make:dao Article |
app/Dao/ArticleDao.php |
DAO héritant d'AbstractDao, table auto-détectée |
make:controller Article --resource |
app/Controllers/ArticleController.php |
7 actions CRUD (index, show, create, store, edit, update, destroy) |
make:controller Article --api |
app/Controllers/ArticleController.php |
5 actions JSON avec validation et codes HTTP |
make:migration create_articles_table |
database/migrations/…php |
Migration SQLite + MySQL |
Après la génération — étapes manuelles
# 1. Appliquer la migration php bin/console migrate # 2. Enregistrer dans app/Providers/AppServiceProvider.php $container->singleton(ArticleDao::class, fn($c) => new ArticleDao($c->make(PDO::class))); $container->bind(ArticleController::class, fn($c) => new ArticleController( view: $c->make(View::class), request: $c->make(Request::class), session: $c->make(Session::class), articleDao: $c->make(ArticleDao::class), )); # 3. Ajouter les routes dans config/routes.php $router->group('', function (Router $r): void { $r->get('/articles', ArticleController::class, 'index'); $r->get('/articles/create', ArticleController::class, 'create'); $r->post('/articles', ArticleController::class, 'store'); $r->get('/articles/:id', ArticleController::class, 'show'); $r->get('/articles/:id/edit', ArticleController::class, 'edit'); $r->put('/articles/:id', ArticleController::class, 'update'); $r->post('/articles/:id/delete', ArticleController::class, 'destroy'); }, [AuthMiddleware::class, CsrfMiddleware::class]);
public/index.phpetsrc/Core/Application.phpne sont jamais modifiés.
Fonctionnalités PHP 8.x utilisées
declare(strict_types=1)partout- Constructor Property Promotion (
public function __construct(PDO $pdo)) - Named Arguments (
findAll(orderBy: 'name')) matchexpression dansConnection.phpetValidator.php- Union types (
string|array) str_contains(),str_starts_with()fn()arrow functionsmixedtype hintneverreturn type (PHP 8.1, utilisé optionnellement)