imamx39 / php-framework
A custom PHP framework inspired by Symfony/Laravel — ORM, Router, Auth, Events, Cache, Forms, Mailer, Queue, Pipeline and more.
Requires
- php: >=8.1
- twig/twig: ^3.24
Requires (Dev)
- phpunit/phpunit: ^10.0
This package is auto-updated.
Last update: 2026-06-21 20:49:41 UTC
README
Un framework PHP moderne inspiré de Symfony et Laravel, construit from scratch.
Sommaire
Installation
composer create-project imamx39/php-framework mon-projet
cd mon-projet
Le script d'init s'exécute automatiquement : .env copié depuis .env.example, répertoires var/ et storage/ créés.
Configuration
Édite .env à la racine du projet :
APP_NAME=MonApp APP_ENV=development APP_DEBUG=true APP_URL=http://localhost:8000 # Base de données DATABASE_URL=mysql://root:@127.0.0.1:3306/ma_base # SQLite (dev) : # DATABASE_URL=sqlite:////chemin/absolu/vers/db.sqlite # Mailer (optionnel — NullMailer si vide) MAIL_HOST=smtp.mailtrap.io MAIL_PORT=2525 MAIL_USERNAME=xxxxx MAIL_PASSWORD=xxxxx MAIL_ENCRYPTION=tls MAIL_FROM_ADDRESS=noreply@monapp.com MAIL_FROM_NAME="${APP_NAME}" # Queue (optionnel — "file" par défaut) QUEUE_DRIVER=file # ou "sync" pour le dev/test
Lance le serveur de développement :
composer serve
# → http://localhost:8000
Structure du projet
mon-projet/
├── app/
│ ├── Controller/ # Contrôleurs de l'application
│ ├── Entity/ # Entités ORM
│ ├── Pipeline/ # Étapes de pipeline métier
│ └── Repository/ # Repositories
├── bin/
│ ├── console # CLI
│ └── setup # Script d'initialisation
├── config/
│ ├── routes.php # Définition des routes
│ └── services.php # Conteneur de services (DI)
├── migrations/ # Migrations SQL versionnées
├── public/
│ └── index.php # Point d'entrée HTTP
├── src/ # Code source du framework
├── templates/ # Templates Twig
├── tests/ # Tests PHPUnit
├── var/
│ ├── cache/ # Cache compilé (auto)
│ ├── logs/ # Logs (auto)
│ └── queue/ # Jobs en attente (auto)
├── storage/app/ # Fichiers uploadés
├── .env # Config locale (ignoré par git)
├── .env.example # Template à copier
└── composer.json
Démarrage rapide
1. Créer une entité
php bin/console make:entity Product
Génère app/Entity/Product.php et app/Repository/ProductRepository.php.
#[Entity(table: 'products', repositoryClass: ProductRepository::class)] class Product { #[Id] #[GeneratedValue] #[Column(type: 'integer')] private ?int $id = null; #[Column(type: 'string', length: 255)] private string $name; #[Column(type: 'decimal')] private float $price; }
2. Créer et lancer une migration
php bin/console make:migration CreateProductsTable
Édite le fichier généré dans migrations/ :
public function up(): void { $this->execute(' CREATE TABLE products ( id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(255) NOT NULL, price DECIMAL(10,2) NOT NULL ) '); } public function down(): void { $this->execute('DROP TABLE products'); }
php bin/console migrate php bin/console migrate:status # état des migrations php bin/console migrate:rollback # annuler la dernière
3. Créer un contrôleur
php bin/console make:controller Product
class ProductController extends AbstractController { public function __construct( private readonly ProductRepository $repo, ) {} #[Route('/products', name: 'product.index', methods: ['GET'])] public function index(): Response { return $this->render('product/index.html.twig', [ 'products' => $this->repo->findAll(), ]); } }
Fonctionnalités
Routing
// config/routes.php $router->get('/products', [ProductController::class, 'index']); $router->post('/products', [ProductController::class, 'store']); $router->get('/products/{id}', [ProductController::class, 'show']); $router->put('/products/{id}', [ProductController::class, 'update']); $router->delete('/products/{id}', [ProductController::class, 'destroy']);
Via attribut PHP 8 directement sur la méthode :
#[Route('/products/{id}', name: 'product.show', methods: ['GET'])] public function show(Request $request, int $id): Response { $product = $this->repo->find($id); // ... }
Contrôleurs & Injection
Les dépendances sont injectées automatiquement dans le constructeur via le conteneur :
class OrderController extends AbstractController { public function __construct( private readonly OrderRepository $orders, private readonly Dispatcher $queue, private readonly Gate $gate, private readonly Logger $logger, ) {} }
Helpers disponibles dans tout contrôleur :
$this->render('template.html.twig', $data); // réponse HTML Twig $this->json($data, 200); // réponse JSON $this->redirect('/url'); // redirection
ORM
Lecture
$product = $repo->find(1); $products = $repo->findAll(); $actifs = $repo->findBy(['active' => 1], ['name' => 'ASC']); $one = $repo->findOneBy(['email' => 'a@b.com']); $total = $repo->count(['active' => 1]); // Pagination $page = $repo->paginate(page: 1, perPage: 15); $page->items(); // entités de la page courante $page->total(); // nombre total d'enregistrements $page->lastPage(); $page->hasMore(); $page->from(); // rang du 1er élément $page->to(); // rang du dernier
Persistance
$repo->save($product); // INSERT si id null, UPDATE sinon $repo->delete($product);
Relations
#[Entity(table: 'posts')] class Post { #[ManyToOne(targetEntity: User::class, joinColumn: 'user_id')] private ?User $author = null; #[OneToMany(targetEntity: Comment::class, mappedBy: 'post_id')] private array $comments = []; #[ManyToMany( targetEntity: Tag::class, joinTable: 'post_tags', joinColumn: 'post_id', inverseJoinColumn: 'tag_id', )] private array $tags = []; } // Chargement explicite $post = $repo->find(1, relations: ['author', 'tags', 'comments']); // Gestion ManyToMany $repo->attach($post, $tag, 'tags'); $repo->detach($post, $tag, 'tags'); $repo->sync($post, [$tag1, $tag2], 'tags');
Enum Support
Les BackedEnum PHP sont castés automatiquement :
enum Status: string { case Active = 'active'; case Inactive = 'inactive'; } #[Entity(table: 'users')] class User { #[Column(type: 'string')] private Status $status = Status::Active; } // Stocké en DB : 'active' | En PHP : Status::Active $user->getStatus() === Status::Active; // true
DTO / Value Objects
Objets immutables créés et validés automatiquement depuis la Request :
class CreateUserDTO extends AbstractDTO { public function __construct( #[Validate('required|email')] public readonly string $email, #[Validate('required|min:8')] public readonly string $password, #[Validate('required|min:2|max:100')] public readonly string $name, public readonly string $role = 'user', ) {} }
Dans le contrôleur, le DTO est injecté directement — zéro boilerplate :
public function register(CreateUserDTO $dto): Response { // $dto est déjà validé, castés et prêt à l'emploi $user = new User($dto->email, $dto->name, $dto->role); $this->repo->save($user); return $this->json(['status' => 'created'], 201); }
Si la validation échoue, une ValidationException est levée automatiquement avec les erreurs.
Casts automatiques : int, float, bool, string, DateTimeImmutable, DateTime.
Collection
Wrapper fluent et chaînable autour d'un tableau, inspiré de Laravel :
$users = collect($repo->findAll()); // Filtrer, transformer, trier $emails = $users ->filter(fn($u) => $u->isActive()) ->sortBy(fn($u) => $u->getName()) ->map(fn($u) => $u->getEmail()) ->values(); // Agrégats $total = collect($orders)->sum(fn($o) => $o->getTotal()); $average = collect($scores)->avg(); $max = collect($prices)->max(); // Grouper / découper $byRole = collect($users)->groupBy(fn($u) => $u->getRole()); $chunks = collect($items)->chunk(10); // Recherche $admin = collect($users)->first(fn($u) => $u->getRole() === 'admin'); $hasAdmin = collect($users)->some(fn($u) => $u->getRole() === 'admin'); $allOk = collect($items)->every(fn($i) => $i->isValid()); // Transformation $flat = collect([[1, 2], [3, 4]])->flatten(); $names = collect($users)->pluck('name'); $unique = collect([1, 2, 2, 3])->unique()->values(); // Sérialisation $array = $collection->toArray(); $json = $collection->toJson();
Le helper global collect() est disponible partout dans l'application.
Query Scopes
Encapsuler des filtres réutilisables directement dans le repository :
class UserRepository extends AbstractRepository { protected function getEntityClass(): string { return User::class; } // Définir des scopes — préfixe "scope" + nom en PascalCase public function scopeActive(QueryBuilder $qb): QueryBuilder { return $qb->where('active', 1); } public function scopeRole(QueryBuilder $qb, string $role): QueryBuilder { return $qb->where('role', $role); } public function scopeRecent(QueryBuilder $qb, int $days = 30): QueryBuilder { return $qb->where('created_at', '>=', date('Y-m-d', strtotime("-{$days} days"))); } }
Utilisation avec chaînage fluent :
// Récupérer $users = $repo->active()->get(); // Collection $admin = $repo->active()->role('admin')->first(); // ?object $count = $repo->active()->role('user')->count(); // int // Paginer $page = $repo->active()->recent(7)->paginate(page: 1, perPage: 20); // Avec chargement de relations $users = $repo->active()->get(relations: ['profile']);
Gates & Policies
Système d'autorisation en deux niveaux : gates simples et policies par entité.
Définir des abilities
// config/services.php (Gate déjà enregistré en singleton) $gate = $container->get(Gate::class); $gate->define('admin', fn(User $u) => $u->getRole() === 'admin'); $gate->define('moderator', fn(User $u) => in_array($u->getRole(), ['admin', 'moderator']));
Policies par entité
class PostPolicy { public function edit(User $user, Post $post): bool { return $user->getId() === $post->getUserId(); } public function delete(User $user, Post $post): bool { return $user->getRole() === 'admin' || $user->getId() === $post->getUserId(); } } // Enregistrement $gate->policy(Post::class, PostPolicy::class);
Vérifications dans les contrôleurs
// Vérifier $gate->allows('admin'); // bool $gate->allows('edit', $post); // bool — passe par la policy $gate->denies('edit', $post); // bool // Autoriser (lève ForbiddenException HTTP 403 si refusé) $gate->authorize('edit', $post); // Vérifier le rôle $gate->hasRole('admin'); $gate->hasRole('admin', 'moderator'); // OR
Pipeline
Faire transiter une valeur à travers une série d'étapes ordonnées et indépendantes.
// Chaque étape reçoit la valeur et un callable $next class ValidateStock { public function handle(OrderData $order, callable $next): OrderData { if ($order->quantity > $this->available()) { throw new HttpException(422, 'Stock insuffisant.'); // ← court-circuit : les étapes suivantes ne s'exécutent pas } return $next($order); // ← passe à l'étape suivante } } class ApplyDiscount { public function handle(OrderData $order, callable $next): OrderData { if ($order->total >= 100) { $order->total *= 0.90; // -10 % } return $next($order); } }
// Dans un contrôleur ou service $order = Pipeline::send(new OrderData(...)) ->through([ ValidateStock::class, ApplyDiscount::class, SendInvoice::class, ]) ->thenReturn();
Fonctionnalités avancées :
// Closures inline Pipeline::send($value) ->through([ fn($v, $next) => $next(trim($v)), fn($v, $next) => $next(strtolower($v)), ]) ->thenReturn(); // Ajouter une étape dynamiquement $pipeline->pipe(fn($v, $next) => $next($v)); // Méthode personnalisée (défaut : handle) ->via('process') // Destination finale ->then(fn($order) => new JsonResponse($order)); // Immutable — through() et pipe() retournent un clone $base = Pipeline::send($v)->through([StepA::class]); $extended = $base->pipe(StepB::class); // $base inchangé
Authentication
$auth = $container->get(Auth::class); // Connexion if ($auth->attempt($email, $password)) { return $this->redirect('/dashboard'); } // Vérifications $auth->check(); // bool $auth->user(); // User|null $auth->id(); // int|null $auth->logout();
Middlewares Auth
// config/routes.php ou Application new AuthMiddleware($auth) // redirige vers /login si non connecté new GuestMiddleware($auth) // redirige vers /dashboard si déjà connecté
Formulaires
class RegisterFormType extends AbstractFormType { public function buildForm(FormBuilder $builder): void { $builder ->add('name', 'text', ['rules' => 'required|min:2']) ->add('email', 'email', ['rules' => 'required|email']) ->add('password', 'password', ['rules' => 'required|min:8|confirmed']) ->add('password_confirmation', 'password', []); } } // Contrôleur $form = $factory->create(new RegisterFormType()); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $data = $form->getData(); // tableau des valeurs validées }
Dans Twig :
{{ form_start(form, '/register', 'POST') }}
{{ form_row(form, 'name') }}
{{ form_row(form, 'email') }}
{{ form_row(form, 'password') }}
{{ form_row(form, 'password_confirmation') }}
{{ csrf_field() }}
<button type="submit">S'inscrire</button>
{{ form_end() }}
Types disponibles : text email password number textarea select checkbox hidden.
Validation
$validator = new Validator(); $errors = $validator->validate($request->all(), [ 'name' => 'required|string|min:2|max:100', 'email' => 'required|email', 'age' => 'required|integer|min:18', 'role' => 'required|in:admin,user,moderator', 'password' => 'required|min:8|confirmed', 'website' => 'url', ]); if (!empty($errors)) { // $errors = ['email' => ['Email invalide.'], ...] }
Règles disponibles :
| Règle | Description |
|---|---|
required |
Champ obligatoire |
string |
Doit être une chaîne |
integer |
Doit être un entier |
numeric |
Entier ou flottant |
boolean |
true / false |
email |
Format email valide |
url |
URL valide |
min:N |
Longueur ou valeur minimale |
max:N |
Longueur ou valeur maximale |
between:N,M |
Entre N et M |
in:a,b,c |
Valeur dans la liste |
not_in:a,b |
Valeur absente de la liste |
confirmed |
Doit correspondre au champ _confirmation |
regex:/pattern/ |
Correspond à l'expression régulière |
Cache
$cache = $container->get(CacheInterface::class); $cache->put('key', $value, ttl: 3600); // TTL en secondes $cache->get('key', default: null); $cache->has('key'); $cache->forget('key'); $cache->flush(); // Mémoïsation $users = $cache->remember('users.all', 600, fn() => $repo->findAll());
Drivers disponibles : FileCache (production), ArrayCache (tests).
Queue / Jobs
Traitement asynchrone de tâches lourdes : envois d'emails, exports, notifications.
// 1. Définir un job class SendWelcomeEmailJob implements JobInterface { public function __construct( private readonly string $email, private readonly string $name, ) {} // Les dépendances de handle() sont injectées automatiquement public function handle(MailerInterface $mailer): void { $mailer->send( (new Message()) ->to($this->email, $this->name) ->subject('Bienvenue !') ->html("<h1>Bonjour {$this->name} !</h1>") ); } } // 2. Dispatcher depuis un contrôleur public function register(CreateUserDTO $dto): Response { $this->dispatcher->dispatch(new SendWelcomeEmailJob($dto->email, $dto->name)); // Avec délai (en secondes) $this->dispatcher->dispatch(new ReminderJob($user->id), delay: 3600); return $this->json(['status' => 'created'], 201); }
php bin/console queue:work # worker en boucle infinie php bin/console queue:work --once # traite un seul job php bin/console queue:flush # vide la queue
| Driver | Usage | Comportement |
|---|---|---|
file (défaut) |
Production | Jobs sérialisés dans var/queue/ |
sync |
Dev / Tests | Exécution immédiate, pas de fichier |
Retry automatique : 3 tentatives. Échec définitif → déplacé dans var/queue/failed/.
HTTP Client
Client HTTP fluent basé sur cURL pour appeler des APIs externes :
$client = new HttpClient(baseUrl: 'https://api.example.com'); // GET $response = $client->get('/users'); $users = $response->json(); // array décodé $status = $response->status(); // 200 $ok = $response->ok(); // bool // POST avec JSON $response = $client->post('/users', [ 'name' => 'Alice', 'email' => 'alice@example.com', ]); // Headers, auth, timeout $client = (new HttpClient()) ->withToken($apiKey) // Bearer token ->withBasicAuth($user, $password) // Basic Auth ->withHeaders(['X-App-Version' => '1.0']) ->withTimeout(10); // Méthodes disponibles $client->get('/resource'); $client->post('/resource', $data); $client->put('/resource/1', $data); $client->patch('/resource/1', $data); $client->delete('/resource/1'); // Gestion des erreurs if ($response->failed()) { // clientError() → 4xx | serverError() → 5xx } $response->throw(); // lève HttpClientException si status >= 400
Mailer
$message = (new Message()) ->from('noreply@monapp.com', 'Mon App') ->to($user->getEmail(), $user->getName()) ->cc('admin@monapp.com') ->subject('Bienvenue !') ->html('<h1>Bonjour !</h1><p>Ton compte est créé.</p>') ->text('Bonjour ! Ton compte est créé.'); $mailer->send($message);
En développement, le NullMailer absorbe les envois sans les transmettre.
Serializer
$serializer = $container->get(Serializer::class); // Objet → tableau / JSON $array = $serializer->normalize($product); $json = $serializer->toJson($product); $json = $serializer->toJson($products); // tableau d'objets // Groupes — exposer différents champs selon le contexte $public = $serializer->normalize($user, groups: ['public']); $admin = $serializer->normalize($user, groups: ['admin']);
Annoter les propriétés :
#[SerializeGroup('public', 'admin')] private string $name; #[SerializeGroup('admin')] // invisible pour le groupe 'public' private string $passwordHash;
File Storage
$storage = $container->get(LocalStorage::class); // Écriture / lecture $storage->put('avatars/user-1.jpg', $imageContent); $content = $storage->get('avatars/user-1.jpg'); $url = $storage->url('avatars/user-1.jpg'); // → /storage/avatars/user-1.jpg // Vérification / suppression $storage->exists('avatars/user-1.jpg'); $storage->delete('avatars/user-1.jpg'); // Upload depuis un formulaire $path = $storage->putUpload($_FILES['avatar'], directory: 'avatars'); // Lister $files = $storage->files('avatars'); $all = $storage->files('', recursive: true);
Rate Limiter
$limiter = $container->get(RateLimiter::class); if (!$limiter->attempt("login:{$ip}", maxAttempts: 5, decaySeconds: 60)) { return new Response('Trop de tentatives. Réessaie dans 1 minute.', 429); } $limiter->remaining("login:{$ip}", 5); // tentatives restantes $limiter->clear("login:{$ip}"); // remettre à zéro
Via middleware (appliqué sur une route ou globalement) :
new ThrottleMiddleware($limiter, maxAttempts: 60, decaySeconds: 60)
Events
$dispatcher = $container->get(EventDispatcher::class); // S'abonner $dispatcher->on('user.registered', function (Event $event) { // envoyer email de bienvenue }, priority: 10); // Émettre $dispatcher->emit('user.registered', new UserRegisteredEvent($user)); // Événements kernel (court-circuitage possible) $dispatcher->on(KernelEvents::REQUEST, function (RequestEvent $event) { $event->setResponse(new Response('En maintenance.', 503)); });
Logger
Compatible PSR-3, deux handlers configurés par défaut :
$logger = $container->get(Logger::class); $logger->debug('Requête reçue', ['url' => $request->getUri()]); $logger->info('Utilisateur connecté', ['user_id' => $auth->id()]); $logger->warning('Tentative suspecte', ['ip' => $ip]); $logger->error('Erreur critique', ['exception' => $e->getMessage()]);
var/logs/app.log— tous les niveaux (DEBUG+ en dev, INFO+ en prod)var/logs/error.log— ERROR et supérieur uniquement
CSRF Protection
// Middleware global (déjà configuré) new CsrfMiddleware($csrfManager) // Avec exemptions (webhooks, APIs) new CsrfMiddleware($csrfManager, exemptPaths: ['/api/', '/webhook/'])
Dans Twig :
<form method="POST" action="/login"> {{ csrf_field() }} ... </form>
Pour les requêtes AJAX :
<meta name="csrf-token" content="{{ csrf_token() }}">
fetch('/api/data', { method: 'POST', headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content }, body: JSON.stringify(data), });
Debug Toolbar
Activée automatiquement quand APP_DEBUG=true.
Injectée avant </body> sur toutes les réponses HTML, la toolbar affiche :
- Requête — méthode, URL, durée totale
- Requêtes SQL — chaque requête avec ses paramètres et son temps d'exécution
- Logs — tous les messages enregistrés durant la requête
- Mémoire — pic d'utilisation mémoire
APP_DEBUG=true # active la toolbar APP_DEBUG=false # désactivée en production
Console
# Génération de code php bin/console make:entity NomEntite php bin/console make:migration NomMigration php bin/console make:controller NomController # Migrations php bin/console migrate php bin/console migrate:status php bin/console migrate:rollback # Queue php bin/console queue:work php bin/console queue:work --once php bin/console queue:flush
Tests
composer test # ou vendor/bin/phpunit --testdox
516 tests · 839 assertions — tout vert sur PHP 8.1 / 8.2 / 8.3 / 8.4.
Licence
MIT — IMAMx39