denisyu-1/articulate
Articulate is a context-bounded ORM for modular PHP apps sharing database tables. Map multiple small entity classes to one table, with repositories, relations, migrations, identity map, unit of work, lazy loading, caching, and MySQL/PostgreSQL support.
Verify the package name before installing. The README's changelog links point at
articulate-orm/core, while the repository isDenisYu-1/articulate. Confirm the actual Composer name and current tag on Packagist (composer show --all <vendor>/articulate) before adding it tocomposer.json.
Prerequisites
Minimal wiring (service provider)
// app/Providers/ArticulateServiceProvider.php
use Articulate\Connection;
use Articulate\Modules\EntityManager\EntityManager;
public function register(): void
{
$this->app->scoped(EntityManager::class, function () {
$db = config('database.connections.mysql');
$connection = new Connection(
dsn: "mysql:host={$db['host']};port={$db['port']};dbname={$db['database']}",
user: $db['username'],
password: $db['password'],
persistent: app()->isProduction(),
);
return new EntityManager($connection);
});
}
Use scoped() rather than singleton() so Octane and queue workers get a fresh identity map/unit of work per request or job.
Where to look first
EntityManager / Connection constructors (cache arguments are the only real configuration surface).First use case — a read-only projection. Do not start by replacing Eloquent writes. Start with a reporting or auth-side view over an existing table that Laravel already owns:
#[Entity(tableName: 'users', readOnly: true)]
class ActiveUserReportRow
{
#[PrimaryKey]
public int $id;
#[Property]
public string $email;
#[Property]
public string $name;
}
// In a controller / query object:
$rows = app(EntityManager::class)
->getRepository(ActiveUserReportRow::class)
->findBy(['status' => 'active']);
This exercises the attribute mapping, the type conversion, and the read path with zero risk to your write path. Expand from there once the metadata/DDL story is settled.
One entity class per bounded context, one physical table. The idiomatic shape is a suffix convention per module, all pointing at the same tableName:
#[Entity(tableName: 'users')] class AuthUser { /* id, login, password */ }
#[Entity(tableName: 'users', readOnly: true)] class AdminUserList { /* id, email, name, status */ }
#[Entity(tableName: 'users')] class BillingCustomer{ /* id, stripe_id, plan */ }
Keep each class in the owning module's namespace and treat cross-context reuse of an entity class as the anti-pattern the package exists to prevent.
Repository + DI. Prefer custom repositories over calling getRepository() inline, so module boundaries stay explicit and repositories are mockable:
#[Entity(tableName: 'users', repositoryClass: AuthUserRepository::class)]
class AuthUser { /* ... */ }
class AuthUserRepository extends AbstractRepository
{
public function findForLogin(string $login): ?AuthUser
{
return $this->findOneBy(['login' => $login]);
}
}
// bind in the module's provider
$this->app->bind(AuthUserRepository::class, fn ($app) =>
$app->make(EntityManager::class)->getRepository(AuthUser::class)
);
Write path: unit of work, not save(). The mental model differs from Eloquent. You mutate managed entities and call flush() once per operation; the manager collapses changes into minimal statements. Wrap each operation in its own scope (a job, a command handler, an action class) and let the container resolve a fresh EntityManager:
$em = app(EntityManager::class);
$user = new AuthUser();
$user->login = $login;
$user->password = $hashed;
$em->persist($user);
$em->flush();
For batch work, chunk explicitly and clear tracked entities between chunks rather than growing one unit of work to thousands of objects.
Read path: QueryBuilder + result cache for read-heavy screens. Dashboards, catalogs, and public API list endpoints are the sweet spot:
$em->createQueryBuilder(AdminUserList::class)
->from('users')
->where('status', 'active')
->enableResultCache(lifetime: 300, resultCacheId: 'admin:active-users:v1')
->getResult();
Bake a version segment into resultCacheId and bump it on deploy or on a write that invalidates the view — there is no fine-grained result-cache invalidation, only TTL and key naming.
Cache layering. Three independent layers, all PSR-6:
$em = new EntityManager(
$connection,
resultCache: $queryPool, // QueryBuilder result sets
secondLevelCache: $entityPool, // find() entity cache, cross-request
secondLevelCacheTtl: 3600,
statementCache: $statementPool, // compiled SQL strings
);
Laravel's Cache manager is PSR-16, not PSR-6. Wrap it (e.g. a PSR-6 adapter package) or point these at Redis/Memcached pools directly. Statement cache is free; enable it everywhere. Second-level cache is only safe when Articulate is the only writer to those tables — see Gotchas.
Polymorphic tags. Register morph aliases once at boot (a service provider's boot() is fine, but note it's static global state — never register per request based on user input):
MorphTypeRegistry::register(TaggableOrder::class, 'order');
MorphTypeRegistry::register(TaggableCustomer::class, 'customer');
Aliases keep taggable_type values short and rename-safe; the class-name fallback is only for throwaway code.
Custom type converters for value objects. Enums, money objects, and UUIDs belong in a TypeConverterInterface implementation rather than in getters/setters. Resolution is priority-based when a class matches multiple mappings, so register the most specific converter and test the round-trip (hydrate → flush → re-hydrate) — that's where mapping bugs surface.
Migration ownership — pick one tool. Articulate has schema metadata and migrations. Do not let both Laravel migrations and Articulate generate DDL against the same database. The pragmatic split:
readOnly: true where they omit NOT NULL columns.Anything else produces drift you'll debug at 2am.
Coexistence with Eloquent. The realistic adoption path is Eloquent for writes on core tables, Articulate for context-specific reads and for the one module where the shared-entity coupling actually hurts. Guard that boundary: one writer per table, and treat Articulate as a read model until you've validated the write path in a non-critical module.
config/articulate.php, no artisan articulate:*. Everything in Getting Started is code you write and maintain. Budget for that.EntityManager owns the identity map and unit of work. A singleton bound in a queue worker or Octane accumulates tracked entities and dirty state forever, and find() returns the same instance on repeat calls (identity map), so stale reads within a request are by design, not a bug. Bind scoped(); if you must share, explicitly discard the manager between jobs.Connection. If Eloquent writes and Articulate writes need to be atomic, they must share the same PDO/transaction — verify whether Connection accepts an existing PDO before designing around it. Otherwise, sequence the writes and accept non-atomicity, or keep the write path in one tool.flush() through that EntityManager. An Eloquent update, a raw query, or another service will leave stale entities in the L2 pool for the full TTL. Either make Articulate the sole writer for cached tables or keep secondLevelCacheTtl short.persist()/remove() throw ReadOnlyEntityException before SQL is built — good. But mutations to a read-only entity's in-memory properties are silently ignored, which looks like a no-op bug. Review code that hydrates a readOnly: true entity and then assigns to it.email length/nullability will fail loudly — treat that as a feature, but it means the classes must be kept in sync when you add columns.ENGINE/CHARSET/COLLATE in generated DDL — by design. If your database default is not utf8mb4/InnoDB, generated tables will inherit the wrong defaults. Set charset at CREATE DATABASE time and confirm SHOW CREATE TABLE on the first generated table.#[Index] takes PHP property names, not column names. #[Index(fields: ['userId', 'createdAt'])]. Copying column names from a SQL file yields a silently wrong or failing index. Expression/prefix indexes (LOWER(email), title(100)) are not supported — don't reach for raw column strings as a workaround.getResult(), you can get surprising key collisions. Always set resultCacheId on queries you care about.FOR UPDATE queries are never cached. Don't build a locking read path and then wonder why the cache isn't helping; that's intentional.taggable_type values that break inverse loading. Register in boot() with literals only.MorphToMany is shown as Collection, MorphedByMany as array. Match what the hydrator actually assigns — a typed property mismatch is a TypeError at load time, not a helpful ORM exception.articulate-orm/core in changelogs). Have an exit plan: your entity classes are plain PHP with attributes, so the escape hatch back to hand-written PDO or Eloquent read models is mechanical — which is itself an argument for keeping Articulate entities thin and free of business logic.users/orders table being contorted by five modules is the bar.How can I help you explore Laravel packages today?