⚡ PHP 8.3+ · Open Source · MIT

The PHP Framework
Built for SaaS.

EntityForge is a configuration-driven, multi-tenant SaaS framework. JSON-driven code generation, two tenant isolation strategies, automated migrations, an HTTP routing layer with middleware pipeline, and a DI container — all wired through a single boot cycle.

Get Started View on GitHub
$ composer require entity-forge/entity-forge
PHP 8.3+ MySQL PDO 2 tenancy strategies 4 tenant resolvers FastRoute router DI container + autowire Worker-safe lifecycle MIT license

Everything a SaaS backend needs

EntityForge ships a complete, opinionated foundation. Every feature is documented, tested, and wired together through a single boot cycle.

🧩

JSON-Driven Code Generation

Define an entity in JSON — fields, relations, and indexes. One command generates a typed PHP entity class, a repository with full CRUD, and paired .up.sql / .down.sql migration files.

php bin/ef generate Order --migration
🏢

Two Tenancy Strategies

Shared database (scoped by tenant_id) or database-per-tenant. Declared in config — BaseRepository and TenantConnectionResolver adapt automatically.

shared · database
🔍

Four Tenant Resolvers

Resolve the active tenant from an HTTP header, subdomain, Bearer JWT, or session. Configured in one YAML key. Custom resolvers implement TenantResolverInterface.

header · subdomain · jwt · session
🔄

Tenant Lifecycle

TenantService handles onboard, suspend, resume, and offboard. Provisioning is atomic — a failed migration drops the half-created database before re-throwing.

onboard · suspend · resume · offboard
🌐

HTTP Layer

FastRoute-backed Router with {param} segments. Immutable Request / Response value objects. Streaming response support. Composable immutable middleware Pipeline.

Router · Pipeline · Request · Response
⚙️

DI Container

Supports bind(), singleton(), instance(), and reflection-based make() with auto-wiring. Core services pre-registered as singletons after boot.

bind · singleton · instance · autowire
🗄️

Migration System

Batch-tracked forward and rollback migrations. migrate:all-tenants runs across every active tenant DB with optional --parallel N workers and --dry-run support.

migrate · rollback · all-tenants · dry-run

Worker-Mode Safe

RequestLifecycle::begin() / end() clear TenantContext and flush the connection cache. setTenantId() throws if a tenant is already set — omitted lifecycle calls become hard errors.

Swoole · RoadRunner · Octane
🔒

Auth Integration Surface

EntityForge does not ship auth. AuthMiddlewareInterface is the designated hook — implement against any provider and attach the resolved identity via withAttribute('user', $id).

AuthMiddlewareInterface · make:middleware --auth

From config to running queries in minutes

Five steps. One JSON entity, one YAML config, three CLI commands.

1. config/application.yaml

tenancy:
  enabled: true
  strategy: shared # or: database
  resolver: header # or: subdomain|jwt|session
  header_key: X-Tenant-ID
database:
  driver: mysql
  host: 127.0.0.1
  database: entity_forge
  username: root
  password: root

2. config/entities/Order.json

{
  "entity": "Order",
  "fields": { "amount": "float", "status": "string" },
  "relations": { "belongsTo": { "User": "user_id" } },
  "indexes": [
    { "columns": ["status"] },
    { "columns": ["user_id", "status"], "unique": true }
  ]
}

3. Generate, migrate, onboard

# Generate entity + repo + migration files
$ php bin/ef generate Order --migration

# Apply migrations
$ php bin/ef migrate

# Onboard a tenant
$ php bin/ef tenant:create acme --name "Acme Corp"

4. Boot and query

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

$repo = new OrderRepository($app->getConfig());
$repo->create(['amount' => 99.00, 'status' => 'pending']);
print_r($repo->findAll());

Two strategies. Declared in config. Zero hand-wiring.

The pivot is tenancy.strategy in config/application.yaml. BaseRepository and TenantConnectionResolver read it and adapt automatically.

🟢 Shared Database Cost Efficient

