sairahcaz/laravel-dynamic-model
Laravel Dynamic Model lets you create Eloquent models at runtime for any database table without generating model classes. Configure table names, use familiar Eloquent queries and CRUD, and work with dynamic schemas in Laravel apps.
Install with Composer and check the version constraint first — v4.0.0 is a Laravel 13 release. If the app is still on Laravel 11/12, pin ^3.0 and revisit later; the major bump exists to track the framework, not to introduce new API.
composer require laracraft-tech/laravel-dynamic-model
Where to look first:
src/ for the base model/trait — this package is thin, so reading the whole source takes minutes and pays for itself the first time a schema cache goes stale.php artisan vendor:publish --tag="dynamic-model-config".First use case to reach for it: a table that only exists at runtime. Classic examples are per-tenant tables created by a provisioning job, staging/import tables generated by an ETL or CSV upload, and legacy databases where table names are data rather than code. The minimal working flow is: point the model at a table name, let it read the schema, then use normal Eloquent as if the model had been hand-written:
$model = new DynamicProduct();
$model->setTable('tenant_42_products'); // confirm exact method in README
$model->newQuery()->where('status', 'active')->paginate(50);
The value proposition is that you keep Eloquent's query builder, casts, accessors, events, and relationships instead of dropping to DB::table() everywhere.
One base class per "shape" of dynamic table. Don't make a single god-model switch tables for unrelated data. Create a base class per table family (DynamicProduct, DynamicInvoice) with the casts, accessors, and relations that are stable across all instances, then swap only the table name. Casts and accessors are schema-independent, so they survive the swap; $fillable is not.
Resolve the table name in one place. Put table resolution behind a repository or a small factory service, not scattered through controllers. Tenancy, prefixes, and year-sharding all become one method:
final class TenantTableResolver
{
public function productsFor(Tenant $tenant): DynamicProduct
{
return (new DynamicProduct())->setTable("tenant_{$tenant->id}_products");
}
}
This is also the only place you need to audit for injection risk — see Gotchas.
Guard mass assignment explicitly. Dynamic tables means unknown columns, which tempts $guarded = []. Don't. Accept a whitelist of writable columns from the schema or from your own config and fill() only those. A dynamic table reached by a user-controlled payload is a mass-assignment hole with extra steps.
Handle the schema cache deliberately. The package caches column listings per table, which is what makes dynamic models fast enough to use in a loop. The cost is that ALTER TABLE after a provisioning job leaves you with stale columns. Wire cache invalidation into the same job that runs the DDL — flush the table's cache entry (or the whole store) at the end of the migration/provisioning step. If you cannot, shorten the TTL in config for those environments.
Relationships work, but the table must be set before the query is built. belongsTo/hasMany from a dynamic model are fine because the parent's table is already resolved when the relation executes. Eager loading nested dynamic relations is where people get bitten — resolve the table in a query scope or repository method that runs before with() is hydrated, and prefer Model::resolveRelationUsing() registered at boot when the relation target's table is stable.
Testing: create the real table. Don't mock the schema layer. In a test, run Schema::create('tenant_test_products', ...), set the table, assert, then Schema::dropIfExists(...) in teardown. Use RefreshDatabase plus a small trait that creates fixture tables. This catches the cache-staleness and column-detection bugs that mocks hide.
Reach for DB::table() when you're read-only and one-shot. If a job just aggregates a runtime table and never needs casts, events, or relations, the plain query builder is honest and dependency-free. Reserve the dynamic model for places where you genuinely want Eloquent semantics.
Static/instance table state leaks in long-running processes. Under Octane, Horizon, or a queue worker handling many tenants, a model resolved for tenant A can be reused for tenant B if you stash the table on a shared instance or static. Always construct or clone a fresh model per unit of work inside the job/request; never rely on a container singleton holding a resolved dynamic model.
Never interpolate user input into a table name. Table names cannot be bound as parameters. Whitelist against a known set (tenant IDs from your own DB, a prefix pattern validated with a regex), or resolve via an allowlist map. This is the single highest-severity mistake available with this package.
The package does not manage schema. It reads and caches columns; it does not migrate. Your provisioning code is responsible for DDL, indexes, and — importantly — cache invalidation ordering. Create the table first, then resolve the model, or you'll cache an empty column set.
Column cache keys are per table (and per connection in most setups). If you have multiple database connections, verify which connection the model resolves before trusting the cached columns; a same-named table on two connections is a real footgun in multi-tenant setups with a shared and a tenant connection.
Debug by printing the table before the query. Most "column not found" and "no such table" reports are simply the model pointing somewhere unexpected. $model->getTable() and $model->toSql() immediately after resolution will save you an hour.
Missing columns can look like missing attributes. If the cache was populated before a column existed, writes silently drop the attribute and reads return null rather than erroring — the classic stale-schema symptom. When an attribute "disappears," flush the schema cache before debugging anything else.
Extension points are shallow by design. There's no plugin system; customise by subclassing the base model and overriding table/column resolution. Keep those overrides in your app, not in a fork, so v4 → v5 upgrades stay trivial.
Upgrading to v4: the diff is Laravel 13 support plus CI dependency bumps — no application-level API change is signalled. The real work is the upgrade chain: confirm every other package in the app supports Laravel 13 first, update the CI matrix and composer.json constraint together, and keep a ^3.0 branch alive if you deploy multiple apps on different framework versions. Because this is a 0.x-style small-surface package, expect the upgrade to be a version-constraint exercise rather than a code change — but run the dynamic-table test suite regardless, since schema-cache behaviour is exactly the kind of thing that shifts quietly across major framework releases.
How can I help you explore Laravel packages today?