PHPackages                             entity-forge/entity-forge - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Framework](/categories/framework)
4. /
5. entity-forge/entity-forge

ActiveLibrary[Framework](/categories/framework)

entity-forge/entity-forge
=========================

A configuration-driven PHP framework for generating entity models and building multi-tenant SaaS applications.

v2.2.1(1mo ago)291MITPHPPHP ^8.3CI passing

Since Jun 15Pushed 1mo ago2 watchersCompare

[ Source](https://github.com/vedavith/Entity-Forge)[ Packagist](https://packagist.org/packages/entity-forge/entity-forge)[ Docs](https://entityforge.dev)[ RSS](/packages/entity-forge-entity-forge/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (5)Dependencies (16)Versions (9)Used By (0)

EntityForge
===========

[](#entityforge)

[![CI](https://github.com/vedavith/Entity-Forge/actions/workflows/php.yml/badge.svg)](https://github.com/vedavith/Entity-Forge/actions/workflows/php.yml)[![codecov](https://camo.githubusercontent.com/8306bd07aa7deb6110f1792d7848325de7ac1943881ff993383a711cd2192ab1/68747470733a2f2f636f6465636f762e696f2f67682f76656461766974682f456e746974792d466f7267652f67726170682f62616467652e737667)](https://codecov.io/gh/vedavith/Entity-Forge)

**EntityForge** is a configuration-driven, multi-tenant SaaS framework built in PHP 8.3+.

It provides everything needed to build a scalable SaaS backend: JSON-driven code generation, two tenant isolation strategies, automated migrations, an HTTP routing layer with middleware pipeline, and a dependency injection container — all wired together through a single boot cycle.

---

Features
--------

[](#features)

- **Code generation** from JSON schemas — entities, repositories, and migrations in one command; supports field types, foreign key relations, and composite indexes
- **Two tenancy strategies** — shared database (scoped by `tenant_id`) or database-per-tenant
- **Tenant lifecycle management** — onboard, suspend, resume, offboard via `TenantService`
- **HTTP layer** — `Router` (backed by FastRoute), immutable `Pipeline`, immutable `Request`/`Response` value objects
- **Middleware pipeline** — composable, immutable, executed outermost-first
- **DI container** — bind, singleton, instance, and reflection-based autowire
- **Migration system** — forward and rollback with dry-run, batch-tracked, tenant-aware
- **Multi-worker safe** — `RequestLifecycle` prevents tenant state leaking between requests in long-lived workers (Swoole, RoadRunner, Octane)

---

Example Projects
----------------

[](#example-projects)

### [ledger-api](https://github.com/vedavith/ledger-api)

[](#ledger-api)

A double-entry accounting REST API that demonstrates a complete EntityForge project using the `shared` tenancy strategy.

**What it covers:**

- Entity schemas for `Account`, `JournalEntry`, and `LedgerLine` with foreign key relations and tenant-scoped unique indexes
- Migrations generated with `generate:all --migration` and run with `migrate`
- Tenants created with `tenant:create`
- Controllers scaffolded with `make:controller` and filled in with CRUD logic using `$request->json()` and `BaseRepository`
- Middleware scaffolded with `make:middleware` — `TenantMiddleware` reads the `X-Tenant-ID` header and calls `TenantContext::setTenantId()`
- Transaction wrapping in `JournalController` — if any ledger line insert fails, the journal entry is rolled back atomically
- Routes wired in `public/index.php` via `Router` and `Pipeline`

```
git clone https://github.com/vedavith/ledger-api.git
cd ledger-api
composer install
# create database, 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
```

---

Requirements
------------

[](#requirements)

- PHP 8.3+
- MySQL (PDO)
- Composer

---

Installation
------------

[](#installation)

```
composer require entity-forge/entity-forge
```

---

Quick Start
-----------

[](#quick-start)

### 1. Configure tenancy

[](#1-configure-tenancy)

`config/application.yaml`:

```
tenancy:
  enabled: true
  strategy: shared        # or: database
  resolver: header        # or: subdomain | jwt
  header_key: X-Tenant-ID
  # resolver: jwt
  # jwt_public_key: /path/to/public.pem
  # jwt_algorithm: RS256
  # jwt_tenant_claim: tenant_id

database:
  driver: mysql
  host: 127.0.0.1
  port: 3306
  database: entity_forge
  username: root
  password: root
```

### 2. Define an entity

[](#2-define-an-entity)

`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 }
  ]
}
```

`relations.belongsTo` emits a `CONSTRAINT fk_… FOREIGN KEY` clause in the migration and a typed nullable property on the entity class. `indexes` emits `INDEX` or `UNIQUE INDEX` clauses. Both sections are optional.

The generated `app/Entity/Order.php` will contain:

```
use App\Entity\User;

class Order
{
    // ...fields...

    /** Loaded via user_id */
    public ?User $user = null;
}
```

Populate the property after loading related data — the repository handles the query, the entity holds the result.

### 3. Generate and migrate

[](#3-generate-and-migrate)

```
php bin/ef generate Order --migration
php bin/ef migrate
```

### 4. Onboard a tenant (database strategy)

[](#4-onboard-a-tenant-database-strategy)

```
php bin/ef tenant:create acme --name "Acme Corp"
```

Or programmatically — this also registers the tenant in the `tenants` table:

```
$app->getContainer()->make(TenantService::class)->onboard('acme', 'Acme Corp');
```

### 5. Boot and query

[](#5-boot-and-query)

```
use EntityForge\Core\Application;
use App\Repository\OrderRepository;

$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());
```

### 6. Handle an HTTP request

[](#6-handle-an-http-request)

```
use EntityForge\Http\{Router, Pipeline, Request, Response};

$router = new Router();
$router->get('/orders',       fn(Request $req): Response => (new Response())->withJson($repo->findAll()));
$router->get('/orders/{id}',  fn(Request $req): Response => (new Response())->withJson($repo->findById((int) $req->param('id'))));
$router->post('/orders',      fn(Request $req): Response => (new Response())->withJson($repo->create(['amount' => $req->body('amount'), 'status' => 'pending']), 201));

$pipeline = (new Pipeline())
    ->pipe(new AuthMiddleware())
    ->pipe(new TenantMiddleware());

$response = $pipeline->run(Request::capture(), fn(Request $req): Response => $router->dispatch($req));
$response->send();
```

---

CLI Reference
-------------

[](#cli-reference)

CommandOptionsDescription`generate ``--migration`Generate entity + repository from JSON schema`generate:all``--config-dir`Generate all schemas in `config/entities/``migrate``--dry-run`Run pending migrations on the main database`migrate:rollback``--dry-run`Roll back the last migration batch`migrate:all-tenants``--tenant `, `--parallel N`, `--dry-run`Run pending migrations on every active tenant DB`tenant:create ``--name`Onboard a new tenant`make:middleware ``--auth`, `--output`Scaffold a middleware class (use `--auth` for `AuthMiddlewareInterface` stub)`make:controller ``--output`Scaffold a controller class with CRUD stubs`field:add   ``--tenant`, `--label`, `--required`Register a custom field definition for a tenant`field:list ``--tenant`List all custom fields registered for a tenant entity`field:remove ``--tenant`Remove a custom field definition by ID`generate:all` uses a single `EntityGenerator` instance to guarantee monotonically ordered migration timestamps within a session.

`migrate:all-tenants` spawns up to `--parallel N` (default 5) concurrent worker processes via `symfony/process`. Suspended tenants are skipped. A per-tenant failure is reported but does not stop other tenants from being migrated.

---

Tenancy Strategies
------------------

[](#tenancy-strategies)

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

### `shared` — single database, tenant\_id column

[](#shared--single-database-tenant_id-column)

Every table has a `tenant_id` column. `BaseRepository` automatically appends `WHERE tenant_id = :tenant_id` (and `AND tenant_id = :tenant_id` on writes) to every query.

```
entity_forge
  ├── tenants          ← registry
  └── orders           ← tenant_id = 'acme' | 'corp' | ...

```

### `database` — one database per tenant

[](#database--one-database-per-tenant)

Each tenant gets its own database named `{base_db}_{tenantId}`. `TenantConnectionResolver` selects the correct connection. No `tenant_id` column needed.

```
entity_forge            ← main DB: tenants registry only
entity_forge_acme       ← tenant DB: all application data
entity_forge_corp       ← tenant DB: all application data

```

---

Tenant Resolution
-----------------

[](#tenant-resolution)

Configure via `tenancy.resolver` in `application.yaml`:

ResolverConfig keysHow it works`header``header_key` (default: `X-Tenant-ID`)Reads the named HTTP header from the request context`subdomain``subdomain_depth`, `subdomain_min_parts` (default: 3)Extracts the leading subdomain from the `host` context key (`acme.example.com` → `acme`). Set `subdomain_min_parts: 2` for two-part hosts like `acme.io``jwt``jwt_public_key`, `jwt_algorithm` (default: `RS256`), `jwt_tenant_claim` (default: `tenant_id`)Decodes and verifies a Bearer JWT from the `Authorization` header, then extracts the named claim`session``session_key` (default: `tenant_id`)Reads the tenant ID from `$context['session']` (injected) or falls back to PHP `$_SESSION`Add custom resolvers by implementing `TenantResolverInterface` and registering them in `TenantResolverFactory`.

---

Tenant Lifecycle
----------------

[](#tenant-lifecycle)

`TenantService` is the canonical entry point for tenant operations. It is pre-registered as a singleton in the DI container after `boot()`.

```
$svc = $app->getContainer()->make(TenantService::class);

$svc->onboard('acme', 'Acme Corp');  // validates ID, provisions DB, runs migrations, registers tenant
$svc->suspend('acme');               // sets status = 'suspended'; blocks future boots
$svc->resume('acme');                // sets status = 'active'
$svc->offboard('acme');              // drops DB (database strategy) + removes tenant record
```

`onboard()` rejects tenant IDs that do not match `^[a-zA-Z0-9_-]+$`.

`TenantProvisioner::create()` rolls back atomically on failure — 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()` — `assertTenantActive()` throws before any repository is instantiated.

---

HTTP Layer
----------

[](#http-layer)

### Router

[](#router)

Backed by `nikic/fast-route`. Supports `{name}` parameter segments. Register exact paths before parameterised ones — routes match in registration order.

```
$router = new Router();
$router->get('/users',         fn(Request $req): Response => ...);
$router->get('/users/{id}',    fn(Request $req): Response => ... $req->param('id') ...);
$router->post('/users',        fn(Request $req): Response => ...);
$router->put('/users/{id}',    fn(Request $req): Response => ...);
$router->delete('/users/{id}', fn(Request $req): Response => ...);

$response = $router->dispatch($request);  // returns 404 or 405 automatically
```

### Request

[](#request)

Immutable value object. Constructed directly or captured from PHP superglobals:

```
$request = new Request(headers: [...], query: [...], body: [...], method: 'POST', path: '/users');
$request = Request::capture();  // reads $_SERVER, $_GET, $_POST, getallheaders()

$request->header('X-Tenant-ID');
$request->query('page');
$request->body('name');
$request->method();      // 'GET', 'POST', ...
$request->path();        // '/users/42'
$request->param('id');   // route parameter injected by Router
$request->params();      // all route parameters as array

// Arbitrary attributes — set by middleware, read by handlers
$request->withAttribute('user', $resolvedUser);   // returns new instance
$request->getAttribute('user');                   // returns value or null
$request->getAttribute('user', 'guest');          // returns default if missing
```

### Response

[](#response)

Three output modes:

```
// Immutable builder — standard pipeline path
$response = (new Response())
    ->withJson(['id' => 1], 201)
    ->withHeader('X-Request-Id', $id);
$response->send();   // http_response_code + headers + echo body

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

// Legacy direct-echo (kept for backwards compatibility)
(new Response())->json(['ok' => true], 200);
```

### Middleware Pipeline

[](#middleware-pipeline)

Immutable chain — each `pipe()` call returns a new instance. Executed outermost-first.

```
interface MiddlewareInterface {
    public function handle(Request $request, callable $next): Response;
}

$pipeline = (new Pipeline())
    ->pipe(new LoggingMiddleware())
    ->pipe(new AuthMiddleware());

$response = $pipeline->run($request, fn(Request $req): Response => $router->dispatch($req));
```

---

DI Container
------------

[](#di-container)

```
$container = $app->getContainer();

$container->bind(MyService::class, fn($c) => new MyService($c->make(Dep::class)));  // new instance per call
$container->singleton(Cache::class, fn() => new RedisCache());                      // shared instance
$container->instance(Config::class, $myConfig);                                      // pre-built object

$service = $container->make(MyService::class);
```

Unregistered classes are resolved automatically via reflection. All constructor parameters must be typed class parameters or have default values; otherwise `make()` throws `InvalidArgumentException`.

---

Repository Layer
----------------

[](#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 passed to `create()`, `where()`, and `update()` 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`). Set `$this->table` in the subclass constructor to override.

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

---

Migration System
----------------

[](#migration-system)

```
database/migrations/
  20260101_000001_create_orders_table.up.sql
  20260101_000001_create_orders_table.down.sql

```

Every `.up.sql` must have a paired `.down.sql`. A missing down file aborts rollback with an exception. `MigrationRunner` skips already-executed files based on the `migrations` tracking table (auto-created).

Both `run()` and `rollback()` accept a dry-run mode — all writes are skipped and output is prefixed with `[DRY RUN]`:

```
php bin/ef migrate --dry-run
php bin/ef migrate:rollback --dry-run
```

---

Long-Lived Workers
------------------

[](#long-lived-workers)

`TenantContext` is a static singleton. In PHP-FPM static state resets per process. In persistent runtimes (Swoole, RoadRunner, Octane), it persists between requests.

`TenantContext::setTenantId()` throws `LogicException` if a tenant ID is already set — a forgotten `RequestLifecycle::begin()` call surfaces as a hard error rather than a silent data leak.

Wrap each request loop iteration:

```
RequestLifecycle::begin();   // clears TenantContext + flushes connection cache

// ... handle request ...

RequestLifecycle::end();     // clears again on teardown
```

---

Boot Sequence
-------------

[](#boot-sequence)

```
Application::boot($context, $resolveTenant)
  │
  ├── ConfigLoader::loadMultiple([saas.yaml, application.yaml])
  │     array_replace_recursive — application.yaml wins on conflicts
  │
  ├── ConfigValidator::validate()
  │     requires: tenancy.enabled, database.{driver,host,port,database,username,password}
  │
  ├── CoreSchemaManager::ensure()
  │     CREATE TABLE IF NOT EXISTS tenants  (always, both strategies)
  │
  ├── Container::registerBindings()
  │     singletons: TenantRepository, TenantProvisioner, TenantService
  │
  └── if $resolveTenant && tenancy.enabled:
        TenantResolverFactory::create() → resolver.resolve($context)
        TenantContext::setTenantId()    ← throws LogicException if already set
        if strategy === database:
          TenantRepository::findByTenantId() — throws if not found or suspended

```

Pass `false` as the second argument to skip tenant resolution — required for CLI commands that run before a tenant is set.

---

Key Invariants
--------------

[](#key-invariants)

1. **Tenant isolation is never optional.** Every query decision must account for both strategies.
2. **Main DB ↔ tenant DB boundary is sacred.** The `tenants` registry lives only in the main DB. Application data lives only in tenant DBs (or is scoped by `tenant_id` in shared mode).
3. **Repository instances are not reusable across tenant switches.** Instantiate fresh 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.
6. **Configuration drives generation.** New entity types go through the generator pipeline, not handwritten files.

---

Running Tests
-------------

[](#running-tests)

```
composer install
vendor/bin/phpunit
vendor/bin/phpunit tests/Path/To/SomeTest.php   # single file
vendor/bin/phpstan analyse                       # static analysis — project runs clean at level 8
```

---

Integrating Auth
----------------

[](#integrating-auth)

EntityForge does not ship an auth implementation — authentication is handled by your chosen provider (Firebase Auth, Auth0, a custom JWT stack, etc.). The framework provides the integration surface.

### 1. Scaffold an auth middleware

[](#1-scaffold-an-auth-middleware)

```
php bin/ef make:middleware FirebaseAuthMiddleware --auth
```

This generates `app/Http/Middleware/FirebaseAuthMiddleware.php` implementing `AuthMiddlewareInterface` with annotated steps:

```
public function handle(Request $request, callable $next): Response
{
    // 1. Extract credentials (e.g. Bearer token)
    $token = $request->header('Authorization');

    // 2. Verify with your provider
    $user = $this->firebaseAuth->verifyIdToken($token);

    // 3. Attach the identity and pass downstream
    return $next($request->withAttribute('user', $user));

    // 4. On failure, return early
    // return (new Response())->withStatus(401)->withJson(['error' => 'Unauthorized']);
}
```

### 2. Read the identity in handlers

[](#2-read-the-identity-in-handlers)

```
$router->get('/me', function (Request $req): Response {
    $user = $req->getAttribute('user');   // set by auth middleware upstream
    return (new Response())->withJson($user);
});
```

### 3. Wire into the pipeline

[](#3-wire-into-the-pipeline)

```
$pipeline = (new Pipeline())
    ->pipe(new FirebaseAuthMiddleware($firebaseAuth))
    ->pipe(new TenantMiddleware());

$response = $pipeline->run(Request::capture(), fn($req) => $router->dispatch($req));
```

Auth runs before tenant resolution if the tenant ID is embedded in the token. Reverse the order if the tenant is resolved from the URL and auth is tenant-scoped.

---

Dynamic Schema Extension
------------------------

[](#dynamic-schema-extension)

Tenants can define their own custom fields on any opted-in entity without touching migrations. Add `"metadata": true` to the entity schema:

```
{
  "entity": "Account",
  "metadata": true,
  "fields": { "name": "string", "email": "string" }
}
```

This emits a `metadata JSON NULL` column in the migration and `getMeta`/`setMeta`/`getAllMeta` methods on the repository. A `tenant_fields` table (auto-created by `CoreSchemaManager`) stores each tenant's field definitions.

```
# Register a custom field for tenant "beta" on the accounts entity
php bin/ef field:add accounts vat_number string --tenant=beta --label="VAT Number"

# List all custom fields for a tenant entity
php bin/ef field:list accounts --tenant=beta

# Remove a field by ID (shown in field:list output)
php bin/ef field:remove 1 --tenant=beta
```

Read and write custom field values via the repository:

```
$repo = new AccountRepository($app->getConfig());

$repo->setMeta(42, 'vat_number', 'GB123456789');
$vatNumber = $repo->getMeta(42, 'vat_number');
$allMeta   = $repo->getAllMeta(42);  // ['vat_number' => 'GB123456789']
```

Works identically with both `shared` and `database` tenancy strategies.

---

Roadmap
-------

[](#roadmap)

- Official Packagist release

---

Contributing
------------

[](#contributing)

Contributions are welcome. Open an issue or pull request on GitHub.

---

License
-------

[](#license)

MIT

###  Health Score

43

—

FairBetter than 89% of packages

Maintenance91

Actively maintained with recent releases

Popularity9

Limited adoption so far

Community9

Small or concentrated contributor base

Maturity54

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~4 days

Total

7

Last Release

42d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/18259805?v=4)[Vedavith Ravula](/maintainers/vedavith)[@vedavith](https://github.com/vedavith)

---

Top Contributors

[![vedavith](https://avatars.githubusercontent.com/u/18259805?v=4)](https://github.com/vedavith "vedavith (100 commits)")

---

Tags

code-generationdatabase-per-tenantmigrationsmulti-tenantmultitenancyormphpphp83saassymfony-consolephpframeworkormpdomigrationsentityrepositorysaascode-generationmulti-tenantmultitenancysymfony-consoledatabase-per-tenanttenant-isolation

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Type Coverage Yes

### Embed Badge

![Health badge](/badges/entity-forge-entity-forge/health.svg)

```
[![Health](https://phpackages.com/badges/entity-forge-entity-forge/health.svg)](https://phpackages.com/packages/entity-forge-entity-forge)
```

###  Alternatives

[typo3/cms

TYPO3 CMS is a free open source Content Management Framework initially created by Kasper Skaarhoj and licensed under GNU/GPL.

1.2k1.9M122](/packages/typo3-cms)[drupal/core

Drupal is an open source content management platform powering millions of websites and applications.

19467.3M1.9k](/packages/drupal-core)[shopware/platform

The Shopware e-commerce core

3.4k1.5M3](/packages/shopware-platform)[typo3/cms-core

TYPO3 CMS Core

3313.6M5.6k](/packages/typo3-cms-core)[drupal/core-recommended

Locked core dependencies; require this project INSTEAD OF drupal/core.

6943.5M449](/packages/drupal-core-recommended)[shopware/core

Shopware platform is the core for all Shopware ecommerce products.

595.8M674](/packages/shopware-core)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
