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

Eloquent Model Tester Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

1. One test method per concern, not per model

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 */ }

2. Use the strict variants

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).

3. Pin mass assignment with 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.

4. Test relations in both directions

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');

5. Cover polymorphics and pivots

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.

6. Data-provider sweep for boilerplate

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],
        // ...
    ];
}

7. Pest

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');

8. Treat it as a PR gate, not a replacement for behaviour tests

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.

Gotchas and Tips

  • The README misspells the trait. Every usage example says 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.
  • The factory generation step is vestigial. The README tells you to run 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.
  • Subset vs. strict is a real footgun. 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.
  • Connection and table prefix. 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.
  • SQLite vs. MySQL differences. With 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.
  • Relation assertions verify the method shape, not the semantics. They confirm 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.
  • Maturity check before you commit. The package's codebase reads as Laravel 6-era (Travis CI, StyleCI badges), with a recent release date but zero dependents. Verify PHPUnit/Laravel version constraints against your app before adding it to a large suite — a PHPUnit major bump is the likely breakage point. It's --dev-only and trait-based, so removal is trivial if it stops being maintained, which lowers the risk considerably.
  • Keep these out of your unit suite. They need a database. Put them in tests/Feature/Models (or a dedicated tests/Models group added to phpunit.xml) so --testsuite=Unit stays fast and DB-free.
  • Debugging a failure: the assertion messages name the offending column or relation, so start there. If a relation assertion fails and you're sure the relation is right, check (a) the related model's namespace, (b) that the migration for the foreign key column actually ran, and (c) the relation method's name — the package derives the default name from the related model class, so a custom method name requires the explicit override argument.
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.
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib