codenco-dev/eloquent-model-tester
Test Eloquent models in Laravel with codenco-dev/eloquent-model-tester. It verifies database table structures, relationships, fillable and guarded fields, and more using factories and the simple HasModelTester trait, helping you catch model issues early with less boilerplate in your test suite.
Install (dev only) and wire it into a base test case.
composer require codenco-dev/eloquent-model-tester --dev
Rather than repeating use RefreshDatabase; use HasModelTester; in every model test, create one small base class and extend it:
// tests/Feature/Models/ModelTestCase.php
namespace Tests\Feature\Models;
use CodencoDev\EloquentModelTester\HasModelTester;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
abstract class ModelTestCase extends TestCase
{
use RefreshDatabase;
use HasModelTester;
}
First test to write. Pick the model with the most moving parts (usually User) and write a single test that covers the whole contract: schema, mass-assignment, timestamps, and relations. This one test is what you'll run first whenever a migration or relation changes.
// tests/Feature/Models/UserTest.php
namespace Tests\Feature\Models;
use App\Models\Phone;
use App\Models\User;
class UserTest extends ModelTestCase
{
public function test_user_model_contract(): void
{
$this->modelTestable(User::class)
->assertHasOnlyColumns([
'id', 'name', 'email', 'password', 'remember_token',
'created_at', 'updated_at',
])
->assertHasTimestampsColumns()
->assertCanOnlyFill(['name', 'email', 'password'])
->assertNoGuardedAndFillableFields()
->assertHasHasManyRelation(Phone::class);
}
}
Run it with php artisan test --filter=UserTest. If it passes, roll the same shape out to every model — that's where the day-to-day value is: a cheap, fast "contract test" per model that fails loudly when someone edits a migration, drops a $fillable entry, or renames a relation method.
Where to look first in the README: the structure + fillable/guarded section and the relations section. Skim the rest (morphs, hasManyThrough, scopes) and come back when you actually have those relations.
Don't cram everything into a single test_.... Split so a failure names the problem immediately:
public function test_user_has_expected_columns(): void { /* assertHasOnlyColumns + timestamps */ }
public function test_user_mass_assignment_is_locked_down(): void { /* assertCanOnlyFill + assertNoGuardedAndFillableFields */ }
public function test_user_relations(): void { /* relation assertions */ }
assertHasColumns() and assertHasColumnsInFillable() are subset checks — they won't notice a column being added. Prefer assertHasOnlyColumns(), assertHasOnlyColumnsInFillable(), assertHasOnlyColumnsInGuarded(). Subset versions are only useful for models where you deliberately don't want to pin the full shape (e.g. a shared/legacy table).
assertCanOnlyFill()This is the highest-signal assertion in the package for security reviews. It fails if anything is in $fillable that you didn't list, and if a listed field is also in $guarded:
$this->modelTestable(User::class)
->assertCanOnlyFill(['name', 'email', 'password']);
Pair it with assertNoGuardedAndFillableFields() on models that maintain both arrays by hand — that overlap is a classic silent bug.
The package expects the inverse pair to be asserted on each side. Do both:
// UserTest
$this->modelTestable(User::class)->assertHasHasManyRelation(Phone::class);
// PhoneTest
$this->modelTestable(Phone::class)->assertHasBelongsToRelation(User::class);
For non-conventional naming, pass the overrides explicitly — this doubles as documentation of the keys you chose:
$this->modelTestable(Customer::class)
->assertHasBelongsToRelation(Category::class, 'category', 'category_id');
$this->modelTestable(Category::class)
->assertHasHasManyRelation(Customer::class, 'customers');
Morphs are easy to get subtly wrong (wrong *_type column, morphMany vs morphOne), so always assert both directions:
$this->modelTestable(Post::class)
->assertHasHasManyMorphRelation(Comment::class, 'comments');
$this->modelTestable(Comment::class)
->assertHasBelongsToMorphRelation(Post::class, 'commentable')
->assertHasBelongsToMorphRelation(Video::class, 'commentable');
For pivot tables with no model class, tableTestable() is the only way in:
$this->tableTestable('role_user')
->assertHasOnlyColumns(['user_id', 'role_id']);
This is genuinely useful for belongsToMany joins that nobody ever writes a test for.
Timestamps and assertNoGuardedAndFillableFields apply to almost every model. A provider-driven test gives you blanket coverage for free and catches the model someone added last sprint:
/** @dataProvider models */
public function test_every_model_has_timestamps(string $model): void
{
$this->modelTestable($model)->assertHasTimestampsColumns();
}
public static function models(): array
{
return [
'user' => [\App\Models\User::class],
'post' => [\App\Models\Post::class],
// ...
];
}
The trait works fine under Pest — register it once:
// tests/Pest.php
uses(\Tests\Feature\Models\ModelTestCase::class)->in('Feature/Models');
// or, if you keep plain TestCase:
uses(\CodencoDev\EloquentModelTester\HasModelTester::class)->in('Feature/Models');
The natural workflow: any PR that touches a migration, a $fillable/$guarded array, or a relation signature must update the corresponding model contract test in the same commit. That's the whole point — the test file becomes the human-readable spec of the model's public shape, and CI enforces it.
use HasModelTestor; — the actual class is HasModelTester. This is the #1 thing that will bite you on first install; you'll get a Trait not found fatal, not a helpful assertion failure. Also note the class lives in CodencoDev\EloquentModelTester.php artisan make:model MyModel -mf first. None of the assertions shown actually need a factory — they're schema introspection and reflection based. Skip it and save yourself the noise, unless you're also writing persistence tests.assertHasOnlyColumns requires you to list created_at/updated_at. The README flags this explicitly and it's the most common first failure. If a table has timestamps, include them, or you'll get a mystifying "unexpected columns" failure. Same for deleted_at — assertHasSoftDeleteTimestampColumns() needs both the SoftDeletes trait and the column.assertHasColumns() will keep passing after a column is dropped-from-your-expectations-but-still-present, and assertHasColumnsInFillable() will keep passing if someone adds is_admin to $fillable. Default to the Only variants and only relax deliberately.$guarded = ['*'] (the Laravel default) breaks assertCanOnlyFill. If the model uses the default guarded config, there is no fillable allow-list to assert; use assertHasColumnsInGuarded(['*']) or, better, convert the model to an explicit $fillable before adopting the assertion.modelTestable() should follow the model's $connection, but tableTestable('pivot_table') has no model to read a connection from — it uses the default. If you have multi-connection setups, verify behaviour against a second connection before relying on it, or wrap tableTestable in DB::connection('other')->... yourself.RefreshDatabase on :memory: SQLite the tests are fast, but SQLite's type affinity is loose. If any assertion seems to check declared column types, don't trust it to catch a string → text change the way MySQL would. Where the type genuinely matters, add an explicit migration assertion or a feature test that writes and reads the value.assertHasHasManyThroughRelation has no inverse. You can't assert the reverse from the orders side — a known limitation, not a bug in your model. Don't waste time trying.comments() exists and returns the right relation class pointing at the right model. They won't catch a missing where() constraint, a wrong orderBy, or a broken withDefault(). Test those by actually calling the relation.assertHasScope() only checks the method exists. Behaviour still needs a real query assertion. Use the package assertion to catch accidental deletions/renames during refactors, then test the filtering logic separately.--dev-only and trait-based, so removal is trivial if it stops being maintained, which lowers the risk considerably.tests/Feature/Models (or a dedicated tests/Models group added to phpunit.xml) so --testsuite=Unit stays fast and DB-free.How can I help you explore Laravel packages today?