โšก 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.com โ†’ acme). 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.

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 & library usage

A reference SaaS application demonstrating real-world EntityForge usage โ€” entity definitions, tenant onboarding, HTTP routing, middleware pipeline, and repository patterns โ€” to help developers get started quickly.

Planned

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