PHPackages                             denisyu-1/articulate - 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. [Database &amp; ORM](/categories/database)
4. /
5. denisyu-1/articulate

ActiveLibrary[Database &amp; ORM](/categories/database)

denisyu-1/articulate
====================

Context-bounded PHP ORM for domain-driven applications

1.1.0(4w ago)364[1 issues](https://github.com/DenisYu-1/articulate/issues)[1 PRs](https://github.com/DenisYu-1/articulate/pulls)1Apache-2.0PHPPHP &gt;=8.4CI passing

Since Feb 26Pushed 1mo agoCompare

[ Source](https://github.com/DenisYu-1/articulate)[ Packagist](https://packagist.org/packages/denisyu-1/articulate)[ Docs](https://github.com/DenisYu-1/articulate)[ RSS](/packages/denisyu-1-articulate/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (2)Dependencies (36)Versions (13)Used By (1)

Articulate
==========

[](#articulate)

Context-bounded ORM for modular PHP applications that share database tables across modules.

Why Articulate?
---------------

[](#why-articulate)

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 do not 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.

 [![Articulate Logo](logo.svg)](logo.svg)

Articulate still provides the expected ORM foundations: attributes, repositories, relations, migrations, type mapping, identity map, unit of work, lazy loading, and caching. The difference is that these pieces are designed around context-bounded entities from the start.

Badges
------

[](#badges)

[![CI](https://github.com/DenisYu-1/articulate/workflows/QA/badge.svg)](https://github.com/DenisYu-1/articulate/actions)[![Mutation testing](https://camo.githubusercontent.com/37d607e74f439d9bcec663807b7ea28739ed572fc2b4ed20d8898c45d0bea326/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4d75746174696f6e25323053636f72652d38332532352b2d627269676874677265656e)](https://github.com/DenisYu-1/articulate/actions)[![PHP Version](https://camo.githubusercontent.com/6e44ad49e5307c87d1393389feb52ab61c99956e2e5f8c77177b2501f1d3d47f/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7068702d253345253344382e342d3838393242462e737667)](https://php.net/)

What Makes It Different?
------------------------

[](#what-makes-it-different)

- Multiple entity classes can map to one physical table.
- Partial entities can be marked read-only when they intentionally omit required columns.
- Each `EntityManager` owns its identity map and units of work.
- Shared-table sibling entities are handled deliberately during writes and cache eviction.
- Schema metadata, migrations, relations, lazy loading, repositories, and type conversion all understand context-bounded entities.
- MySQL and PostgreSQL are first-class targets.

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

[](#quick-start)

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

Before / After
--------------

[](#before--after)

**Before** — one shared entity shape for every context:

```
#[Entity]
class User
{
    public int $id;
    public string $login;
    public string $password;
    public string $name;
    public array $phones;  // Auth doesn't need this
    public array $groups;  // Auth doesn't need this
    public Cart $cart;     // Auth doesn't need this
}

// Auth: loads full user + all relations
$user = $userRepo->find($id);
return $auth->validate($user->login, $user->password);
```

**After** — separate entities per context, same table:

```
#[Entity(tableName: 'user')]
class LoginUser
{
    #[PrimaryKey]
    public int $id;

    #[Property]
    public string $login;

    #[Property]
    public string $password;
}

#[Entity]
class User
{
    #[PrimaryKey]
    public int $id;

    #[Property]
    public string $name;

    #[OneToMany(ownedBy: 'user', targetEntity: Phone::class)]
    public array $phones;

    #[OneToOne(targetEntity: Cart::class, referencedBy: 'user')]
    public Cart $cart;
}

// Auth: loads only id, login, password
$loginUser = $em->getRepository(LoginUser::class)->find($id);
return $auth->validate($loginUser->login, $loginUser->password);
```

When It Fits
------------

[](#when-it-fits)

Articulate is a good fit when different bounded contexts need different views of the same data, when adding a relation for one workflow should not affect every other workflow, or when long-running processes need tighter control over tracked entities.

If your application has one stable entity model per table and your current ORM handles that well, Articulate would still fit, just probably will not solve a meaningful problem for you.

Core Concepts
-------------

[](#core-concepts)

### Context-Bounded Entities

[](#context-bounded-entities)

Multiple entity classes can point to the same database table, each exposing only the fields and relationships needed for that context. Articulate merges compatible column definitions and validates for conflicts.

### Read-Only Entities

[](#read-only-entities)

Mark a context-bounded entity as read-only when it intentionally omits required columns — for example, a `LoginUser` that exposes only `login` and `password` from a `users` table that has many more non-nullable columns.

```
#[Entity(tableName: 'user', readOnly: true)]
class LoginUser
{
    #[PrimaryKey]
    public int $id;

    #[Property]
    public string $login;

    #[Property]
    public string $password;
}

// find() and QueryBuilder work normally:
$loginUser = $em->getRepository(LoginUser::class)->find($id);
$auth->validate($loginUser->login, $loginUser->password);

// persist() and remove() throw ReadOnlyEntityException:
$em->persist($loginUser); // throws
```

`ReadOnlyEntityException` is thrown at `persist()` and `remove()` — before any SQL is built.

### Memory-Efficient Unit of Work

[](#memory-efficient-unit-of-work)

- Clear entities from memory that are no longer needed within specific operations
- Different units of work can track their own entities independently
- Entity manager combines all unit-of-work changes into minimal database queries during flush

Useful for processing large datasets, complex business operations spanning multiple contexts, and long-running processes with varying entity lifecycles.

### Polymorphic Many-To-Many Relations

[](#polymorphic-many-to-many-relations)

Use `MorphToMany` / `MorphedByMany` when several entity types share one pivot table, such as tagging orders and customers through `taggables`.

```
use Articulate\Attributes\Relations\MorphedByMany;
use Articulate\Attributes\Relations\MorphToMany;
use Articulate\Attributes\Relations\MorphTypeRegistry;
use Articulate\Modules\EntityManager\Collection;

MorphTypeRegistry::register(TaggableOrder::class, 'order');
MorphTypeRegistry::register(TaggableCustomer::class, 'customer');

#[Entity(tableName: 'orders')]
class TaggableOrder
{
    #[PrimaryKey]
    public int $id;

    #[MorphToMany(targetEntity: Tag::class, name: 'taggable', targetIdColumn: 'tag_id')]
    public Collection $tags;
}

#[Entity(tableName: 'tags')]
class Tag
{
    #[PrimaryKey]
    public int $id;

    #[MorphedByMany(targetEntity: TaggableOrder::class, name: 'taggable', targetIdColumn: 'tag_id')]
    public array $orders;
}

$order->tags->add($tag);
$em->flush(); // inserts into taggables using taggable_type = 'order'
```

The pivot table uses `{name}_type`, `{name}_id`, and the target id column:

```
taggables(taggable_type, taggable_id, tag_id)
```

Registered morph aliases are used for owning and inverse relation loading. If no alias is registered, Articulate falls back to storing and loading the full entity class name.

When Articulate generates a polymorphic pivot schema, it uses the composite key (`taggable_type`, `taggable_id`, `tag_id`) as the relation identity. A separate technical `id` column is not required for collection loading or persistence.

Type Mapping System
-------------------

[](#type-mapping-system)

Built-in mappings: `bool` ↔ `TINYINT(1)`, `int` ↔ `INT`, `float` ↔ `FLOAT`, `string` ↔ `VARCHAR(255)`, `DateTimeInterface` ↔ `DATETIME`.

Custom class mappings and `TypeConverterInterface` for complex types. Priority-based resolution when a class implements multiple interfaces with registered mappings.

Repository Pattern
------------------

[](#repository-pattern)

```
$userRepo = $em->getRepository(User::class);
$user = $userRepo->find(1);
$users = $userRepo->findBy(['status' => 'active']);
$user = $userRepo->findOneBy(['email' => 'user@example.com']);
```

Custom repositories via `#[Entity(repositoryClass: UserRepository::class)]` extending `AbstractRepository`.

Caching
-------

[](#caching)

Articulate has three independent cache layers, all using PSR-6 (`CacheItemPoolInterface`). Pass the same pool instance to share backend, or separate instances for isolation.

### Second-Level Cache

[](#second-level-cache)

Cross-request entity cache. Survives beyond a single `EntityManager` instance.

```
Request A: identity map miss → DB hit → entity stored in L2 cache
Request B: identity map miss → L2 cache hit → DB skipped entirely
Request C: identity map miss → L2 cache hit → DB skipped entirely

```

Pass any PSR-6 pool to `EntityManager`. If no dedicated pool is given, it falls back to the result cache pool automatically.

```
$em = new EntityManager(
    $connection,
    resultCache: $cachePool,           // also backs L2 cache unless overridden
    secondLevelCacheTtl: 3600,
);

// Or with a dedicated L2 pool:
$em = new EntityManager(
    $connection,
    resultCache: $queryPool,
    secondLevelCache: $entityPool,     // separate backend for entity cache
    secondLevelCacheTtl: 3600,
);
```

`find()` checks the identity map first, then the L2 cache, then the database. On `flush()`, modified and deleted entity entries are evicted automatically — stale data is never served after a write.

### Query Result Cache

[](#query-result-cache)

Cache raw result sets from `QueryBuilder` queries. Useful for read-heavy queries that don't change often.

```
$users = $em->createQueryBuilder(User::class)
    ->from('users')
    ->where('status', 'active')
    ->enableResultCache(lifetime: 300, resultCacheId: 'active_users')
    ->getResult();
```

- Custom cache key via `resultCacheId`, or auto-generated from query shape + parameters
- Locked queries (`FOR UPDATE`) are never cached
- Call `disableResultCache()` to opt out per query

### Statement Cache

[](#statement-cache)

Caches compiled SQL strings (query structure, not results). Eliminates repeated SQL compilation for queries with the same shape but different parameter values.

```
$em = new EntityManager($connection, statementCache: $cachePool);
```

Transparent — no per-query opt-in needed. Failures are silently ignored so a broken cache backend never breaks queries.

Connection Pooling
------------------

[](#connection-pooling)

Enable PDO persistent connections to reuse open database connections across requests:

```
$connection = new Connection(
    dsn: 'mysql:host=127.0.0.1;dbname=myapp',
    user: 'root',
    password: 'secret',
    persistent: true,
);
```

Skips TCP handshake and authentication overhead on each request. Pair with a pool-aware cache backend for full cross-request performance.

MySQL Table Options (ENGINE, CHARSET, COLLATE)
----------------------------------------------

[](#mysql-table-options-engine-charset-collate)

Articulate does not append table options like `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=...` to generated `CREATE TABLE` statements. This is intentional.

Storage engine and character set are deployment concerns, not schema concerns. The right values depend on the MySQL version, the hosting environment, and the application's locale requirements — there is no single correct default. Hardcoding them would mean either inheriting outdated assumptions or overriding a deliberate server configuration.

Instead, Articulate delegates to the server's configured defaults:

- **ENGINE** — InnoDB is the MySQL default since 5.7 and is the only engine that supports foreign keys; Articulate's FK generation already implies it.
- **CHARSET / COLLATE** — configure once at the server or database level (`CREATE DATABASE ... CHARACTER SET utf8mb4`). All tables created in that database inherit the correct charset without per-table repetition.

If per-table overrides are ever needed, the right path is an explicit option on `#[Entity]`, not a framework-wide hardcoded string.

Index Attribute Design
----------------------

[](#index-attribute-design)

`#[Index]` takes `fields` — PHP property names, not column names:

```
#[Index(fields: ['userId', 'createdAt'])]
#[Entity]
class Order { ... }
```

This keeps index definitions coupled to the entity model. When a property is renamed alongside its column, PHP tooling catches the broken reference in `fields`. Raw column strings would silently diverge.

**Expression and prefix indexes** (e.g. `LOWER(email)`, `title(100)`) have no PHP property to reference. If that need arises, a dedicated `ExpressionIndex` attribute will be introduced as an explicit escape hatch rather than mixing column-string support into `Index`.

License
-------

[](#license)

Licensed under the Apache License 2.0. See [LICENSE](./LICENSE).

###  Health Score

45

—

FairBetter than 91% of packages

Maintenance84

Actively maintained with recent releases

Popularity15

Limited adoption so far

Community14

Small or concentrated contributor base

Maturity59

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 56% 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 ~47 days

Total

4

Last Release

29d ago

Major Versions

v0.1.1 → v1.0.02026-07-05

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/230987271?v=4)[DenisYu-1](/maintainers/DenisYu-1)[@DenisYu-1](https://github.com/DenisYu-1)

---

Top Contributors

[![denis-yuriev-1](https://avatars.githubusercontent.com/u/113170771?v=4)](https://github.com/denis-yuriev-1 "denis-yuriev-1 (47 commits)")[![actions-user](https://avatars.githubusercontent.com/u/65916846?v=4)](https://github.com/actions-user "actions-user (18 commits)")[![DenisYu-1](https://avatars.githubusercontent.com/u/230987271?v=4)](https://github.com/DenisYu-1 "DenisYu-1 (18 commits)")[![V-Srv](https://avatars.githubusercontent.com/u/7782973?v=4)](https://github.com/V-Srv "V-Srv (1 commits)")

---

Tags

phpdatabaseormdddentity-managerbounded-context

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/denisyu-1-articulate/health.svg)

```
[![Health](https://phpackages.com/badges/denisyu-1-articulate/health.svg)](https://phpackages.com/packages/denisyu-1-articulate)
```

###  Alternatives

[laravel/framework

The Laravel Framework.

34.9k556.2M21.5k](/packages/laravel-framework)[tempest/framework

The PHP framework that gets out of your way.

2.3k37.6k21](/packages/tempest-framework)[sylius/sylius

E-Commerce platform for PHP, based on Symfony framework.

8.5k6.0M777](/packages/sylius-sylius)[sulu/sulu

Core framework that implements the functionality of the Sulu content management system

1.3k1.4M236](/packages/sulu-sulu)[matomo/matomo

Matomo is the leading Free/Libre open analytics platform

21.7k39.6k](/packages/matomo-matomo)[flow-php/flow

PHP ETL - Extract Transform Load - Data processing framework

86337.5k](/packages/flow-php-flow)

PHPackages © 2026

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