All tenants share one database. Every table gets a tenant_id column. BaseRepository automatically appends WHERE tenant_id = :tenant_id to every query.

  • Automatic query scoping via BaseRepository
  • Lower infrastructure cost — one DB instance
  • Simpler ops — single backup, single pool
  • Best for: startups, SMB SaaS, dev/staging
entity_forge
 ├── tenants ← registry
 └── orders
      id · amount · status
      tenant_id ← isolation column
Configtenancy:
  strategy: shared

🔵 Database per Tenant Full Isolation

Each tenant gets a dedicated database named {base_db}_{tenantId}. TenantConnectionResolver switches the PDO connection on boot. No tenant_id column needed.

  • Full data isolation — physical separation
  • Independent backups per tenant
  • Enterprise compliance ready
  • Best for: enterprise SaaS, regulated industries
entity_forge ← main DB (registry only)
 └── tenants
entity_forge_acme
 └── orders (id · amount · status)
entity_forge_corp
 └── orders (id · amount · status)
Configtenancy:
  strategy: database

Four ways to identify the active tenant

header

Header Resolver

Reads a configurable HTTP header from the request context. Default: X-Tenant-ID.

header_key: X-Tenant-ID
subdomain

Subdomain Resolver

Extracts the leading subdomain from the host (acme.example.comacme). Set subdomain_min_parts: 2 for two-part hosts.

subdomain_min_parts: 3
jwt

JWT Resolver

Decodes and verifies a Bearer JWT from the Authorization header, then extracts a configurable claim. Supports RS256.

jwt_public_key: /path/to/pub.pem
jwt_algorithm: RS256
jwt_tenant_claim: tenant_id
session

Session Resolver

Reads tenant from $context['session'] when provided (useful in tests), otherwise falls back to PHP $_SESSION.

session_key: tenant_id

Onboard. Suspend. Resume. Offboard.

1

Onboard

Validates tenant ID format (^[a-zA-Z0-9_-]+$), provisions the database, runs all migrations, and registers the tenant — atomically. On failure, drops the partially-created database before re-throwing.

2

Suspend

Sets status = 'suspended'. Blocked at Application::boot() before any repository is instantiated. Skipped automatically by migrate:all-tenants.

3

Resume

Sets status = 'active'. The tenant can boot normally again. Apply any pending migrations via migrate:all-tenants.

4

Offboard

Drops the tenant database (database strategy) via TenantProvisioner::drop() and removes the tenant record from the registry on both strategies.

Router · Pipeline · Request · Response

A complete, immutable HTTP stack built on FastRoute. No additional framework required.

Router
Pipeline
Request
Response
Auth Integration
// FastRoute-backed. {name} parameters. 404/405 automatic.
$router = new Router();
$router->get('/orders', fn(Request $req): Response => ...);
$router->get('/orders/{id}', fn(Request $req): Response => ...$req->param('id')...);
$router->post('/orders', fn(Request $req): Response => ...);
$router->put('/orders/{id}', fn(Request $req): Response => ...);
$router->delete('/orders/{id}', fn(Request $req): Response => ...);

$response = $router->dispatch($request); // 404 or 405 auto
Register exact paths before parameterised ones — routes match in registration order.
// Immutable chain. Each pipe() returns a new instance. Outermost-first.
$pipeline = (new Pipeline())
  ->pipe(new LoggingMiddleware())
  ->pipe(new AuthMiddleware())
  ->pipe(new TenantMiddleware());

$response = $pipeline->run(
  $request,
  fn(Request $req): Response => $router->dispatch($req)
);
$response->send();
$request = Request::capture(); // from $_SERVER, $_GET, $_POST, getallheaders()

$request->header('X-Tenant-ID');
$request->query('page');
$request->body('amount');
$request->param('id'); // route parameter from Router
$request->params(); // all route parameters

// Immutable attributes — set by middleware, read by handlers
$request->withAttribute('user', $identity); // new instance
$request->getAttribute('user'); // null if absent
$request->getAttribute('user', 'guest'); // with default
// 1. Immutable builder
(new Response())->withJson(['id' => 1], 201)->withHeader('X-Id', $id)->send();

// 2. Streaming — caller controls chunk output
(new Response())
  ->withStatus(200)->withHeader('Content-Type', 'text/csv')
  ->stream(function(): void {
    echo "id,amount\n"; flush();
  });

