denisyu-1/articulate 问题修复 & 功能扩展

解决BUG、新增功能、兼容多环境部署,快速响应你的开发需求

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

denisyu-1/articulate

Composer 安装命令:

composer require denisyu-1/articulate

包简介

Context-bounded PHP ORM for domain-driven applications

README 文档

README

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

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

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

CI Mutation testing PHP Version

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

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 — 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

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

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

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

  • 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

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

Built-in mappings: boolTINYINT(1), intINT, floatFLOAT, stringVARCHAR(255), DateTimeInterfaceDATETIME.

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

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

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

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

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

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

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)

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

Licensed under the Apache License 2.0. See LICENSE.

denisyu-1/articulate 适用场景与选型建议

denisyu-1/articulate 是一款 基于 PHP 开发的 Composer 扩展包,目前已累计 41 次下载、GitHub Stars 达 1, 最近一次更新时间为 2026 年 02 月 26 日, 在 PHP 生态内属于活跃度较高的组件。

它主要适用于以下技术方向: 「database」 「orm」 「php」 「ddd」 「entity-manager」 「bounded-context」 等业务场景。在实际项目中,围绕这些方向常见需要落地的问题包括:接口对接、性能调优、并发安全、与既有框架(Laravel / ThinkPHP / Yii / Webman 等)的兼容适配,以及生产环境的日志埋点与稳定性保障。

我们在过去多个企业项目中使用过 denisyu-1/articulate 或与其功能相近的方案,如果你在选型或落地过程中遇到问题,例如 版本兼容、二次改造、私有化封装、与内部系统对接、生产 BUG 排查,欢迎联系我们协助评估。

围绕 denisyu-1/articulate 我们能提供哪些服务?
定制开发 / 二次开发

基于 denisyu-1/articulate 在你已有业务上做功能扩展、字段裁剪、UI 适配、与内部账号 / 权限 / 日志系统的深度对接。

BUG 修复 & 性能优化

线上偶发问题、内存泄漏、慢查询、并发异常等排查修复;针对高流量场景做缓存、队列、索引层面的调优。

项目外包 & 长期维护

承接完整的项目从需求 → 设计 → 开发 → 上线 → 长期运维;也可按月提供技术保姆服务。

yvsm@zunyunkeji.com QQ:316430983 微信:yvsm316 西安尊云信息科技 · 专注 PHP / Go / 分布式系统研发

统计信息

  • 总下载量: 41
  • 月度下载量: 0
  • 日度下载量: 0
  • 收藏数: 1
  • 点击次数: 24
  • 依赖项目数: 1
  • 推荐数: 0

GitHub 信息

  • Stars: 1
  • Watchers: 0
  • Forks: 0
  • 开发语言: PHP

其他信息

  • 授权协议: Apache-2.0
  • 更新时间: 2026-02-26