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

Tags::fake() swaps the manager for a TagsFake and returns it:

use RoundlyConsulting\Tags\Facades\Tags;
use RoundlyConsulting\Tags\Models\Tag;

it('labels a concert', function () {
    $tags = Tags::fake();

    $concert = Concert::factory()->create();
    $concert->attachTags(['live'], 'label');           // what your action does inside

    $tags->assertTagged($concert, ['live'], type: 'label');
    $tags->assertNothingUntagged();
    $tags->assertTagCreated(fn (Tag $tag) => $tag->slug === 'live');
});
  • It is a recording, still-performing fake: every call runs for real — rows, counts, events — and each successful operation is recorded. A call that throws records nothing.
  • It is swapped in behind the facade and in the container, and it extends TagsManager, so an injected manager is faked and still type-checks. It records the facade, an injected manager, a handle, a builder, a model method and the JSON API; calling an action class directly is not recorded.
  • Because it performs, a test using the fake needs the database (the published migrations) like any other.
  • It does not fake Laravel’s events — use Event::fake() next to it.

Assertions

Assertions come in pairs: assert<Verb>() with assertNothing<Verb>() for the tagging verbs, assert<Noun><Verb>() with assertNo<Noun><Verb>() for the catalog. Every one is documented on the facade too, so Tags::assertTagged(…) works:

AssertionNegativeMeaning
assertTagged($model, ?array $tags = null, $type = null)assertNothingTagged()Tags attached by an attach or a sync — optionally these (models, or names in the current locale) of this type.
assertUntagged($model, ?array $tags = null, $type = null)assertNothingUntagged()Tags detached by a detach, a sync or a clear.
assertSynced($model, $type, ?array $tags = null)assertNothingSynced()The type was synced — with tags, the model now carries exactly these.
assertCleared($model, $type = null)assertNothingCleared()A clear of the type (null: any) — the force-delete hook’s clear counts.
assertTagCreated(?Closure $callback = null)assertNoTagCreated()create(), a findOrCreate() that created, or a free-form name an attach or sync created. A restored tag counts as restored.
assertTagUpdated(?Closure $callback = null)assertNoTagUpdated()update(), rename() or edit()->save().
assertTagMoved(?Closure $callback = null)assertNoTagMoved()move().
assertTagDeleted(?Closure $callback = null)assertNoTagDeleted()delete().
assertTagRestored(?Closure $callback = null)assertNoTagRestored()restore(), or a deleted tag a create, findOrCreate, attach or sync brought back.
assertTagsReordered($type = null)assertNoTagsReordered()reorder(), optionally of one type.
assertRepaired(?Closure $callback = null)assertNothingRepaired()Tags::repair() or tags:repair; the callback gets the RepairReport.
assertNothingHappened()—No operation was recorded at all.
$tags->assertTagged($concert);                              // any tag attached (an attach or a sync)
$tags->assertTagged($concert, [$jazz, 'live'], 'label');   // these tags — models, or names in the current locale
$tags->assertUntagged($concert, ['outdoor'], 'label');     // a detach, a sync or a clear
$tags->assertSynced($concert, 'genre', [$jazz]);           // synced, and the model now carries exactly these
$tags->assertCleared($concert, 'label');                   // null type = any clear (a force delete's included)

$tags->assertTagCreated(fn (Tag $tag) => $tag->type === 'genre');
$tags->assertTagUpdated();
$tags->assertTagMoved();
$tags->assertTagDeleted();
$tags->assertTagRestored();
$tags->assertTagsReordered('genre');
$tags->assertRepaired(fn (RepairReport $report) => $report->isClean());

$tags->assertNothingHappened();

Recorded operations

$fake->recorded(?TagOperation $operation = null) returns the raw RecordedTagOperation records in call order. TagOperation lists every mutating verb: attach, detach, sync, clear, create_tag, find_or_create_tag, update_tag, move_tag, reorder_tags, delete_tag, restore_tag and repair:

use RoundlyConsulting\Tags\Enums\TagOperation;

foreach ($tags->recorded(TagOperation::Sync) as $operation) {
    $operation->operation;        // TagOperation::Sync
    $operation->context;          // ['taggable' => …, 'tags' => […], 'type' => 'genre']
    $operation->result;           // TagChanges
    $operation->changes();        // TagChanges, or null for a catalog operation
    $operation->concerns($concert);
    $operation->changesType();    // 'genre'
}

Pest matchers

// tests/Pest.php
use RoundlyConsulting\Tags\Testing\TagExpectations;

TagExpectations::register();

// any test
expect($concert)->toHaveTags(['live', 'outdoor'], 'label');
expect($concert)->toHaveNoTags('genre');
expect($concert)->toHaveNoTags();

toHaveTags() checks each tag with hasTag() — a model, a key, a name or a slug; toHaveNoTags() checks one type, or every type. register() defines nothing when Pest’s expect() does not exist.

The tag factory

use RoundlyConsulting\Tags\Models\Tag;

$music = Tag::factory()->ofType('genre')->named(['en' => 'Music', 'sk' => 'Hudba'])->create();
$jazz = Tag::factory()->under($music)->named('Jazz')->create();      // type, scope, path and depth from the parent
$ours = Tag::factory()->ofType('label')->within($team)->create();
$any = Tag::factory()->create();                                     // type tag, a unique name in the current locale

Factory rows skip the package’s actions — no events, no usage counts, no tree lock — so create through Tags::type() where a test needs those. The factory builds the configured tag model. For taggable models, tag in afterCreating (see Taggable models).

Testing an app

  • No special harness: the published migrations are ordinary migrations, so RefreshDatabase works on SQLite 3.38+ in memory as well as on MySQL or PostgreSQL.
  • Keep the test locale in translatable.locales, or tagging by name throws.
  • Define the Gate abilities your API tests rely on (tags.manage, tags.scope) — undefined abilities deny.
  • The API is off unless TAGS_ROUTES=on is set before the app boots — use on / off values in phpunit.xml, because PHPUnit turns false into an empty string, which means the default.
<php>
    <env name="TAGS_ROUTES" value="on"/>
</php>

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.