fennectra / framework
Fennectra — PHP 8.3+ high-performance MVC API framework with built-in compliance (SOC 2, ISO 27001, NF525, GDPR)
Requires
- php: >=8.2
- aws/aws-sdk-php: ^3.373
- dompdf/dompdf: ^3.1
- firebase/php-jwt: ^7.0
- google/cloud-storage: ^1.49
- intervention/image: ^3.0
- monolog/monolog: ^3.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.94
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.0
README
High-performance PHP 8.3+ framework
JWT · ORM · Events · Worker · Profiler · Scheduler · Queue · Notifications · Webhooks · Images · Feature Flags · Multi-tenant · Storage · PDF · SOC 2 · ISO 27001 · NF525 · GDPR · PostgreSQL · MySQL · SQLite
High-performance PHP 8.3+ framework with Dependency Injection, JWT, auto-generated OpenAPI, CLI, ORM with eager loading, Multi-database (PostgreSQL, MySQL, SQLite), Event Dispatcher with multiple brokers, Profiler, Rate Limiting, Migrations, K8s-safe Scheduler, Job Queue, Feature Flags, multi-channel Notifications, HMAC-SHA256 signed Webhooks, Image Transforms (Intervention/Image), SSE Broadcasting, OAuth, multi-driver Storage (Local, S3, GCS), PDF generation, GDPR (consent, DPO dashboard, data subject rights), and FrankenPHP worker support.
Quickstart
# Install the CLI (once) composer global require fennectra/installer # Create a new project fennectra new my-api cd my-api cp .env.example .env # configure DB_DRIVER + credentials + SECRET_KEY ./forge serve # http://localhost:8080
Architecture
┌───────────────────────────────────────────────────────────────┐
│ public/index.php │
├───────────────────────────────────────────────────────────────┤
│ Request → CORS → Tenant → Profiler → Logging → Auth → Ctrl │
├─────────┬─────────┬─────────┬──────────┬─────────┬───────────┤
│ Router │ DI │ ORM │ Events │ JWT │ Rate │
│ │Container│ Model │Dispatcher│ Service │ Limiter │
├─────────┼─────────┼─────────┼──────────┼─────────┼───────────┤
│ Cache │Scheduler│ Queue │ Feature │ State │Notificat. │
│ Redis │+RedLock │ Jobs │ Flags │ Machine │Multi-chan │
├─────────┼─────────┼─────────┼──────────┼─────────┼───────────┤
│Webhooks │ Image │ Storage │ SSE │ OAuth │ PDF │
│HMAC-sign│Transform│L/S3/GCS │Broadcast │ G + GH │ dompdf │
├─────────┼─────────┼─────────┼──────────┼─────────┼───────────┤
│ Audit │Encrypti.│ Security│ NF525 │ GDPR │ │
│ SOC 2 │AES-256 │ Logger │ Fiscal │ Consent │ │
├─────────┴─────────┴─────────┴──────────┴─────────┴───────────┤
│ src/Core/ — The engine │
├───────────────────────────────────────────────────────────────┤
│ PostgreSQL / MySQL / SQLite Redis │
└───────────────────────────────────────────────────────────────┘
framework/ ← framework core (Packagist: fennectra/framework) (do not modify)
src/
Attributes/ ← validation, API docs, ORM, RateLimit, StateMachine, Broadcast, Auditable, Encrypted, Nf525
Commands/ ← CLI (serve, make:*, quality, migrate, seed, queue, schedule, deploy, tinker)
Core/ ← App, Router, Container, ORM, Events, JWT...
Database/ ← DB drivers (PostgreSQL, MySQL, SQLite) + DriverFactory
Profiler/ ← per-request debug profiler
Relations/ ← eager loading (BelongsTo, HasMany, HasOne)
RateLimiter/ ← rate limiting (Redis + InMemory stores)
Redis/ ← RedisConnection, RedisLock
Cache/ ← RedisCache, TaggedCache
Migration/ ← MigrationRunner, Seeder, FakeDataGenerator
Scheduler/ ← Schedule, CronExpression, Redis Lock K8s-safe
Queue/ ← Job dispatch, QueueWorker, FailedJobHandler
Feature/ ← Feature Flags with Redis cache
StateMachine/ ← controlled transitions on Models
Notification/ ← multi-channel (Mail, Slack, Database, Webhook)
Webhook/ ← outgoing HMAC-SHA256 webhooks + delivery jobs
Image/ ← image transformations (GD-based)
Broadcasting/ ← SSE via Redis Pub/Sub
OAuth/ ← Google, GitHub providers
Audit/ ← HasAuditTrail (SOC 2)
Encryption/ ← AES-256-GCM at rest (SOC 2)
Security/ ← SecurityLogger, PasswordPolicy, AccountLockout (ISO 27001)
Logging/ ← LogMaskingProcessor (SOC 2)
Nf525/ ← HasNf525, ClosingService, FecExporter, HashChainVerifier
Middleware/ ← Auth, CORS, Profiler, RateLimit, Security, Logging, IpAllowlist
database/ ← migrations and seeders
config/ ← phpstan, phpunit, cs-fixer
docker/ ← Dockerfile, docker-compose, Caddyfile, kubernetes
app/ ← your application code
Controllers/ ← HTTP handlers
Models/ ← ORM models
Dto/ ← input/output validation
Routes/ ← route files (auto-loaded)
Jobs/ ← job classes for the queue
config/tenants.php ← multi-tenancy mapping (domain/port → database)
Schedule.php ← scheduled tasks
storage/ ← uploaded files (local driver)
public/ ← web root
index.php ← HTTP entry point (worker + classic)
router.php ← router script for the built-in PHP server
storage ← symlink to storage/ (created by storage:link)
Routing
Routes are defined in app/Routes/ — one file per domain, loaded automatically.
// app/Routes/admin.php $router->group([ 'prefix' => '/admin', 'description' => 'Administration', 'middleware' => [[Auth::class, ['admin']]], ], function ($router) { $router->get('/users', [AdminController::class, 'listUsers']); $router->post('/users', [AdminController::class, 'create']); $router->put('/users/{id}', [AdminController::class, 'update']); $router->delete('/users/{id}', [AdminController::class, 'delete']); });
Available methods: get(), post(), put(), delete()
Dynamic parameters: /users/{id} — automatically injected into the controller
Middleware: per route or per group
OpenAPI: auto-generated documentation from attributes
ORM & Query Builder
ORM Model
#[Table('users')] class User extends Model { public function role(): BelongsTo { return $this->belongsTo(Role::class, 'role_id'); } }
Fluent Queries
// Search User::where('active', true) ->where('role_id', '>', 5) ->orderBy('created_at', 'DESC') ->limit(10) ->get(); // Collection of Models User::find(123); // or null User::findOrFail(123); // or HttpException 404 // Create $user = new User(['email' => 'x@y.com', 'name' => 'Ali']); $user->save(); // or User::create(['email' => 'x@y.com']); // Update $user->email = 'new@y.com'; $user->save(); // UPDATE only modified fields // Delete $user->delete(); // soft delete (deleted_at) $user->forceDelete(); // actual DELETE $user->restore(); // undo soft delete
Relations
| Method | Type | Example |
|---|---|---|
belongsTo() |
Many-to-One | User → Role |
hasMany() |
One-to-Many | Role → Users |
hasOne() |
One-to-One | User → Profile |
Eager Loading (N+1 prevention)
// BEFORE: N+1 queries (1 + N queries) $users = User::where('active', true)->get(); foreach ($users as $user) { echo $user->role->name; // 1 query per user! } // AFTER: 2 queries total $users = User::with('role')->where('active', true)->get(); foreach ($users as $user) { echo $user->role->name; // already loaded, 0 queries } // Multiple relations User::with('role', 'profile')->paginate(20);
Query Builder (without ORM)
DB::table('users')->where('active', true)->get(); // main database DB::table('clients', 'job')->limit(10)->get(); // secondary database DB::raw('SELECT * FROM users WHERE id = :id', ['id' => 1]); DB::transaction(function () { /* ... */ });
JWT Authentication & RBAC
Token Generation
// POST /token — generate a JWT $jwt = $jwtService->generate([ 'email' => $user->email, 'role' => $user->role()->name, 'id' => $user->id, ]);
Route Protection
// Accessible to all authenticated users $router->get('/profile', [UserController::class, 'me'], [[Auth::class]]); // Restricted to admins $router->get('/admin', [AdminController::class, 'index'], [[Auth::class, ['admin']]]); // Restricted to admin + manager $router->group([ 'middleware' => [[Auth::class, ['admin', 'manager']]], ], function ($router) { /* ... */ });
Get the Authenticated User
$user = Auth::user(); // ['email' => ..., 'role' => ..., 'id' => ...]
Tokens: Access (15min) + Refresh (24h) — configurable via JWT_ACCESS_TTL and JWT_REFRESH_TTL
Event Dispatcher
Event system with 3 interchangeable brokers via EVENT_BROKER:
| Broker | Transport | Dependency | Usage |
|---|---|---|---|
sync |
Same process | None | Dev / default |
redis |
Redis Pub/Sub | REDIS_* |
Async production |
database |
PostgreSQL table | EVENT_DB_* |
Async without Redis |
Usage
// Dispatch an event Event::dispatch('user.created', $userData); // Listen Event::listen('user.created', function ($data) { // send email, log, notify... }, priority: 10); // Listen once Event::once('user.verified', fn($data) => /* ... */); // Check if listeners exist Event::hasListeners('user.created'); // bool
DTOs & Validation
Validation via PHP 8.1+ attributes — auto-documented in OpenAPI.
readonly class ProductRequest { public function __construct( #[Required] #[MinLength(3)] #[Description('Product name')] public string $name, #[Required] #[Email] public string $contact_email, #[Min(0)] public float $price, ) {} }
Available Attributes
| Attribute | Description |
|---|---|
#[Required] |
Required field |
#[Email] |
Valid email |
#[MinLength] |
Minimum length |
#[MaxLength] |
Maximum length |
#[Min] |
Minimum numeric value |
#[Max] |
Maximum numeric value |
#[Regex] |
Custom regex pattern |
#[ArrayOf] |
Array element typing |
#[Description] |
Field documentation |
$errors = Validator::validate(ProductRequest::class, $requestData);
CLI — Commands
./forge # list all commands ./forge serve # PHP dev server ./forge serve --frankenphp # native FrankenPHP ./forge serve --frankenphp --worker # worker mode (max perf) ./forge serve --port=3000 # custom port
CRUD Generation
./forge make:all Product --roles=admin,manager # full CRUD ./forge make:all Invoice --connection=job # on secondary database ./forge make:all Article --no-auth # without auth
Individual Generation
./forge make:model Product ./forge make:controller ProductController --crud ./forge make:dto ProductRequest --request ./forge make:dto ProductResponse --response ./forge make:route Product --prefix=/product --middleware=auth ./forge make:event UserCreated ./forge make:listener SendWelcomeEmail
Migrations & Seeding
./forge migrate # apply migrations ./forge migrate --rollback # rollback the last batch ./forge migrate --status # view current status ./forge make:migration add_phone # create a migration ./forge make:audit # full audit module (SOC 2) ./forge make:webhook # full webhooks module ./forge make:nf525 # full NF525 module (fiscal) ./forge make:rgpd # full GDPR module (consent) ./forge db:seed # run seeders ./forge make:seeder UserSeeder # create a seeder
Queue & Scheduler
./forge queue:work # consume jobs ./forge queue:work --queue=emails # specific queue ./forge queue:retry --id=5 # retry a failed job ./forge schedule:run # run due tasks ./forge make:job SendWelcomeEmail # create a job
Feature Flags & Deploy
./forge feature list # list flags ./forge feature enable dark-mode # enable a flag ./forge feature disable dark-mode # disable a flag ./forge deploy # build + push + K8s rollout ./forge deploy --dry-run # preview without executing
Tinker (Interactive SQL)
./forge tinker --sql="SELECT * FROM users LIMIT 5" ./forge tinker --sql="\dt" # list tables ./forge tinker --sql="\d users" # describe table ./forge tinker --sql="SELECT 1" --connection=job # secondary database
Storage
./forge storage:link # symlink public/storage → storage/
Quality & Cache
./forge quality # lint + PHPStan + tests ./forge quality --fix # auto-fix style ./forge cache:clear # clear cache ./forge cache:routes # cache routes
FrankenPHP Worker
High-performance mode where the application boots once and handles requests in a loop.
┌─────────────────────────────────────────┐
│ FrankenPHP Worker │
│ │
│ Boot (1x) ──→ ┌───────────────────┐ │
│ │ Request Loop │ │
│ │ req → handle │ │
│ │ req → handle │ │
│ │ req → handle │ │
│ │ ... │ │
│ └───────────────────┘ │
│ │
│ 10-100x faster than PHP-FPM │
└─────────────────────────────────────────┘
Starting
# Native dev ./forge serve --frankenphp --worker # Docker (uses Caddyfile + frankenphp run) docker build -f Dockerfile.frankenphp -t php-api:franken . docker run -p 8080:8080 --env-file .env php-api:franken
Monitoring & Probes
GET /health → basic health check
GET /healthz → liveness probe (K8s)
GET /readyz → readiness probe (DB + Redis)
GET /debug/worker → worker stats (requests, memory, trend)
GET /debug/profiler → profiler (last 50 requests with SQL, events, timing)
index.phpautomatically detects worker mode viafrankenphp_handle_request()- Caddyfile for worker routing (not
php-server) - Monolog logger (14-day rotation, stderr for Docker/K8s)
- WorkerStats: memory delta, trend analysis (stable/growing/spiky), error tracking
- Guaranteed cleanup (try/finally): DB flush, auth reset, GC
- Configurable request limit (
MAX_REQUESTS)
Multi-database & Multi-driver
Supported Drivers
The framework supports 3 database drivers via the DB_DRIVER variable:
| Driver | DB_DRIVER |
Env prefix | Default port |
|---|---|---|---|
| PostgreSQL | pgsql (default) |
POSTGRES_ |
5432 |
| MySQL | mysql |
MYSQL_ |
3306 |
| SQLite | sqlite |
SQLITE_ |
— |
PostgreSQL Configuration (default)
DB_DRIVER=pgsql POSTGRES_HOST=localhost POSTGRES_PORT=5432 POSTGRES_DB=fennectra POSTGRES_USER=fennectra POSTGRES_PASSWORD=secret
MySQL Configuration
DB_DRIVER=mysql MYSQL_HOST=localhost MYSQL_PORT=3306 MYSQL_DB=myapp MYSQL_USER=root MYSQL_PASSWORD=secret
SQLite Configuration
DB_DRIVER=sqlite SQLITE_DB=var/database.sqlite # or in-memory for tests: # SQLITE_DB=:memory:
Multiple Connections
# Secondary database (any name) POSTGRES_JOB_HOST=10.0.0.50 POSTGRES_JOB_DB=job_database POSTGRES_JOB_USER=user POSTGRES_JOB_PASSWORD=pass
DB::table('users')->get(); // main database DB::table('invoices', 'job')->get(); // "job" database DB::table('test_data', 'test')->get(); // "test" database
Custom Driver
use Fennec\Core\Database\DriverFactory; // Register a custom driver (CockroachDB, etc.) DriverFactory::register('cockroach', MyCockroachDriver::class);
Models generated with --connection=job automatically use the correct connection.
Multi-tenancy
Database isolation per tenant, automatically resolved from the HTTP domain or port.
Configuration
1. Declare tenants in app/config/tenants.php:
return [ 'domains' => [ 'client1.example.com' => 'client1', 'client2.example.com' => 'client2', '*.client3.com' => 'client3', // wildcard subdomains ], 'ports' => [ 8081 => 'client1', // useful for local dev 8082 => 'client2', ], 'tenants' => [ 'client1' => [ 'host' => 'POSTGRES_TENANT_CLIENT1_HOST', 'port' => 'POSTGRES_TENANT_CLIENT1_PORT', 'db' => 'POSTGRES_TENANT_CLIENT1_DB', 'user' => 'POSTGRES_TENANT_CLIENT1_USER', 'password' => 'POSTGRES_TENANT_CLIENT1_PASSWORD', ], 'client2' => [ 'host' => 'POSTGRES_TENANT_CLIENT2_HOST', 'port' => 'POSTGRES_TENANT_CLIENT2_PORT', 'db' => 'POSTGRES_TENANT_CLIENT2_DB', 'user' => 'POSTGRES_TENANT_CLIENT2_USER', 'password' => 'POSTGRES_TENANT_CLIENT2_PASSWORD', ], ], ];
2. Add environment variables in .env:
POSTGRES_TENANT_CLIENT1_HOST=localhost POSTGRES_TENANT_CLIENT1_PORT=5432 POSTGRES_TENANT_CLIENT1_DB=client1_db POSTGRES_TENANT_CLIENT1_USER=client1 POSTGRES_TENANT_CLIENT1_PASSWORD=secret POSTGRES_TENANT_CLIENT2_HOST=10.0.0.50 POSTGRES_TENANT_CLIENT2_PORT=5432 POSTGRES_TENANT_CLIENT2_DB=client2_db POSTGRES_TENANT_CLIENT2_USER=client2 POSTGRES_TENANT_CLIENT2_PASSWORD=secret
How it Works
- The
TenantMiddlewaredetects the tenant on each request (domain > wildcard > port) - The
defaultconnection is automatically redirected to the tenant's database - Named connections (
job,test, etc.) are not affected - Compatible with worker mode: the tenant is reset between each request
- If no tenant matches and multi-tenancy is configured, a 400 error is returned
Resolution Priority
- Exact domain (
client1.example.com) - Wildcard (
*.client3.com) - Port (
8081)
Accessing the Current Tenant
// In a controller (via the Container) $tenantManager = Container::getInstance()->get(TenantManager::class); $tenantManager->current(); // 'client1' or null // In a middleware (via request attributes) $tenantId = $request->getAttribute('tenant');
Local Multi-tenant Development
# Start 2 instances on different ports ./forge serve --port=8081 # → client1 ./forge serve --port=8082 # → client2
Dependency Injection Container
// Register a singleton $container->singleton(JwtService::class, fn() => new JwtService($secret)); // Resolve automatically $jwt = $container->get(JwtService::class); // Factory (new instance on each call) $container->bind(Logger::class, fn() => new Logger('app'));
Automatic resolution of constructor dependencies.
The Container is accessible via $app->container() or Container::getInstance().
Auto-generated API Documentation
OpenAPI documentation automatically generated from code:
- Scalar UI: http://localhost:8080/docs
- OpenAPI JSON: http://localhost:8080/docs/openapi
Automatic introspection:
- Routes and HTTP methods
#[ApiDescription],#[ApiStatus]attributes- DTO schemas (fields, types, validation)
- Required roles and authentication
Quality & Tests
# Check everything at once ./forge quality # lint + PHPStan + tests ./forge quality --fix # auto-fix style # Individually composer test # PHPUnit composer analyse # PHPStan (level 5) composer lint # PHP-CS-Fixer (PSR-12) composer lint:fix # auto-fix
- PHPUnit 11 — tests in
tests/ - PHPStan — static analysis
- PHP-CS-Fixer — PSR-12 style
Docker & Deployment
PHP-FPM + Nginx (classic)
docker build -f docker/Dockerfile -t php-api .
docker run -p 8080:8080 --env-file .env php-api
FrankenPHP Worker (max perf)
docker build -f docker/Dockerfile -t php-api:franken .
docker run -p 8080:8080 --env-file .env php-api:franken
FrankenPHP configuration via Caddyfile in docker/.
Docker Compose (local dev)
docker compose -f docker/docker-compose.yml up -d # starts API + PostgreSQL + Redis docker compose logs -f # follow logs
Kubernetes
Production manifests in docker/kubernetes/ with liveness/readiness probes.
./forge deploy # build + push + K8s rollout ./forge deploy --dry-run # preview without executing
Debug Profiler
Per-request profiler built into the worker. Automatically collects:
- SQL queries: SQL, bindings, duration in ms
- Dispatched events: name, listener duration
- Middleware: each middleware with its execution time
- DI resolutions: services resolved by the Container
- Memory: peak, delta per request
- N+1 detection: warning if the same query runs > 3 times
# Enable (automatic if APP_ENV=dev)
PROFILER_ENABLED=1
GET /debug/profiler → list of the last 50 requests
GET /debug/profiler/{id} → details of a request
The ring buffer persists in memory within the worker (zero I/O).
Rate Limiting
// Per route or group $router->group([ 'middleware' => [[RateLimitMiddleware::class, ['limit' => 30, 'window' => 60]]], ], function ($router) { /* ... */ });
- Redis in production (shared across K8s pods)
- InMemory in dev (zero deps)
- Automatic headers:
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset - 429 response with
Retry-After
Migrations & Seeding
Migrations
./forge make:migration create_products # generate a file ./forge migrate # apply pending migrations ./forge migrate --rollback # rollback the last batch ./forge migrate --status # current status
Format: database/migrations/2026_03_21_143022_create_products.php
return [ 'up' => 'CREATE TABLE products (id SERIAL PRIMARY KEY, name VARCHAR(255))', 'down' => 'DROP TABLE products', ];
Note: The
migrationstable is automatically created with driver-appropriate SQL (SERIALfor PostgreSQL,AUTO_INCREMENTfor MySQL,AUTOINCREMENTfor SQLite).
Seeding
class UserSeeder extends Seeder { public function run(): void { for ($i = 0; $i < 50; $i++) { User::create([ 'name' => $this->fake()->name(), 'email' => $this->fake()->email(), ]); } } }
Built-in FakeDataGenerator: name(), email(), number(), date(), uuid(), phone() — zero external dependencies.
Scheduler (K8s-safe)
Scheduled tasks with Redis Lock — single execution per pod in K8s.
// app/Schedule.php return (new Schedule()) ->call(fn() => DB::raw('DELETE FROM logs WHERE created_at < NOW() - INTERVAL \'30 days\'')) ->daily()->name('clean-logs') ->command('cache:clear') ->everyFiveMinutes()->name('cache-refresh');
The scheduler runs inside the FrankenPHP worker (60s throttle, zero external cron).
| Method | Frequency |
|---|---|
everyMinute() |
Every minute |
everyFiveMinutes() |
Every 5 minutes |
hourly() |
Every hour |
daily() / dailyAt('08:00') |
Daily |
weekly() / weekdays() |
Weekly |
cron('*/10 * * * *') |
Custom |
SCHEDULER_ENABLED=1 + REDIS_HOST to activate.
Job Queue
// Dispatch a job Job::dispatch(SendWelcomeEmail::class, ['user_id' => 123]); // Define a job class SendWelcomeEmail implements JobInterface { public function handle(array $payload): void { /* ... */ } public function retries(): int { return 3; } public function failed(array $payload, \Throwable $e): void { /* ... */ } }
./forge queue:work # consume (Redis BLPOP or DB polling) ./forge queue:retry --id=5 # retry a failed job
Drivers: QUEUE_DRIVER=redis (BLPOP) or database (FOR UPDATE SKIP LOCKED).
Failed jobs are stored in failed_jobs.
Feature Flags
// Simple check if (FeatureFlag::enabled('new-checkout')) { /* ... */ } // Per user/role (progressive rollout) if (FeatureFlag::for('beta-ui')->whenRole('admin')->enabled()) { /* ... */ } // Activate/deactivate FeatureFlag::activate('dark-mode'); FeatureFlag::deactivate('dark-mode');
Redis cache (60s TTL) + fallback to feature_flags table in DB.
./forge feature list
./forge feature enable dark-mode
State Machine
Controlled transitions on Models via attribute:
#[StateMachine(column: 'status', transitions: [ 'draft->submitted', 'submitted->approved', 'submitted->rejected', 'approved->shipped', 'shipped->delivered', ])] class Order extends Model { use HasStateMachine; } $order->transitionTo('submitted'); // draft → submitted OK $order->transitionTo('shipped'); // submitted → shipped FAIL Exception $order->canTransitionTo('approved'); // true $order->availableTransitions(); // ['approved', 'rejected']
Events automatically dispatched: Order.transitioned:submitted:approved
Multi-channel Notifications
// Send $user->notify(new OrderShippedNotification($order)); // Define class OrderShippedNotification extends Notification { public function via(): array { return ['database', 'mail', 'slack']; } public function toMail(): MailMessage { /* ... */ } public function toSlack(): SlackMessage { /* ... */ } public function toDatabase(): array { return ['order_id' => $this->order->id]; } }
Channels: Database, Mail (built-in SMTP, zero deps), Slack (webhook), Webhook (signed HTTP POST).
HasNotifications trait: notify(), notifications(), unreadNotifications().
Outgoing Webhooks
Webhook system for notifying external URLs on internal events, with HMAC-SHA256 signatures and automatic retry.
Automatic Dispatch via Events
// All registered webhooks listening to 'order.shipped' will be notified Event::dispatch('order.shipped', ['order_id' => 42, 'tracking' => 'ABC123']);
The WebhookManager listens to all events and automatically dispatches to matching webhooks via the Job Queue.
Register a Webhook (webhooks table)
INSERT INTO webhooks (name, url, secret, events, is_active) VALUES ( 'Partner API', 'https://partner.com/webhooks/orders', 'whsec_MySharedSecret', '["order.shipped", "order.cancelled"]', true );
A webhook can listen for specific events or ["*"] to receive everything.
HMAC-SHA256 Signature
Each request is signed — the recipient can verify authenticity:
// Receiver side — verify the signature $payload = file_get_contents('php://input'); $signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE']; $timestamp = (int) $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP']; $isValid = WebhookManager::verify($payload, $secret, $signature, $timestamp); // Automatically rejects requests older than 5 minutes (replay protection)
Sent headers:
| Header | Description |
|---|---|
X-Webhook-Event |
Event name (order.shipped) |
X-Webhook-Signature |
sha256=hmac(timestamp.payload, secret) |
X-Webhook-Timestamp |
Unix timestamp |
User-Agent |
Fennec-Webhook/1.0 |
Via the Notification System
class OrderShippedNotification extends Notification { public function via(): array { return ['database', 'webhook']; // multi-channel } public function toWebhook(): WebhookMessage { return (new WebhookMessage()) ->url('https://partner.com/hook') ->secret('whsec_MySecret') ->event('order.shipped') ->payload(['order_id' => $this->order->id]); } } $user->notify(new OrderShippedNotification($order));
Automatic Retry
Failed deliveries are retried 5 times with exponential backoff via the Job Queue. Each attempt is logged in the webhook_deliveries table:
| Column | Description |
|---|---|
webhook_id |
Reference to webhooks.id |
event |
Event name |
status |
pending / delivered / failed |
http_status |
HTTP response code |
response_body |
Response body (max 2000 chars) |
attempt |
Attempt number |
Setup
./forge make:webhook # generates the full module (migration + Models + DTOs + Controller + Routes) ./forge migrate # apply the migration
Generated API (admin only):
GET /webhooks Paginated list
GET /webhooks/{id} Details
POST /webhooks Create
PUT /webhooks/{id} Update
DELETE /webhooks/{id} Delete
PATCH /webhooks/{id}/toggle Enable/disable
GET /webhooks/{id}/deliveries Deliveries
GET /webhooks/stats Statistics
GET /webhooks/failures Recent failures
POST /webhooks/deliveries/{id}/retry Retry
Image Transforms
Image transformation via Intervention/Image v3 — resize, crop, blur, watermark, format conversion and more. Integrates with the existing Storage system.
Quick Transforms
// Resize (preserves aspect ratio) ImageTransformer::resize('photos/avatar.jpg', 800); ImageTransformer::resize('photos/avatar.jpg', 800, 600); // Square thumbnail 150x150 ImageTransformer::thumbnail('photos/avatar.jpg', 150); // Cover (fills exact dimensions) ImageTransformer::fit('photos/banner.jpg', 1200, 630); // Crop a region ImageTransformer::crop('photos/photo.jpg', 400, 400, 50, 50); // Convert to WebP ImageTransformer::convert('photos/large.png', 'webp', 85);
Each method returns the path of the transformed file in Storage.
Chainable Pipeline
For complex transformations, the pipeline lets you chain operations:
$outputPath = ImageTransformer::make('photos/original.jpg') ->orient() // automatic EXIF correction ->resize(1200) // max 1200px wide ->crop(800, 600, 100, 50) // crop a region ->blur(3) // gaussian blur ->sharpen(15) // sharpness ->brightness(10) // brightness ->contrast(5) // contrast ->greyscale() // black and white ->watermark('My App', 'bottom-right', 20, 'ffffff', 50) ->format('webp', 85) // conversion + quality ->apply(); // save to Storage // $outputPath = 'photos/transforms/original_a1b2c3d4.webp'
Direct Buffer (HTTP response)
// Return the transformed image without saving it $buffer = ImageTransformer::make('photos/avatar.jpg') ->fit(200, 200) ->format('webp') ->toBuffer(); header('Content-Type: image/webp'); echo $buffer;
Available Operations
| Method | Description |
|---|---|
resize(w, h) |
Resize (preserves aspect ratio) |
resizeExact(w, h) |
Resize (stretch, no ratio) |
crop(w, h, x, y) |
Crop a region |
fit(w, h) |
Cover — fills exact dimensions |
blur(amount) |
Gaussian blur (1-100) |
sharpen(amount) |
Increase sharpness (1-100) |
brightness(level) |
Brightness (-100 to +100) |
contrast(level) |
Contrast (-100 to +100) |
rotate(angle) |
Rotation in degrees |
flip() |
Horizontal mirror |
flop() |
Vertical mirror |
greyscale() |
Greyscale |
orient() |
Automatic EXIF correction |
watermark(text, pos, size, color, opacity) |
Text watermark |
format(fmt, quality) |
Output format (jpg, png, webp, gif) |
quality(q) |
Output quality (1-100) |
Pipeline Cache
Each pipeline generates a unique cache key based on the path + operations:
$pipeline = ImageTransformer::make('photo.jpg')->resize(800)->format('webp'); $cacheKey = $pipeline->cacheKey(); // 'img:a1b2c3d4e5f6...' // Use with the framework's Cache $result = Cache::remember($cacheKey, 86400, fn() => $pipeline->apply());
Attribute for Controllers
#[ImageTransform(maxWidth: 2000, maxHeight: 2000, allowedFormats: ['jpg', 'png', 'webp'])] public function transform(string $path): void { $buffer = ImageTransformer::make($path) ->resize((int) ($_GET['w'] ?? 800)) ->format($_GET['fmt'] ?? 'webp', (int) ($_GET['q'] ?? 85)) ->toBuffer(); header('Content-Type: ' . ImageTransformer::mimeType($_GET['fmt'] ?? 'webp')); header('Cache-Control: public, max-age=86400'); echo $buffer; }
Supported Formats
| Format | Read | Write | MIME |
|---|---|---|---|
| JPEG | Yes | Yes | image/jpeg |
| PNG | Yes | Yes | image/png |
| WebP | Yes | Yes | image/webp |
| GIF | Yes | Yes | image/gif |
Driver: GD by default. For optimal performance and animated GIF support, install the Imagick extension and instantiate with
new ImageTransformer(ImageManager::imagick()).
SSE Broadcasting
Server-Sent Events for real-time:
// Server side Broadcaster::broadcast('orders', 'shipped', ['order_id' => 42]); // Client side (JavaScript) const es = new EventSource('/events/stream?channels=orders'); es.onmessage = (e) => console.log(JSON.parse(e.data));
- Redis Pub/Sub for cross-pod communication
- Heartbeat every 15s
#[Broadcast('channel')]attribute for auto-broadcast
OAuth (Google, GitHub)
The framework provides an OAuth engine (src/Core/OAuth/) with Google and GitHub providers. To use it, generate a controller and routes:
./forge make:controller OAuthController
Then configure routes in app/Routes/:
// app/Routes/public.php $router->get('/auth/{provider}/redirect', [OAuthController::class, 'redirect']); $router->get('/auth/{provider}/callback', [OAuthController::class, 'callback']);
Example usage in the controller:
use Fennec\Core\OAuth\OAuthManager; class OAuthController { public function __construct(private OAuthManager $oauth) {} public function redirect(string $provider): array { $driver = $this->oauth->driver($provider); $url = $driver->getAuthorizationUrl($state); return ['redirect_url' => $url]; } public function callback(string $provider): array { $driver = $this->oauth->driver($provider); $token = $driver->getAccessToken($_GET['code']); $user = $driver->getUserInfo($token->accessToken); // create/find the user, generate a JWT... } }
OAUTH_GOOGLE_CLIENT_ID=... OAUTH_GOOGLE_CLIENT_SECRET=... OAUTH_GOOGLE_REDIRECT_URI=http://localhost:8080/auth/google/callback
Zero external dependencies — HTTP via stream_context_create.
SOC 2 Compliance
The framework includes the technical controls required for SOC 2 Type II.
Audit Trail
Automatic tracking of create/update/delete on Models:
#[Table('users'), Auditable(except: ['password'])] class User extends Model { use HasAuditTrail; } // Generate the full module (migration + Model + DTOs + Controller + Routes) ./forge make:audit ./forge migrate
Each mutation records: auditable_type, auditable_id, action, old_values, new_values, user_id, ip_address, request_id, created_at.
Security Event Logger
Dedicated Monolog channel security → var/logs/security.log + stderr (K8s ready):
SecurityLogger::alert('auth.failed', ['email' => $email]); SecurityLogger::track('token.revoked', ['user_id' => 42]); SecurityLogger::critical('brute_force.detected', ['attempts' => 100]);
Events automatically logged by middleware:
auth.missing_token,auth.invalid_token,auth.revoked_token,auth.insufficient_rolerate_limit.exceeded
Each entry is enriched with: request_id, ip, uri, method, user, timestamp.
Encryption at Rest (AES-256-GCM)
Transparent encryption of sensitive fields in the database:
#[Table('users')] class User extends Model { use HasEncryptedFields; #[Encrypted] public string $phone; #[Encrypted] public string $address; } // Values are encrypted in DB (enc: prefix) and decrypted on read $user->phone = '+33612345678'; // stored: enc:base64(iv+tag+cipher) $user->save(); echo $user->phone; // +33612345678
# Generate: php -r "echo base64_encode(random_bytes(32));" ENCRYPTION_KEY=your_base64_32_byte_key
Token Hardening
Configurable TTL (SOC 2 compliant defaults):
JWT_ACCESS_TTL=900 # 15 minutes (default) JWT_REFRESH_TTL=86400 # 24 hours (default)
CORS Whitelist
Controlled origins in production:
CORS_ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com
- In dev (
APP_ENV=dev): everything allowed - In prod: only listed origins receive CORS headers
- Without config in prod: no origins allowed
Log Masking
Automatic masking of sensitive data in all logs:
password, token, secret, authorization, credit_card, ssn, api_key → ***
Configurable via LOG_MASK_FIELDS to add custom keys.
SOC 2 Summary
| Criterion | Control | Component |
|---|---|---|
| Traceability | Audit trail on Models + admin API | #[Auditable] + HasAuditTrail + make:audit |
| Incident detection | Security event logging | SecurityLogger |
| Confidentiality | Encryption at rest | #[Encrypted] + AES-256-GCM |
| Limited sessions | Token TTL 15min/24h | JWT_ACCESS_TTL / JWT_REFRESH_TTL |
| Access control | CORS whitelist | CORS_ALLOWED_ORIGINS |
| Log protection | Sensitive data masking | LogMaskingProcessor |
ISO 27001 Compliance
ISO 27001 Annex A technical controls built into the framework.
Password Policy (A.8.5)
Password strength validation:
$errors = PasswordPolicy::validate($password); // Checks: length (12+), uppercase, lowercase, digit, special, common words PasswordPolicy::assertValid($password); // RuntimeException if invalid $score = PasswordPolicy::strength($password); // 0-5
PASSWORD_MIN_LENGTH=12 # configurable
Account Lockout (A.8.5)
Automatic lockout after N failed attempts:
if (AccountLockout::isLocked($email)) { // account locked, return 429 } AccountLockout::recordFailure($email); // +1 attempt AccountLockout::reset($email); // after successful login
LOCKOUT_MAX_ATTEMPTS=5 # attempts before lockout LOCKOUT_DURATION=900 # 15 min lockout
Automatically integrated into TokenController — each login failure/success is logged in SecurityLogger.
IP Allowlist (A.8.5)
IP-based access restriction for sensitive routes:
$router->group([ 'middleware' => [[IpAllowlistMiddleware::class]], ], function ($router) { // admin routes only accessible from allowed IPs });
IP_ALLOWLIST=10.0.0.0/8,192.168.1.0/24,127.0.0.1
Supports exact IPs and CIDRs. Without configuration, the middleware allows everything (opt-in).
Log Integrity HMAC (A.8.15)
Each SecurityLogger entry includes a chained HMAC SHA-256:
{"event": "auth.failed", ..., "_hmac": "a1b2c3..."}
Each entry's HMAC depends on the previous HMAC — any deletion or modification of a line breaks the chain. Key derived from SECRET_KEY.
Data Retention (A.5.33)
Automatic purge of old audit logs:
./forge audit:purge # purge > 365 days (default) ./forge audit:purge --days=90 # purge > 90 days ./forge audit:purge --dry-run # preview without deleting
AUDIT_RETENTION_DAYS=365
Login Auditing (A.8.15)
All authentication events are logged in security.log:
| Event | When |
|---|---|
auth.login_success |
Successful login |
auth.login_failed |
Incorrect password or unknown user |
auth.account_locked |
Account locked (too many attempts) |
auth.missing_token |
Request without Bearer token |
auth.invalid_token |
Invalid or expired JWT |
auth.revoked_token |
Token revoked in database |
auth.insufficient_role |
Insufficient role |
rate_limit.exceeded |
Rate limit exceeded |
access.ip_blocked |
Unauthorized IP |
ISO 27001 Summary
| Annex A Control | Implementation |
|---|---|
| A.8.5 Secure authentication | PasswordPolicy + AccountLockout |
| A.8.5 Access control | IpAllowlistMiddleware |
| A.8.12 Data leak prevention | LogMaskingProcessor on all loggers |
| A.8.15 Integrated logging | HMAC chain + complete auth event auditing |
| A.5.33 Data retention | audit:purge command |
NF525 — Certified Invoicing
NF525 compliance module for invoicing software in France. Covers the 4 pillars: immutability, security, preservation, and archiving.
Setup
./forge make:nf525 # generates the full module (migration + 4 Models + DTOs + Controller + Routes) ./forge migrate # create the tables
Generated API (admin only):
GET /nf525/invoices List invoices
GET /nf525/invoices/{id} Details with lines
POST /nf525/invoices Create an invoice
POST /nf525/invoices/{id}/credit Create a credit note
GET /nf525/closings List closings
POST /nf525/closings Trigger a closing
GET /nf525/verify Verify the hash chain
GET /nf525/fec/export Export the FEC
GET /nf525/journal Event journal
GET /nf525/stats NF525 statistics
Immutable Invoice Model
#[Table('invoices'), Nf525(prefix: 'FA')] class Invoice extends Model { use HasNf525; } // Create an invoice — automatic numbering + hash chain $invoice = Invoice::create([ 'client_name' => 'SARL Dupont', 'total_ht' => 1000.00, 'tva' => 200.00, 'total_ttc' => 1200.00, ]); // number: FA-2026-000001 // hash: sha256(previous_hash + data) // Modify? FORBIDDEN $invoice->total_ht = 500; $invoice->save(); // RuntimeException: NF525 — modification forbidden // Delete? FORBIDDEN $invoice->delete(); // RuntimeException: NF525 — deletion forbidden // Correct? Via credit note $credit = $invoice->createCredit('Amount error'); // FA-2026-000002 (credit note, negative amounts, references the original invoice)
Signed Periodic Closings
./forge nf525:close --daily=2026-03-22 # daily closing ./forge nf525:close --monthly=2026-03 # monthly closing ./forge nf525:close --annual=2026 # annual closing
Each closing generates: HT/VAT/TTC totals, document count, cumulative grand total, HMAC-SHA256 hash chained with the previous closing.
FEC Export (Accounting Entries File)
./forge nf525:export --year=2026 # export in FEC format ./forge nf525:export --year=2026 --output=FEC.txt # to a specific file
Standardized TSV format: JournalCode, EcritureDate, CompteNum, CompteLib, Debit, Credit, etc.
Integrity Verification
./forge nf525:verify # verify the invoices table ./forge nf525:verify --table=invoices # specific table
Traverses the entire hash chain and detects anomalies (modification, deletion, insertion).
The 4 NF525 Pillars
| Pillar | Implementation |
|---|---|
| Immutability | HasNf525 trait — blocks update/delete, corrections via credit notes |
| Security | Sequential SHA-256 hash chain on each invoice |
| Preservation | ClosingService — signed closings (daily/monthly/annual) |
| Archiving | FecExporter — standardized FEC export for the tax authority |
Tables Generated by make:nf525
| Table | Purpose |
|---|---|
invoices |
Invoices with hash chain (number, hash, previous_hash) |
invoice_lines |
Invoice lines (description, quantity, price, VAT) |
nf525_closings |
Periodic closings (totals, HMAC hash, cumulative) |
nf525_journal |
NF525 event journal |
GDPR — Consent & Compliance
Complete GDPR consent management module with legal document versioning, traceability, and data subject rights.
Setup
./forge make:rgpd # generates the full module (migration + Models + DTOs + Controller + Routes) ./forge migrate # create the tables
11 files generated:
database/migrations/..._create_rgpd_tables.php
app/Models/ConsentObject.php # versioned legal documents
app/Models/UserConsent.php # consents + DPO functions
app/Dto/ConsentObject*.php # 4 DTOs
app/Dto/UserConsentRequest.php
app/Dto/RgpdStatsResponse.php
app/Controllers/ConsentController.php # 14 endpoints
app/Routes/consent.php # 4 route groups by role
Versioned Legal Documents
// Create a new version (automatic chaining) ConsentObject::createNewVersion('tos', 'Terms of Service v3', '<h1>...</h1>', isRequired: true); // object_version: 3, object_previous_version: id_v2 // Latest version by key ConsentObject::latestByKey('tos'); // latest active version ConsentObject::allLatest(); // all keys, latest version
User Consent
// Record a consent UserConsent::recordConsent($userId, $docId, status: true, objectVersion: 3, way: 'web'); // Check compliance UserConsent::hasAcceptedAll($userId); // true if all accepted (latest version) // History (GDPR right of access) UserConsent::userHistory($userId); // Export (GDPR right to portability) UserConsent::exportForUser($userId); // Withdrawal (GDPR right to erasure) UserConsent::withdrawAll($userId);
DPO Dashboard
// Compliance rate UserConsent::complianceRate(); // { total_active_users: 15000, compliant_users: 14950, compliance_rate: 99.67 } // Stats per document UserConsent::statsByDocument(); // [{ object_name: 'ToS', accepted: 15304, refused: 26 }, ...] // Non-compliant users UserConsent::nonCompliantUsers(limit: 50);
API (14 endpoints)
Public:
GET /consent/documents/{key}/latest Latest version of a document
Authenticated user (all roles):
POST /consent/me Give consent
GET /consent/me My consent status
DELETE /consent/me Withdraw my consents
Admin:
GET /consent/documents List documents
GET /consent/documents/{id} Document details
POST /consent/documents Create a new version
DPO / Admin:
GET /consent/dpo/dashboard Full dashboard
GET /consent/dpo/stats Stats per document
GET /consent/dpo/compliance Compliance rate
GET /consent/dpo/non-compliant Non-compliant users
GET /consent/dpo/users/{id}/history User history
GET /consent/dpo/users/{id}/export Portability export
DELETE /consent/dpo/users/{id}/consents Right to erasure
Tables
| Table | Purpose |
|---|---|
consent_objects |
Versioned legal documents (ToS, legal notices, privacy policy) |
user_consents |
User consents with traceability (status, method, version, dates) |
GDPR Rights Covered
| Right | Endpoint |
|---|---|
| Right of access (art. 15) | GET /consent/dpo/users/{id}/history |
| Right to portability (art. 20) | GET /consent/dpo/users/{id}/export |
| Right to object (art. 21) | DELETE /consent/me |
| Right to erasure (art. 17) | DELETE /consent/dpo/users/{id}/consents |
| Proof of consent (art. 7) | user_consents table (consent_way, timestamp, version) |
Redis Cache
// Simple API $user = Cache::remember("user:{$id}", 3600, fn() => User::find($id)); Cache::forget("user:{$id}"); // Tags (group invalidation) Cache::tags(['users'])->set("user:{$id}", $user, 3600); Cache::tags(['users'])->flush(); // invalidate the entire group
RedisConnection shared with RateLimiter, Scheduler, Queue and EventDispatcher.
PDF Generation (dompdf)
PDF generation from HTML/CSS via dompdf.
use Dompdf\Dompdf; use Dompdf\Options; $options = new Options(); $options->set('defaultFont', 'Helvetica'); $dompdf = new Dompdf($options); $dompdf->loadHtml('<h1>My Invoice</h1><p>HTML content...</p>'); $dompdf->setPaper('A4', 'portrait'); $dompdf->render(); header('Content-Type: application/pdf'); echo $dompdf->output();
Demo
GET /pdf/demo → sample invoice generated as PDF
Useful Options
| Option | Description |
|---|---|
defaultFont |
Default font (Helvetica, Courier, Times) |
isRemoteEnabled |
Load external images/CSS (false by default) |
setPaper('A4', 'landscape') |
Format and orientation |
$dompdf->output() |
Returns PDF content as string |
stream('file.pdf') |
Forces browser download |
Tips
- Use
<table>for layouts (better dompdf support than flexbox/grid) position: fixed; bottom: 30px;to stick a footer at the bottom of the page- Prefer standard fonts (Helvetica, Courier, Times) or embed custom fonts
Tutorials
12 tutorials with corrected exercises in Tutos/ — open Tutos/index.html in a browser.
Worker Mode:
| # | Title | Level |
|---|---|---|
| 01 | Lifecycle & Golden Rules | Beginner |
| 02 | Patterns & Architecture | Intermediate |
| 03 | Memory & Monitoring | Intermediate |
| 04 | Worker Exercises | All levels |
Framework:
| # | Title | Level |
|---|---|---|
| 05 | Routing & Middleware | Beginner |
| 06 | ORM & Query Builder | Intermediate |
| 07 | Auth, DI & Events | Intermediate |
| 08 | Framework Exercises | All levels |
Advanced Features:
| # | Title | Level |
|---|---|---|
| 09 | Profiler, Rate Limiting & Security | Intermediate |
| 10 | Migrations, Seeding & Cache | Beginner-Intermediate |
| 11 | Scheduler, Queue & Feature Flags | Intermediate |
| 12 | Notifications, SSE & OAuth | Intermediate-Advanced |
Configuration (.env)
# Database driver (pgsql | mysql | sqlite) DB_DRIVER=pgsql # PostgreSQL (if DB_DRIVER=pgsql) POSTGRES_HOST=localhost POSTGRES_PORT=5432 POSTGRES_DB=fennectra POSTGRES_USER=fennectra POSTGRES_PASSWORD=secret # MySQL (if DB_DRIVER=mysql) # MYSQL_HOST=localhost # MYSQL_PORT=3306 # MYSQL_DB=myapp # MYSQL_USER=root # MYSQL_PASSWORD=secret # SQLite (if DB_DRIVER=sqlite) # SQLITE_DB=var/database.sqlite # JWT SECRET_KEY=your_jwt_key_32_chars_minimum # Event Broker (sync | redis | database) EVENT_BROKER=sync # Redis (events, cache, rate limit, scheduler, queue) REDIS_HOST=127.0.0.1 REDIS_PORT=6379 REDIS_DB=0 REDIS_PREFIX=app:events: # Queue QUEUE_DRIVER=redis # Scheduler (inside the FrankenPHP worker) SCHEDULER_ENABLED=1 # Profiler PROFILER_ENABLED=1 # OAuth OAUTH_GOOGLE_CLIENT_ID= OAUTH_GOOGLE_CLIENT_SECRET= OAUTH_GOOGLE_REDIRECT_URI=http://localhost:8080/auth/google/callback # Notifications MAIL_HOST=smtp.example.com MAIL_PORT=587 MAIL_USER= MAIL_PASSWORD= MAIL_FROM=noreply@example.com SLACK_WEBHOOK_URL= # Deploy DEPLOY_REGISTRY=europe-west9-docker.pkg.dev/project/repo DEPLOY_IMAGE=php-app DEPLOY_NAMESPACE=default # SOC 2 — Security & Compliance # JWT_ACCESS_TTL=900 # JWT_REFRESH_TTL=86400 # ENCRYPTION_KEY= # CORS_ALLOWED_ORIGINS=https://app.example.com # LOG_MASK_FIELDS=custom_field # ISO 27001 — Access Controls # PASSWORD_MIN_LENGTH=12 # LOCKOUT_MAX_ATTEMPTS=5 # LOCKOUT_DURATION=900 # IP_ALLOWLIST=10.0.0.0/8,127.0.0.1 # AUDIT_RETENTION_DAYS=365 # Environment APP_ENV=dev
Tutorial: Full CRUD in 1 Command
./forge make:all Product --roles=admin,manager
5 files generated:
app/Models/Product.php ← ORM Model
app/Dto/ProductRequest.php ← input DTO (validation)
app/Dto/ProductResponse.php ← output DTO
app/Controllers/ProductController.php ← CRUD controller
app/Routes/product.php ← REST Routes
Created routes:
GET /product → index (paginated list)
GET /product/{id} → show (details)
POST /product → store (create)
PUT /product/{id} → update (modify)
DELETE /product/{id} → delete (delete)
Variants:
./forge make:all Invoice --connection=job --roles=admin # secondary database ./forge make:all Article --no-auth # without auth
Full Business Modules
The following make:* commands generate a complete module (migration + Models + DTOs + Controller + Routes with roles):
./forge make:rgpd # GDPR consent (14 endpoints, DPO dashboard) ./forge make:audit # SOC 2 audit trail (6 endpoints, admin) ./forge make:webhook # HMAC-SHA256 webhooks (10 endpoints, admin) ./forge make:nf525 # NF525 invoicing (10 endpoints, admin)
Each command is idempotent — re-running does not duplicate anything.
Dependencies: monolog/monolog · firebase/php-jwt · dompdf/dompdf · intervention/image · aws/aws-sdk-php · google/cloud-storage
PHP: >= 8.3 | Runtime: FrankenPHP Worker or PHP-FPM | License: Proprietary