Skip to content
Vedavith Ravula edited this page Jun 27, 2026 · 1 revision

EntityForge Wiki

Welcome to the EntityForge wiki. Use the sidebar to navigate between topics or start with the overview below.


Table of Contents


Overview

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

Installation

Requirements: PHP 8.3+, MySQL, Composer

composer require entity-forge/entity-forge

Configuration

EntityForge 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_id

config/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: secret

application.yaml wins on any conflicting key.


Code Generation

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

Generate all entities:

php vendor/bin/ef generate:all --migration

This produces:

  • app/Entity/Order.php — typed PHP class with relation properties
  • app/Repository/OrderRepository.php — extends BaseRepository
  • database/migrations/{timestamp}_create_orders_table.up.sql
  • database/migrations/{timestamp}_create_orders_table.down.sql

Relations

belongsTo emits a FK constraint in the migration and a typed nullable property on the entity:

public ?User $user = null;  // loaded via user_id

hasMany emits a typed array property:

/** @var OrderItem[] */
public array $orderItems = [];

Indexes

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)

Migrations

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 migrate

Preview without executing:

php vendor/bin/ef migrate --dry-run

Rollback last batch:

php vendor/bin/ef migrate:rollback

Run 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-run

Executed migrations are tracked in a migrations table (auto-created). Rollback undoes all migrations from the most recent batch.


Multi-Tenancy

The pivot is tenancy.strategy in config/application.yaml.

Shared Strategy

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

Database Strategy

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

Tenant Resolvers

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.


Tenant Lifecycle

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 record

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


HTTP Layer

Booting

$app = new Application(__DIR__ . '/config');
$app->boot([], false);  // false = skip tenant resolution (CLI)
$app->boot(['headers' => ['X-Tenant-ID' => 'acme']], true);  // HTTP

Router

$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 = 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

Response

// 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();
    });

Middleware

Scaffold:

php vendor/bin/ef make:middleware TenantMiddleware
php vendor/bin/ef make:middleware AuthMiddleware --auth  # AuthMiddlewareInterface stub

Implement:

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.


Repository Layer

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(): void

Column 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 (OrderRepositoryorders). Override resolveTableName() in the subclass to customise it.

Never reuse a repository instance across tenant switches. Instantiate a fresh one after TenantContext::setTenantId().


DI Container

$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 reflection

Worker Mode

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


CLI Reference

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

Example Project

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