loongs / orm
loong-swoole ORM — Eloquent-style query builder, models and relations; connection chosen per call (name, config array, DSN), always pooled, coroutine-safe
Requires
- php: ^8.4
- ext-pdo: *
- loongs/helper: dev-main
Requires (Dev)
None
Suggests
- ext-pdo_mysql: MySQL driver (the built-in grammar)
- ext-swoole: Coroutine-local transactions / held connections and pooled named connections
- loongs/framework: Named connections use the framework's Swoole PDOPool (db()) when it is booted
- loongs/saas: Multi-tenancy on top of the ORM: tenant scopes, tenant registry / resolvers, one pool per tenant
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-11 06:59:34 UTC
README
Eloquent-style ORM for the loong-swoole stack (PHP 8.4, Swoole coroutines): the connection is chosen per call (name, config array or DSN), every connection comes from a pool and goes back to the same pool, and handles / models never hold one. No illuminate dependency.
Multi-tenancy (tenant scopes, tenant registry / resolvers, one pool per tenant) lives in
loongs/saas, built on the generic extension points of this package —
see "Upgrading: tenancy moved to loongs/saas" at the end.
composer require loongs/orm:dev-main # Packagist; in the monorepo via a path repo (see "Development")
Layout
src/
Orm.php facade: configure, connection/table/transaction/acquire, pool, extension hooks, listeners
Context.php coroutine-local storage (Coroutine::getContext(), static array outside coroutines)
Connection/
ConnectionConfig.php immutable config: named / ad-hoc array / DSN; key = name:x | adhoc:sha1(full config)
ConnectionResolver.php spec → config (every call); lease = LeaseProvider(s) | framework PDOPool (named) | ORM pool
Connection.php query runner; plain handles hold NO PDO; pinned handles (Orm::acquire) share a HeldLease
Lease.php LeaseSource.php one checked-out connection: release() / discard(), idempotent, safety-net reclaim
HeldLease.php acquire()/using() lease, ambient for its config in the acquiring coroutine
ConnectionPool.php PoolBucket.php PoolConfig.php generic keyed pool (the ORM pool, always on): bounded per config, LRU, idle/ttl, validation
LeaseProvider.php extension point: another package serves some configs from its own pool (loongs/saas)
PoolProvider.php FrameworkPoolProvider.php bridge to loongs/framework DatabaseManager (PDOPool, discard)
PdoFactory.php QueryExecuted.php TransactionState.php
Query/ Builder.php JoinClause.php Expression.php Grammar/{Grammar,MySqlGrammar}.php
Model/ Model.php Builder.php Collection.php Pivot.php Attribute.php Scope.php SoftDeletes.php SoftDeletingScope.php
Concerns/{HasAttributes,HasRelationships,HasTimestamps,HasGlobalScopes,HidesAttributes,HasEvents}.php
Relations/{Relation (+ morph map),HasOneOrMany,HasOne,HasMany,BelongsTo,BelongsToMany,
MorphTo,MorphOneOrMany,MorphOne,MorphMany,MorphToMany,HasManyThrough,HasOneThrough}.php
Pagination/Paginator.php Support/{Collection,Inflector}.php Casts/CastsAttributes.php Exceptions/*
tests/ orm_test.php bootstrap.php models.php real-MySQL test suite (composer test)
Quick start
use Loongs\Orm\Orm; use Loongs\Orm\Model\Model; use Loongs\Orm\Model\SoftDeletes; // Outside loongs/framework: give it a config/database.php-shaped array (or a closure returning one). // Inside the framework this is read from config('database') automatically. Orm::configure(['default' => 'mysql', 'connections' => ['mysql' => [ 'driver' => 'mysql', 'host' => '127.0.0.1', 'port' => 3306, 'database' => 'app', 'username' => 'root', 'password' => '...', 'charset' => 'utf8mb4', ]]]); final class User extends Model { use SoftDeletes; protected array $fillable = ['name', 'email', 'settings', 'is_uuims', 'status']; protected array $hidden = ['password']; protected function casts(): array { return ['settings' => 'array', 'is_uuims' => 'bool', 'status' => Status::class, 'score' => 'decimal:2']; } public function posts(): HasMany { return $this->hasMany(Post::class); } public function scopeUsers(Builder $q): void { $q->where('is_uuims', true); } } $u = User::create(['name' => 'ann', 'settings' => ['theme' => 'dark']]); $users = User::users()->with('posts')->orderBy('name')->paginate(20, page: 2); Orm::table('users')->where('score', '>', 10)->count();
Supported: query builder (where/orWhere/nested closures/whereIn/whereNull/whereBetween/whereColumn/whereExists/
whereRaw, joins, groupBy/having, orderBy/latest, limit/offset, locks, aggregates, insert/insertGetId/insertOrIgnore/
update/increment/delete/truncate, chunk/chunkById/cursor, paginate); models (fillable/guarded, casts incl. enums,
json, decimal, datetime and custom CastsAttributes, Attribute::make() accessors/mutators and getXAttribute,
hidden/visible/appends, dirty tracking, timestamps, soft deletes, global and local scopes, firstOrCreate/
updateOrCreate, replicate, model events + observers); relations (hasOne, hasMany, belongsTo, belongsToMany with pivot
columns/timestamps, attach/detach/sync; morphTo/morphOne/morphMany/morphToMany/morphedByMany with a morph map;
hasManyThrough/hasOneThrough) with eager loading (with('posts.user', 'roles'), constrained closures,
load/loadMissing); transactions with savepoints and deadlock retry; query listeners (Orm::listen).
Grammar: MySQL (register others with Orm::extendGrammar()).
Choosing a connection: resolution order
A connection spec is one of:
| spec | meaning |
|---|---|
null |
the configured default (or whatever a default-connection hook returns, see "Extension points") |
'central' |
named connection from config('database.connections') / Orm::configure() |
['driver'=>'mysql','host'=>..,'database'=>'t42','username'=>..,'password'=>..] |
ad-hoc config array (self-contained; not merged with any named connection) |
'mysql://user:pass@host:3306/db', 'mysql://user@unix:/tmp/mysql.sock/db' |
URL DSN |
'mysql:host=...;dbname=...' (+ ConnectionConfig::fromDsn($dsn, $user, $pass)) |
PDO DSN |
Models resolve: bound connection (User::on($spec), $m->setConnection($spec), or the connection the model was
loaded/first saved on) → declared protected ?string $connection = 'central' → default (default-connection
hook, then default).
User::on($shopCfg)->where('active', true)->get(); // explicit, per query Orm::connection('mysql://root@unix:/tmp/mysql.sock/t7')->select('select 1'); $m = new User(['name' => 'x']); $m->setConnection($cfg)->save();
Pools: where every connection comes from
| spec | pool |
|---|---|
a config claimed by a registered LeaseProvider (e.g. loongs/saas tenants) |
that provider's pool |
named connection ('mysql', 'central', or null → default) while loongs/framework pools are booted in this worker |
framework PDOPool (config/database.php → connections.*.pool) |
ad-hoc config: array / URL DSN / PDO DSN / ConnectionConfig |
ORM pool (ConnectionPool), one bucket per full config fingerprint |
| named connection with no booted framework pool (CLI scripts, tests) | ORM pool (bucket keyed by that config) |
Guarantee. Handles (Orm::connection(), Model::on()) and models keep a ConnectionConfig, never a PDO. There
is no static per-connection state outside the pools. A connection is checked out for one statement, one transaction, or
one acquire()/using() block, and always goes back to the pool it came from; a connection can only be handed to
a config whose fingerprint (host, port, socket, database, user, password, charset, options) is identical to the one
that opened it, and SELECT DATABASE() is checked on every checkout from the ORM pool. Pools are per worker process.
ORM pool settings
Orm::configurePool([...]) → config('database.orm_pool') → ORM_POOL_* env → defaults:
| key | env | default | meaning |
|---|---|---|---|
size |
ORM_POOL_SIZE |
8 | max open connections per config (checked out + idle) |
max_pools |
ORM_POOL_MAX_POOLS |
64 | config buckets kept; the least recently used bucket with nothing checked out is evicted |
idle_seconds |
ORM_POOL_IDLE_SECONDS |
60 | idle connections older than this are closed |
ttl |
ORM_POOL_TTL |
600 | connections older than this are closed on return / checkout (0 = no limit) |
wait_timeout |
ORM_POOL_WAIT_TIMEOUT |
3 | seconds a coroutine waits for a free connection, then PoolExhaustedException (-1 = forever) |
validate |
ORM_POOL_VALIDATE |
true | checkout check: not in a transaction + SELECT DATABASE() matches |
// server/config/database.php 'orm_pool' => ['size' => 8, 'max_pools' => 64, 'idle_seconds' => 60, 'ttl' => 600, 'wait_timeout' => 3], Orm::poolStats(); // pools, open, active, idle, waiting, created, closed, hits, discarded, waits, timeouts, evicted_pools Orm::poolStats($spec); // open, active, idle, waiting for one config
Sizing: worst case per worker ≈ max_pools × size open connections (plus the framework pools and any provider
pools) — keep it below MySQL max_connections / workers. Idle eviction is lazy (checked on checkout, at most once per
second, or Orm::pool()->sweep() from a timer). When every connection of a config is busy, coroutines wait (hand-off,
FIFO-ish) up to wait_timeout; outside a coroutine nothing can free one, so it throws immediately. max_pools is
soft: if every bucket has connections checked out, a new config still gets a bucket rather than failing.
Releasing connections
Automatic (default). Every statement checks a connection out and returns it right after; Orm::transaction()
keeps one for the transaction and returns it on commit / rollback.
Manual.
$c = Orm::acquire($spec); // one connection until release() try { $c->table('invoices')->insert([...]); User::on($spec)->find(7); // models / builders on the same config in this coroutine use it too } finally { $c->release(); // idempotent; a second call is a no-op } $n = Orm::using($spec, fn (Connection $c) => $c->table('users')->count()); // acquire + release, always
Nested acquire() of the same config in one coroutine shares the lease (reference counted; the last release()
returns it). A held connection belongs to the acquiring coroutine: other coroutines get their own. $c->discard()
closes it instead of reusing it.
On exceptions — in a statement, a transaction, or your own code inside using() / transaction():
- the connection is always returned (statement
finally,transaction()rollback,using()finally); - an open transaction is rolled back first;
- a connection that is lost / killed / out of sync (MySQL 2006, 2013, 1927, 4031 …, "gone away", "Lost connection") or whose rollback / commit failed is discarded (closed) and the pool opens a replacement later; SQL errors (syntax, constraint) keep the connection.
Safety net. A handle dropped without release() is released by its destructor; a lease still out when its
coroutine ends (kept in a global, or a beginTransaction() never committed) is reclaimed by Coroutine::defer
(open transaction rolled back). Both log WARN via Orm::onWarning(fn (string $m) => ...) (default error_log).
Treat the warning as a bug to fix, not a feature.
Do not keep the PDO / statements from withLease(fn ($pdo) => ...) past the callback, and do not pass a pinned
handle to another coroutine.
Per-call connections and models
$shop = ['driver' => 'mysql', 'host' => '10.0.0.5', 'database' => 'shop_42', 'username' => 'app', 'password' => '…']; $user = User::on($shop)->where('email', $email)->firstOrFail(); // → shop_42 (ORM pool bucket of that config) $user->posts()->create(['title' => 'hi']); // same connection config as $user Plan::find($user->plan_id); // Plan declares $connection = 'central' → central DB (pooled) Orm::transaction(fn () => Invoice::on($shop)->create([...]), $shop); // one connection for the transaction
- A model remembers where it came from: loaded or first saved on config A ⇒
save(),delete(),refresh()and relation queries go to A — the model keeps theConnectionConfig, never a PDO. - The connection of a query builder is fixed when the builder is created.
- Related models inherit the parent's connection unless they declare their own (
protected ?string $connection). - Hot paths:
Orm::using($spec, fn () => ...)keeps one connection for the whole block (no per-statement checkout / validation round trip).
Extension points
Generic hooks, used by loongs/saas for multi-tenancy; nothing in this
package knows about tenants.
// 1. default-connection hook: consulted for every `null` spec (models without bound / declared connection, // Orm::table(), Orm::connection()). Return a spec, or null for the configured default. Read coroutine-local // state only (Loongs\Orm\Context), never per-request statics. Orm::resolveDefaultUsing(fn () => Context::get('my.scope')); // null removes the hook // 2. lease providers: serve some configs from your own pool (asked first, in registration order; null = not mine) final class ReportPools implements LeaseProvider { public function __construct(private ConnectionPool $pool) {} public function lease(ConnectionConfig $c): ?Lease { return str_starts_with($c->database(), 'report_') ? $this->pool->acquire($c) : null; } } // ConnectionPool(PoolConfig, connector, serialOf (MySQL connection id, for the leak / identity checks), lease source) Orm::addLeaseProvider($provider = new ReportPools(new ConnectionPool(PoolConfig::fromArray(['size' => 4]), fn (ConnectionConfig $c) => PdoFactory::make($c), fn (object $pdo) => Orm::resolver()->serial($pdo), LeaseSource::Provider))); Orm::removeLeaseProvider($provider);
ConnectionPool is the same keyed pool the ORM uses (bounded per key, LRU of keys via max_pools, idle / ttl,
SELECT DATABASE() validation, waiting with wait_timeout, broken connections discarded, has() / forget() /
sweep() / stats()); leases taken from a provider pool keep every guarantee (manual release, rollback +
release on exceptions, discard of broken connections, safety net — see "Releasing connections").
Model events
Eloquent names, order and cancellation. Listeners are class metadata: register them once in booted()
(never per request). They receive the model, which carries its own connection, so whatever a listener does
through $model (relations, $model->newQuery(), $model->getConnection()) runs on that model's connection,
inside its transaction if one is open.
final class Order extends Model { protected static function booted(): void { static::creating(fn (Order $o) => $o->total >= 0); // false → INSERT cancelled, save() returns false static::created(fn (Order $o) => $o->audits()->create(['event' => 'created'])); // same connection as $o static::observe(OrderObserver::class); // methods named like events: saved(), deleted(), … } } $order->saveQuietly(); // also updateQuietly / deleteQuietly / restoreQuietly / forceDeleteQuietly Order::withoutEvents(fn () => Order::on($t)->create([...])); // ALL models, THIS coroutine only $copy = $order->replicate(); // unsaved copy, same connection; fires "replicating"
| operation | events (… = cancellable) |
|---|---|
| hydrate (get/find/first/cursor, relations) | retrieved |
| insert | saving… creating… created saved |
| update (dirty) / clean save | saving… updating… updated saved / saving… saved |
model increment()/decrement() |
updating… updated |
delete() (soft or hard) |
deleting… deleted |
restore() |
restoring… then the save events, restored |
forceDelete() |
forceDeleting… deleting… deleted forceDeleted |
replicate() |
replicating (on the copy) |
withoutEvents()is stored in the coroutineContext: concurrent coroutines keep their events; coroutines started inside are not affected. Restored on exceptions.- Mass query
update()/delete()/increment()on a builder fire no events (as Eloquent). Load and save models when events matter.Model::destroy()loads the models, so it does fire them. - Observers are instantiated once per worker and shared by every connection / coroutine: keep them stateless.
save()binds the instance to the connection it resolved to before the first listener runs (so listeners and the write agree even if a listener changes the default connection, e.g. a tenant scope, or cancels).
Polymorphic relations
Orm::morphMap(['post' => Post::class, 'video' => Video::class]); // = Relation::morphMap(); Orm::enforceMorphMap([...]) requires it class Comment extends Model { public function commentable(): MorphTo { return $this->morphTo(); } } // commentable_type / _id class Post extends Model { public function comments(): MorphMany { return $this->morphMany(Comment::class, 'commentable'); } public function image(): MorphOne { return $this->morphOne(Image::class, 'imageable'); } public function tags(): MorphToMany { return $this->morphToMany(Tag::class, 'taggable')->withPivot('weight'); } // taggables(tag_id, taggable_type, taggable_id) } class Tag extends Model { public function posts(): MorphToMany { return $this->morphedByMany(Post::class, 'taggable'); } } Comment::on($t)->with('commentable')->get(); // 1 query + 1 per distinct type Comment::on($t)->with(['commentable' => fn (MorphTo $q) => $q->morphWith([Post::class => ['user']]) ->constrain([Video::class => fn ($q) => $q->where('public', 1)])])->get(); $comment->commentable()->associate($video)->save(); // sets commentable_type = 'video', commentable_id $post->comments()->create(['body' => 'hi']); // type + id filled $post->tags()->sync([1, 2 => ['weight' => 5]]); // attach / detach / sync / updateExistingPivot, type-scoped
- Lookups run on the parent's connection unless the target class declares
$connection. - The stored type is the morph-map alias, else the class name. With
enforceMorphMap()an unmapped model throwsClassMorphViolationExceptionon write and an unmapped type value throws on read. A*_typevalue that is not aModelsubclass is never instantiated (InvalidArgumentException). - Builder calls in a
with(['commentable' => fn ($q) => …])closure are replayed on every type's query.
Has-many-through
// countries → users (users.country_id) → posts (posts.user_id) public function posts(): HasManyThrough { return $this->hasManyThrough(Post::class, User::class); } // all keys: firstKey (through.fk), secondKey (related.fk), localKey (this), secondLocalKey (through) public function owner(): HasOneThrough { return $this->hasOneThrough(Owner::class, Car::class, 'mechanic_id', 'car_id', 'id', 'id'); } Country::on($t)->with(['posts' => fn ($q) => $q->where('posts.published', 1)])->get(); // 2 queries $country->posts()->orderBy('posts.id')->paginate(20);
The through table is joined (same database as the related one; qualify columns in constraints). Soft-deleted
through rows are excluded when the through model uses SoftDeletes.
Table prefixes
Default table prefix is lgs_ when the connection config omits the prefix key. Set 'prefix' => '' explicitly to disable. Explicit non-empty values (e.g. shop_) override the default.
Every connection (named in config/database.php, ad-hoc array, URL mysql://u:p@h/db?prefix=app_&engine=InnoDB,
ConnectionConfig) may carry prefix ([A-Za-z0-9_], max 64), plus engine, charset, collation. The prefix is
applied by the grammar, so it works the same for the default connection, the framework pool, the ORM pool and
provider pools (loongs/saas tenants):
$shop = ['database' => 'shop_42', 'prefix' => 'shop_']; Orm::table('users', $shop)->insert(['name' => 'amy']); // `shop_users` Orm::table('users as u', $shop)->join('posts as p', 'p.user_id', '=', 'u.id')->select('u.name', 'p.*'); // select `shop_u`.`name`, `shop_p`.* from `shop_users` as `shop_u` inner join `shop_posts` as `shop_p` on ... User::on($shop)->count(); // models, relations, pivots, morph/through tables: `shop_...` Orm::prefix($shop); // 'shop_' (default connection when null) Orm::tableName('users', $shop); // 'shop_users' Orm::config($shop)->tableOptions(); // ['prefix' => 'shop_', 'engine' => ?, 'charset' => 'utf8mb4', 'collation' => ?] $c = Orm::connection($shop); // raw SQL: build identifiers with the connection $c->select('select count(*) n from ' . $c->wrapTable('users') . ' where ' . $c->wrap('users.name') . ' = ?', ['amy']);
- Public API:
Orm::config($spec)(normalisedConnectionConfig:prefix(),table(),engine(),charset(),collation(),tableOptions()),Orm::prefix($spec),Orm::tableName($table, $spec),Connection::prefix()/tableName()/wrapTable()/wrap(),Builder::wrap()/wrapTable(),Model::getTablePrefix()/getPrefixedTable()(getTable()stays logical). All resolve per call — nothing cached. - Aliases are prefixed too (same as Laravel):
users as u→`shop_users` as `shop_u`, and qualified columnsu.name→`shop_u`.`name`. Raw fragments (whereRaw,selectRaw,DB::raw) are not rewritten — build their identifiers with$query->wrap()/wrapTable().Expressiontables are left alone. - The prefix is part of the connection fingerprint: same database with a different prefix = a different pool bucket and a different transaction connection.
- A model keeps its bound connection (and so its prefix) whatever the default connection is later.
- The framework
DatabaseManagerraw API has no prefix concept; only ORM builders / models apply it.
loongs/framework integration
Nothing to register. When loongs/framework is present and its DatabaseManager pools are booted (worker start),
named connections — including the default one — are checked out of the framework PDOPool and put back; broken
ones go to DatabaseManager::discard() (the pool refills). Pool exhaustion (pool.wait_timeout) surfaces as
Loongs\Orm\Exceptions\PoolExhaustedException. Ad-hoc configs never enter the framework pools. Named configs come from config('database'). The framework does not depend on this package.
Orm::usePools($provider) swaps the provider (null: named connections use the ORM pool too).
Caveats
- MySQL grammar only (SQLite/Postgres: add a
GrammarviaOrm::extendGrammar). - A pool used before
fork()is abandoned in the child (never closed there); do not query from the master before the server / process manager forks. - Session state other than the current database (user variables,
SET SESSION ..., temporary tables) survives in a pooled connection; reset what you change, ordiscard()the connection. - Model events are not fired by mass query updates / deletes (see "Model events").
getOriginal()applies casts and accessors; usegetRawOriginal()for stored values.- MySQL normalises JSON key order; compare decoded arrays with
==.
Development
In the loong-swoole monorepo it is linked through server/composer.dev.json (path repo, symlink):
cd composer/orm && composer test # = php -d disable_functions= tests/orm_test.php cli && … co # needs MySQL; credentials from LOONGS_TEST_ENV=/path/to/.env (DB_*), throwaway databases loongs_orm_* are dropped at the end cd server && COMPOSER=composer.dev.json composer update loongs/orm
Upgrading: tenancy moved to loongs/saas (breaking)
loongs/orm is now a plain ORM. Everything tenant-specific moved to loongs/saas
(composer require loongs/saas:dev-main); there are no shims in this package.
| before (loongs/orm) | now |
|---|---|
Orm::tenant($t, fn () => …) |
Loongs\Saas\Tenancy::run($t, fn () => …) (loongs/saas) |
Orm::currentTenant() |
Tenancy::connectionConfig() (also Tenancy::current() / id() / active()) |
Orm::go(fn () => …) |
Tenancy::go(fn () => …) |
null spec = tenant scope, else default |
null spec = default-connection hook (Orm::resolveDefaultUsing()), else default; loongs/saas installs the hook |
Loongs\Orm\Connection\TenantPool |
Loongs\Orm\Connection\ConnectionPool (generic) · per-tenant pools: Loongs\Saas\TenantPool |
| tenant configs pooled in the ORM pool | tenants (inside Tenancy) pooled in loongs/saas TenantPool, one bucket per tenant; plain ad-hoc configs still in the ORM pool |
PoolConfig::$maxTenants, key max_tenants |
PoolConfig::$maxPools, key max_pools |
config('database.tenant_pool') (ORM pool) |
config('database.orm_pool'); tenant_pool is now read by loongs/saas for the tenant pools |
env ORM_POOL_MAX_TENANTS |
ORM_POOL_MAX_POOLS (tenant pools: TENANT_POOL_*) |
Orm::poolStats() keys tenants, evicted_tenants |
pools, evicted_pools (tenant pools: Tenancy::poolStats()) |
ConnectionResolver::TENANT_KEY |
removed (scope key lives in loongs/saas) |
LeaseSource Framework / Pool |
+ Provider (leases from a LeaseProvider, e.g. tenant pools) |
| — | new: Orm::resolveDefaultUsing(), Orm::addLeaseProvider() / removeLeaseProvider(), LeaseProvider, ConnectionPool::has() / forget(), PoolConfig::envOverrides(), Context::keys($prefix) |
Defaults are unchanged (size 8, 64 buckets, idle 60 s, ttl 600 s, wait 3 s, validate), so an existing
'tenant_pool' => [...] block keeps its effect on tenants once loongs/saas is installed; rename it to orm_pool if
it was meant for plain ad-hoc connections.
License: MIT
Project config
On install into a Loongs app, missing config/*.php stubs from this package are copied into the project config/ directory (never overwritten). The app root should run bin/publish-loongs-configs.php from Composer post-autoload-dump (see loongs/uuims / loongs/loongs templates).