// 3. Legacy direct-echo (backwards compatibility)
(new Response())->json(['ok' => true], 200);
// 1. Scaffold
$ php bin/ef make:middleware FirebaseAuthMiddleware --auth

// 2. Implement against your provider
public function handle(Request $req, callable $next): Response {
  $user = $this->auth->verify($req->header('Authorization'));
  return $next($req->withAttribute('user', $user));
}

// 3. Read in handlers
$router->get('/me', fn($req) =>
  (new Response())->withJson($req->getAttribute('user'))
);
Auth runs before tenant resolution if the tenant ID is in the token. Reverse the order if the tenant is resolved from the URL.

8 commands. Full control.

Every major operation has a dedicated php bin/ef command.

CommandOptionsDescription
generate <Entity>--migrationGenerate entity + repository from JSON schema. Add --migration for paired .up.sql / .down.sql files.
generate:all--config-dirGenerate all schemas in config/entities/. Monotonically ordered migration timestamps. Run from project root.
migrate--dry-runRun pending migrations on the main database. Skips already-executed files via the migrations tracking table.
migrate:rollback--dry-runRoll back the last migration batch. Requires paired .down.sql — missing file aborts with exception.
migrate:all-tenants--tenant, --parallel N, --dry-runRun pending migrations on every active tenant DB. Up to N concurrent workers via symfony/process. Suspended tenants skipped.
tenant:create <id>--nameOnboard a new tenant. Atomic: validates ID, provisions DB, runs migrations, registers in tenants table.
make:middleware <Name>--auth, --outputScaffold a middleware class. Use --auth for an AuthMiddlewareInterface stub with annotated steps.
make:controller <Name>--outputScaffold a controller class with CRUD method stubs.

See EntityForge in action

A real-world project built end-to-end with EntityForge — from entity schemas to running API.

📒 ledger-api
Double-entry accounting REST API
View on GitHub →

A complete multi-tenant accounting API with Accounts, JournalEntries, and LedgerLines. Built using the shared tenancy strategy — all tenants isolated by tenant_id with automatic query scoping.

shared strategy header resolver generate:all make:controller make:middleware transactions double-entry validation
  • Entity schemas for Account, JournalEntry, and LedgerLine with FK relations and tenant-scoped unique indexes
  • Migrations generated with generate:all --migration and applied with migrate
  • Tenants created with tenant:create acme --name="Acme Corp"
  • Controllers scaffolded with make:controller — CRUD using $request->json() and BaseRepository
  • Middleware scaffolded with make:middleware — reads X-Tenant-ID header and calls TenantContext::setTenantId()
  • Journal entries wrapped in beginTransaction() / commit() — failed ledger lines roll back the entire entry
# Clone and run in under 2 minutes
$ git clone https://github.com/vedavith/ledger-api.git && cd ledger-api
$ composer install
# edit config/application.yaml with your DB credentials, then:
$ php vendor/bin/ef migrate
$ php vendor/bin/ef tenant:create acme --name="Acme Corp"
$ php -S localhost:8181 -t public

One boot cycle. Clear layers.

Every module has a single responsibility. The boot sequence is deterministic and transparent.

Boot Sequence

Application::boot($context, $resolveTenant)
 │
 ├─ ConfigLoader::loadMultiple()
 │    saas.yaml → application.yaml (wins)
 │
 ├─ ConfigValidator::validate()
 │    requires: tenancy.enabled, database.*
 │
 ├─ CoreSchemaManager::ensure()
 │    CREATE TABLE IF NOT EXISTS tenants
 │
 ├─ Container::registerBindings()
 │    singletons: TenantRepo, Provisioner, Service
 │
 └─ if $resolveTenant && tenancy.enabled:
      TenantResolverFactory → resolve()
      TenantContext::setTenantId()
      ↳ LogicException if already set
      if database strategy:
        throws if not found or suspended

Source Tree

src/
├── Config/ — YAML loader + validator
├── Console/
│   ├── Generate, GenerateAll, MigrateAllTenants
│   ├── MakeMiddleware, MakeController
│   └── Migrate, Rollback, TenantCreate
├── Core/
│   ├── Application.php — boot entry point
│   ├── Container.php — DI + autowire
│   └── CoreSchemaManager.php
├── Database/
│   ├── Connection.php — PDO wrapper
│   └── MigrationRunner.php
├── Generator/
│   ├── Builder/ — Entity, Repository, Migration
│   └── Schema/ + Writer/
├── Http/
│   ├── Request, Response, Router, Pipeline
│   └── Middleware/ — MiddlewareInterface, AuthMiddlewareInterface
├── Repository/ — BaseRepository
└── Tenant/
    ├── TenantContext, TenantGuard, RequestLifecycle
    ├── TenantConnectionResolver, TenantResolverFactory
    ├── TenantService, TenantProvisioner, TenantRepository
    └── Resolver/ — Header, Subdomain, JWT, Session

Six rules. Never break them.

1

Tenant isolation is never optional

Every query decision must account for both strategies. There is no "skip scoping" path in BaseRepository.

2

Main DB ↔ tenant DB boundary is sacred

The tenants registry lives only in the main DB. Application data lives only in tenant DBs.

3

Repository instances are not reusable across tenant switches

Instantiate a fresh repository after TenantContext::setTenantId().

4

Idempotent infrastructure

CREATE TABLE IF NOT EXISTS, batch-tracked migrations, CoreSchemaManager — follow this pattern for all schema management.

5

Explicit over implicit

Tenant resolution, connection selection, and scope injection are always conscious calls — nothing happens silently.

6

Configuration drives generation

New entity types go through the generator pipeline, not handwritten files. The JSON schema is the single source of truth.

What's shipped. What's next.

EntityForge is a WIP. The core is complete and tested.

Multi-tenant architecture (shared + database-per-tenant)

Both strategies fully implemented. TenantConnectionResolver, TenantContext, BaseRepository scoping — all production-ready.

Shipped

JSON-based code generation — entities, repositories, migrations

EntityBuilder generates typed properties for belongsTo and hasMany. MigrationBuilder emits FK constraints, INDEX and UNIQUE INDEX clauses.

Shipped

Migration system — forward, rollback, dry-run, batch tracking

MigrationRunner with batch-tracked rollback. dry-run across all migration commands. migrate:all-tenants with --parallel N and suspended-tenant skipping.

Shipped

Tenant lifecycle — onboard, suspend, resume, offboard

TenantService is the canonical entry point. Atomic provisioning with rollback-on-failure. Suspended tenants blocked at boot.

Shipped

HTTP layer — Router, Pipeline, Request, Response

FastRoute-backed router with {param} segments. Immutable middleware chain. Three response modes. AuthMiddlewareInterface integration surface.

Shipped

DI container with reflection-based autowiring

bind, singleton, instance, and make with auto-wiring. Core services pre-registered as singletons after boot.

Shipped

Four tenant resolvers — header, subdomain, JWT, session

All four implemented and configurable via YAML. JWT resolver verifies Bearer tokens. Session resolver supports injected context for testability.

Shipped

Worker-mode safety — RequestLifecycle, static lifecycle enforcement

RequestLifecycle::begin()/end() for Swoole, RoadRunner, Octane. setTenantId() throws LogicException if already set.

Shipped

Official Packagist release

EntityForge is published on Packagist. Install via composer require entity-forge/entity-forge.

Shipped

Example application — ledger-api

A complete double-entry accounting REST API built end-to-end with EntityForge. Demonstrates entity schemas, migrations, tenant onboarding, scaffolded controllers and middleware, transaction wrapping, and multi-tenant query scoping. github.com/vedavith/ledger-api →

Shipped

Ready to build your SaaS?

EntityForge gives you multi-tenancy, code generation, migrations, HTTP routing, and a DI container — all wired through a single boot cycle. Now on Packagist.

$ composer require entity-forge/entity-forge

Built by

Vedavith Ravula

Vedavith Ravula

Creator of EntityForge — building open-source PHP tooling for the SaaS ecosystem. Contributions, feedback, and collaboration welcome.

GitHub LinkedIn