-
Notifications
You must be signed in to change notification settings - Fork 1
Home
Welcome to the EntityForge wiki. Use the sidebar to navigate between topics or start with the overview below.
- Overview
- Installation
- Configuration
- Code Generation
- Migrations
- Multi-Tenancy
- Tenant Resolvers
- Tenant Lifecycle
- HTTP Layer
- Middleware
- Repository Layer
- DI Container
- Worker Mode
- CLI Reference
- Example Project
EntityForge is a configuration-driven, multi-tenant SaaS framework built in PHP 8.3+. It provides:
- JSON-driven code generation — entities, repositories, and migrations from a single schema file
- Two tenant isolation strategies — shared database or database-per-tenant
- Tenant lifecycle management — onboard, suspend, resume, offboard
- HTTP layer — router, middleware pipeline, immutable request/response
- DI container with reflection-based autowiring
- Batch-tracked migration system with dry-run support
Requirements: PHP 8.3+, MySQL, Composer
composer require entity-forge/entity-forgeEntityForge merges two YAML files at boot. saas.yaml sets defaults, application.yaml overrides them.
config/saas.yaml
tenancy:
enabled: true
strategy: shared
tenant_id_column: tenant_idconfig/application.yaml
application:
name: my-app
tenancy:
enabled: true
strategy: shared # shared | database
resolver: header # header | subdomain | jwt | session
header_key: X-Tenant-ID
database:
driver: mysql
host: 127.0.0.1
port: 3306
database: my_app
username: root
password: secretapplication.yaml wins on any conflicting key.
Define entities as JSON schemas in config/entities/.
config/entities/Order.json
{
"entity": "Order",
"fields": {
"amount": "float",
"status": "string",
"placed_at": "datetime"
},
"relations": {
"belongsTo": { "User": "user_id" }
},
"indexes": [
{ "columns": ["status"] },
{ "columns": ["user_id", "status"], "unique": true }
],
"timestamps": true
}Supported field types: int, string, float, bool, datetime
Generate a single entity:
php vendor/bin/ef generate Order --migrationGenerate all entities:
php vendor/bin/ef generate:all --migrationThis produces:
-
app/Entity/Order.php— typed PHP class with relation properties -
app/Repository/OrderRepository.php— extendsBaseRepository database/migrations/{timestamp}_create_orders_table.up.sqldatabase/migrations/{timestamp}_create_orders_table.down.sql
belongsTo emits a FK constraint in the migration and a typed nullable property on the entity:
public ?User $user = null; // loaded via user_idhasMany emits a typed array property:
/** @var OrderItem[] */
public array $orderItems = [];In shared strategy, unique indexes automatically include tenant_id as the first column to prevent cross-tenant conflicts:
UNIQUE INDEX uix_orders_tenant_id_user_id_status (tenant_id, user_id, status)Migration files live in database/migrations/. Every .up.sql must have a paired .down.sql.
database/migrations/
2026_01_01_000001_create_orders_table.up.sql
2026_01_01_000001_create_orders_table.down.sql
Run pending migrations:
php vendor/bin/ef migratePreview without executing:
php vendor/bin/ef migrate --dry-runRollback last batch:
php vendor/bin/ef migrate:rollbackRun against all tenant databases (database strategy only):
php vendor/bin/ef migrate:all-tenants
php vendor/bin/ef migrate:all-tenants --parallel 5
php vendor/bin/ef migrate:all-tenants --dry-runExecuted migrations are tracked in a migrations table (auto-created). Rollback undoes all migrations from the most recent batch.
The pivot is tenancy.strategy in config/application.yaml.
All tenants share one database. Every table has a tenant_id column. BaseRepository automatically appends WHERE tenant_id = :tenant_id to every query — no manual scoping needed.
my_app
├── tenants ← registry
└── orders ← tenant_id = 'acme' | 'beta' | ...
Each tenant gets a dedicated database named {base_db}_{tenantId}. TenantConnectionResolver switches the PDO connection on boot. No tenant_id column needed.
my_app ← main DB: tenants registry only
my_app_acme ← tenant DB: all application data
my_app_beta ← tenant DB: all application data
Configure via tenancy.resolver in application.yaml.
| Resolver | Config key | Behaviour |
|---|---|---|
header |
header_key (default: X-Tenant-ID) |
Reads named HTTP header |
subdomain |
subdomain_min_parts (default: 3) |
Extracts leading subdomain from host |
jwt |
jwt_public_key, jwt_algorithm, jwt_tenant_claim
|
Verifies Bearer JWT, extracts claim |
session |
session_key (default: tenant_id) |
Reads from $context['session'] or $_SESSION
|
Custom resolvers implement TenantResolverInterface and are registered in TenantResolverFactory.
TenantService is the canonical entry point. It is pre-registered as a singleton in the DI container after boot().
$svc = $app->getContainer()->make(TenantService::class);
$svc->onboard('acme', 'Acme Corp'); // provision + register
$svc->suspend('acme'); // block at boot
$svc->resume('acme'); // re-allow boot
$svc->offboard('acme'); // drop DB + remove recordVia CLI:
php vendor/bin/ef tenant:create acme --name="Acme Corp"Tenant ID rules: must match ^[a-zA-Z0-9_-]+$
Onboard is atomic: if migrations fail after the database was created, the database is dropped before re-throwing. No orphaned databases.
Suspended tenants are blocked at Application::boot() before any repository is instantiated.
$app = new Application(__DIR__ . '/config');
$app->boot([], false); // false = skip tenant resolution (CLI)
$app->boot(['headers' => ['X-Tenant-ID' => 'acme']], true); // HTTP$router = new Router();
$router->get('/orders', fn(Request $r): Response => ...);
$router->get('/orders/{id}', fn(Request $r): Response => ...);
$router->post('/orders', fn(Request $r): Response => ...);
$router->put('/orders/{id}', fn(Request $r): Response => ...);
$router->delete('/orders/{id}', fn(Request $r): Response => ...);
$response = $router->dispatch($request); // returns 404 / 405 automatically$request = Request::capture(); // reads $_SERVER, $_GET, $_POST / JSON body, getallheaders()
$request->header('X-Tenant-ID');
$request->query('page');
$request->body('name'); // form field
$request->json(); // parsed JSON body as array
$request->param('id'); // route parameter
$request->method(); // GET, POST, ...
$request->path(); // /orders/42
// Immutable attributes — set by middleware, read downstream
$request->withAttribute('user', $identity); // returns new instance
$request->getAttribute('user');
$request->getAttribute('user', 'guest'); // with default// Immutable builder
(new Response())->withJson(['id' => 1], 201)->withHeader('X-Id', $id)->send();
// Streaming
(new Response())->withStatus(200)->withHeader('Content-Type', 'text/csv')
->stream(function (): void {
echo "id,amount\n";
flush();
});Scaffold:
php vendor/bin/ef make:middleware TenantMiddleware
php vendor/bin/ef make:middleware AuthMiddleware --auth # AuthMiddlewareInterface stubImplement:
class TenantMiddleware implements MiddlewareInterface
{
public function handle(Request $request, callable $next): Response
{
$tenantId = $request->header('X-Tenant-ID');
if (!$tenantId) {
return (new Response())->withJson(['error' => 'Missing X-Tenant-ID header'], 400);
}
TenantContext::setTenantId($tenantId);
return $next($request);
}
}Wire into pipeline:
$pipeline = (new Pipeline())
->pipe(new AuthMiddleware())
->pipe(new TenantMiddleware());
$response = $pipeline->run(
Request::capture(),
fn(Request $r): Response => $router->dispatch($r)
);
$response->send();Middleware executes outermost-first. Each pipe() call returns a new immutable instance.
All generated repositories extend BaseRepository and inherit:
public function create(array $data): array
public function findAll(): array
public function findById(int $id): ?array
public function where(array $conditions): array
public function update(int $id, array $data): bool
public function delete(int $id): bool
public function beginTransaction(): void
public function commit(): void
public function rollback(): voidColumn names are validated against ^[a-zA-Z0-9_]+$ before SQL interpolation. InvalidArgumentException is thrown on violation.
The table name is derived from the class name (OrderRepository → orders). Override resolveTableName() in the subclass to customise it.
Never reuse a repository instance across tenant switches. Instantiate a fresh one after
TenantContext::setTenantId().
$container = $app->getContainer();
$container->bind(MyService::class, fn($c) => new MyService($c->make(Dep::class)));
$container->singleton(Cache::class, fn() => new RedisCache());
$container->instance(Config::class, $myConfig);
$service = $container->make(MyService::class); // auto-wires unregistered classes via reflectionTenantContext is a static singleton. In PHP-FPM it resets per process. In persistent runtimes (Swoole, RoadRunner, Octane) it persists between requests — wrap each request iteration:
RequestLifecycle::begin(); // clears TenantContext + flushes connection cache
// ... handle request ...
RequestLifecycle::end();TenantContext::setTenantId() throws LogicException if a tenant ID is already set — a forgotten begin() call surfaces as a hard error rather than a silent data leak.
| Command | Options | Description |
|---|---|---|
generate <Entity> |
--migration, --config
|
Generate entity + repository from JSON schema |
generate:all |
--migration, --config-dir
|
Generate all schemas in config/entities/
|
migrate |
--dry-run |
Run pending migrations |
migrate:rollback |
--dry-run |
Roll back the last batch |
migrate:all-tenants |
--dry-run, --parallel N
|
Run migrations on all active tenant DBs |
tenant:create <id> |
--name |
Onboard a new tenant |
make:controller <Name> |
--output |
Scaffold a controller with CRUD stubs |
make:middleware <Name> |
--auth, --output
|
Scaffold a middleware class |
ledger-api — a double-entry accounting REST API built end-to-end with EntityForge using the shared tenancy strategy.
Demonstrates: entity schemas, generate:all, migrate, tenant:create, make:controller, make:middleware, transaction wrapping, and automatic tenant scoping.
git clone https://github.com/vedavith/ledger-api.git
cd ledger-api
composer install
# edit config/application.yaml, then:
php vendor/bin/ef migrate
php vendor/bin/ef tenant:create acme --name="Acme Corp"
php -S localhost:8181 -t public