NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

Attributes::fake() swaps the manager for a recording fake — in the facade and in the container, so injected AttributesManagers, the owner handle, the staged writer and every HasAttributes model method are recorded. It’s pass-through: writes still hit your test database, so reads, validation and history behave normally, and each write is recorded once it succeeds. Definitions keep using the real registry and aren’t recorded.

It returns the fake (RoundlyConsulting\Attributes\Testing\AttributesFake) for assertions:

use App\Models\Product;
use RoundlyConsulting\Attributes\Facades\Attributes;

it('records attribute writes', function () {
    $fake = Attributes::fake();
    $product = Product::factory()->create();

    $product->attachAttribute('color', 'red');                    // model method — recorded
    Attributes::for($product)->meta('color', ['hex' => '#f00']);
    Attributes::for($product)->forget('size');
    Attributes::for($product)->sync(['color' => 'red', 'rating' => 5]);
    Attributes::prune(30);

    $fake->assertSet($product, 'color', 'red');                   // with exactly this value
    $fake->assertSet($product, 'color');                          // with any value
    $fake->assertMetaSet($product, 'color', ['hex' => '#f00']);
    $fake->assertForgotten($product, 'size');
    $fake->assertSynced($product, ['rating' => 5, 'color' => 'red']); // exact set, any order
    $fake->assertPruned();

    expect($product->fresh()->getAttachedAttributeValue('rating'))->toBe(5); // writes still hit the database
    expect($fake->recorded())->toHaveCount(5);                                 // list<RecordedWrite>
});

it('writes no attributes on a read-only page', function () {
    $fake = Attributes::fake();

    $this->get('/products')->assertOk();

    $fake->assertNothingSet();
    $fake->assertNothingForgotten();
    $fake->assertNothingSynced();
    $fake->assertNothingMetaSet();
    $fake->assertNothingPruned();
    $fake->assertNothingWritten(); // covers every verb at once
});

Assertions

AssertNegativeRecords
assertSet($owner, $name, $value?)assertNothingSet()set(), setMany() and stage()->save() (one per attribute), attachAttribute(s)(). The value is compared (===) only when you pass it — null included.
assertForgotten($owner, $name)assertNothingForgotten()forget() and forgetExcept() (one per removed name), detachAttribute(s)(), destroyAttributes*().
assertSynced($owner, ?array $attributes)assertNothingSynced()sync(), stage()->sync(), syncAttributes(). With a map, the exact name => value set, any order.
assertMetaSet($owner, $name, ?array $meta)assertNothingMetaSet()meta(), syncAttributeMeta().
assertPruned()assertNothingPruned()prune() and attributes:prune.
—assertNothingWritten()Any write at all.

$fake->recorded() returns the raw list of RecordedWrite objects (verb, owner, name, value, meta, attributes, forceDelete) when you need a check the assertions don’t cover.

Testing against real rows

Because the fake is pass-through, you need the tables either way — the package is plain Eloquent. Publish the migrations into your app as in Installation and use RefreshDatabase:

use App\Models\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use RoundlyConsulting\Attributes\DataTransferObjects\AttributeDefinitionData;
use RoundlyConsulting\Attributes\Enums\AttributeType;
use RoundlyConsulting\Attributes\Events\AttributeAttached;
use RoundlyConsulting\Attributes\Exceptions\InvalidAttributeValueException;
use RoundlyConsulting\Attributes\Facades\Attributes;

uses(RefreshDatabase::class);

it('stores a typed rating', function () {
    Event::fake([AttributeAttached::class]);

    $product = Product::factory()->create();
    $product->attachAttribute('rating', 5);

    expect($product->getAttachedAttributeValue('rating'))->toBe(5);
    Event::assertDispatched(AttributeAttached::class);
});

it('rejects an out-of-range rating', function () {
    Attributes::define(new AttributeDefinitionData('rating', AttributeType::Integer, rules: ['min:1', 'max:5']));

    Product::factory()->create()->attachAttribute('rating', 9);
})->throws(InvalidAttributeValueException::class);

it('filters products by attribute', function () {
    $white = Product::factory()->create()->attachAttribute('color', 'white');
    Product::factory()->create()->attachAttribute('color', 'black');

    expect(Product::query()->whereAttribute('color', 'white')->pluck('id')->all())
        ->toBe([$white->id]);
});

Seeding rows with the factory

The Attribute model ships a factory with integerValue(), booleanValue() and withMeta() states. Link it to an owner through the owner morph relation:

use RoundlyConsulting\Attributes\Models\Attribute;

Attribute::factory()
    ->for($product, 'owner')
    ->integerValue(5)
    ->withMeta(['source' => 'seed'])
    ->create(['name' => 'rating']);

Attribute::factory()->for($product, 'owner')->booleanValue()->create(['name' => 'on_sale']);

Tips

  • Config definitions are loaded once at boot — register test-specific definitions with Attributes::define() instead of changing config.
  • The registry is a container singleton; reset it within a test with Attributes::forget() or Attributes::flush().
  • Strict mode and history are read at call time, so config()->set('attributes.strict', true) or config()->set('attributes.history.enabled', true) inside a test takes effect immediately.
  • Encrypted definitions need an application key — make sure APP_KEY is set in your testing environment.

The package’s own suite runs with:

composer test

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.