Skip to content

The ORM for modular PHP applications — carve narrow entity slices from one physical table, no shared-model coupling.
PHP 8.4+MySQL & PostgreSQLApache 2.0

Most ORMs make the table/entity boundary the modeling boundary: one table, one primary entity class. In modular systems, that turns shared tables into shared domain objects.

A users table may be touched by authentication, administration, billing, public APIs, reporting, and background workers. Those contexts don’t need the same fields, relations, invariants, or lifecycle behavior. A single shared User entity gradually becomes a coupling point between modules.

Articulate makes the bounded context the modeling boundary. Several small entity classes can map to the same physical table: LoginUser for authentication, AdminUser for administration, BillingCustomer for billing, and read-only projection entities for public APIs — while still providing the expected ORM foundations: attributes, repositories, relations, migrations, type mapping, identity map, unit of work, lazy loading, and caching.

Context-bounded entities

Multiple entity classes can map to one physical table, each exposing only the fields and relationships needed for that context.

Per-context Unit of Work

Each EntityManager owns its own IdentityMap, with one active UnitOfWork at a time — no process-wide singletons, no global state. A manager can still open additional scoped UnitOfWorks for bounded sub-operations that flush together.

Explicit per-slice locking

Optimistic locking guards only the columns a slice actually owns, so a cosmetic write in one context can’t block a critical one in another.

Dual-database, first class

MySQL 8.0 and PostgreSQL 15 are both primary targets — schema diffing, migrations, and type mapping understand both.

Attribute-driven metadata

No XML, no YAML, no annotations. Mapping lives in PHP 8 attributes right next to the code they describe: #[Entity], #[Property], #[OneToMany], and more.

Deliberate shared-table writes

Cross-entity remove propagation, cache eviction, and version guards all reason correctly about sibling entities sharing a row — flush combines pending changes across siblings into the minimal set of SQL statements, not one per class.

Install the package:

Terminal window
composer require articulate-orm/core

Map an entity and write your first flush:

use Articulate\Connection;
use Articulate\Modules\EntityManager\EntityManager;
#[Entity]
class User
{
#[PrimaryKey]
public ?int $id = null;
#[Property]
public string $name;
#[Property]
public string $email;
}
$connection = new Connection('mysql:host=127.0.0.1;dbname=myapp', 'user', 'password');
$em = new EntityManager($connection);
$user = new User();
$user->name = 'Jane';
$user->email = 'jane@example.com';
$em->persist($user);
$em->flush();
$user = $em->getRepository(User::class)->find($user->id);