Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Articulate Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

Verify the package name before installing. The README's changelog links point at articulate-orm/core, while the repository is DenisYu-1/articulate. Confirm the actual Composer name and current tag on Packagist (composer show --all <vendor>/articulate) before adding it to composer.json.

Prerequisites

  • PHP >= 8.4 — this forces your Laravel version and CI images up; it is not optional.
  • MySQL or PostgreSQL. Neither SQLite nor MariaDB-specific behaviour is claimed as first-class.
  • No Laravel service provider, facade, config file, or Artisan command ships with the package. You wire it yourself.

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

  1. README → When It Fits — decide honestly whether you have the problem it solves.
  2. Core Concepts → Context-Bounded Entities and Read-Only Entities. These two sections contain the entire value proposition.
  3. The EntityManager / Connection constructors (cache arguments are the only real configuration surface).
  4. The QA workflow and mutation badge, not the README, for what's actually tested.

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.

Implementation Patterns

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:

  • Laravel owns DDL, Articulate entities are read-mostly and marked readOnly: true where they omit NOT NULL columns.
  • Articulate owns DDL for a dedicated module schema, and Laravel migrations never touch those tables.

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.

Gotchas and Tips

  • It is not a Laravel package. No provider, no facade, no config/articulate.php, no artisan articulate:*. Everything in Getting Started is code you write and maintain. Budget for that.
  • PHP 8.4 is a hard floor. If your app or CI matrix is on 8.2/8.3, this package is simply unavailable until you upgrade the whole runtime. Check before you prototype.
  • Long-lived processes will leak entities. 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.
  • Two connections means two transactions. Articulate wraps its own 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.
  • Second-level cache goes stale under external writes. Eviction happens on 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.
  • Read-only is enforced at the API boundary, not the SQL layer. 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.
  • Partial entities still need a primary key, and conflicting column definitions across sibling entities are validated at metadata build time. Two classes disagreeing on 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.
  • No 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.
  • Cursor pagination requires scalar order columns. The 2.0.0 release specifically normalized non-scalar order columns and stopped swallowing cursor-extraction failures — meaning earlier versions failed quietly. If you paginate, order by plain scalar columns and assert on the page boundary in tests.
  • Caching failure modes are quiet. Statement-cache errors are swallowed by design; a misconfigured pool degrades to "slow but correct." Result-cache keys are auto-generated from query shape + parameters — if a query builder mutates after 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.
  • Morph alias registry is static. Registering the same alias twice, or registering per-request with a dynamic alias, produces inconsistent taggable_type values that break inverse loading. Register in boot() with literals only.
  • Watch the type declarations in relation examples. 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.
  • API churn is real and BC breaks happen on minor versions. 1.1.0 changed the entities directory config from string to array; 2.0.0 shipped optimistic-locking changes. Pin an exact version, read the release notes before bumping, and avoid depending on un-documented internals.
  • Very low bus factor. Three stars, zero dependents, one maintainer, and the repository has apparently moved (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.
  • When to skip it. One stable entity model per table, no cross-module coupling pain, and Eloquent already works? The README says it plainly: it will fit, but it won't solve anything. The integration cost here (hand-rolled provider, dual connection, PSR-6 bridging, migration ownership) is higher than for a mature Laravel package, so the payoff needs to be concrete — a shared users/orders table being contorted by five modules is the bar.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin