calhoun/eloquent-otf
Eloquent OTF lets Laravel developers define Eloquent relationships on the fly, adding hasOne, hasMany, belongsTo, and similar relations without editing model classes. Useful for packages, dynamic dashboards, and flexible apps. It keeps models clean while enabling runtime relationship configuration.
calhoun/eloquent-otf ("on the fly") is aimed at the case where you have a database table but no dedicated Eloquent model class — legacy schemas, per-tenant or per-period table names, reporting tables, or throwaway read queries. Instead of generating and committing a class per table, you get a model instance/class configured at runtime.
Minimal steps:
composer require calhoun/eloquent-otfphp artisan vendor:publish --provider="Calhoun\EloquentOtf\..." if there is a config file). Look at config/eloquent-otf.php for defaults around connection, primary key, timestamps, and base model.src/ and read, in this order:
Illuminate\Database\Eloquent\Model),tests/ directory — the fastest, most accurate documentation of intended usage.$orders = EloquentOTF::table('legacy_orders')
->key('order_id')
->timestamps(false)
->make();
$orders::query()->where('status', 'open')->limit(50)->get();
The mental model to hold onto: this package is a factory that returns a configured Eloquent model class, not a new query builder. Everything downstream — scopes, where, with, chunk, toArray — behaves exactly as Eloquent does, because it is Eloquent. That also means every Eloquent assumption about keys, timestamps, casts, and fillable still applies and is now your responsibility.
Wrap it, don't sprinkle it. OTF calls are cheap to write and easy to litter. Put each runtime table behind a small named factory or repository method so the rest of the app sees a normal class-like API:
final class LegacyOrders
{
public static function query(): Builder
{
return EloquentOTF::table('legacy_orders')
->key('order_id')
->timestamps(false)
->make()::query();
}
}
Cache the built class. Building a model class on every request (or inside a loop) is wasteful and can cause subtle issues if the package generates/evals classes. Memoize per table name in a container singleton or a static registry, and — critically — memoize per connection too if you run multi-tenant.
Sharded / periodic tables. This is the strongest fit. For events_2024_01, events_2024_02, … or tenant_42_orders, derive the table name and hand it to the factory; keep the query layer identical across shards:
$model = EloquentOTF::table("events_{$year}_{$month}")
->connection($tenant->connection)
->make();
Then use ->newQuery() for the union/merge patterns and cursor()/chunkById() for cross-shard iteration. Use chunkById rather than chunk when the key is non-standard, since offset paging on large legacy tables will drift.
Explicit schema contract. Since there is no model file to read, declare the important facts at the call site so they're greppable: primary key, incrementing/non-incrementing, timestamps on/off, connection, and $casts for dates and JSON columns stored as text. Treat that block as your "model file" and review it in PRs like one.
Relationships. You can still define relations on runtime models via closures or by having the factory return a subclass of your own base model (the package's documented extension point). If relationships matter, prefer defining a thin real model that extends the factory-produced one, or attach the relation closures once in a service provider. Don't try to build deep with() graphs from raw OTF calls — it gets unreadable fast.
Read-only discipline. Most OTF tables don't have your app's conventions (no updated_at, no auto-increment key, no guarded-friendly columns). Use these models for reads, reporting, and imports. For writes, set $guarded = [] deliberately and wrap in a transaction — never let an OTF model be mass-assigned from $request->all().
Testing. Because the "model" is config, test the factory output rather than the query: assert the resolved table, connection, key, and timestamp flags, then hit an in-memory SQLite schema you create in setUp. Keep one integration test against the real legacy schema shape.
composer.json and read the source before upgrading. Don't put it on a critical write path without a fallback.created_at/updated_at. If you forget timestamps(false), inserts fail or silently write columns that don't exist. Same for incrementing, keyType (string keys), and perPage.$fillable is empty by default, so create() and update() quietly no-op or throw MassAssignmentException. Set guarded/fillable explicitly — quiet no-ops are a classic debugging sink here.decimal:2 or handle as strings.with() and polymorphic relations key off class name. Memoize the class, don't just memoize the query.get_class($model), $model->getTable(), $model->getConnectionName(), and ->toSql() with bindings. When a query returns nothing, check the connection before the where clause — the default connection is the most common silent failure.@property stubs or a real (thin) model class for tables you touch often; OTF is a convenience, not a replacement for a model when the table is core to your domain.getTable, getKeyName, casts), a container binding that resolves the factory, and per-table config in the published config file. Avoid forking the package — the surface is small enough that composition covers most needs.How can I help you explore Laravel packages today?