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

Comments::fake() swaps in a recording CommentsFake — behind the facade and the container, so injected CommentsManagers get it too. It still performs every operation — rows are written, policies and locks apply, events fire — and records each successful mutation, however it was made: the facade, an injected manager, the builder, a query’s bulk moderation, $user->writeComment() or $comment->lockReplies():

use RoundlyConsulting\Comments\Facades\Comments;
use RoundlyConsulting\Comments\Models\Comment;

$fake = Comments::fake();

// ... run the code under test ...

$fake->assertPosted(fn (Comment $comment) => $comment->comment === 'Hi');
$fake->assertApproved($comment);
$fake->assertLocked($post);
$fake->assertThreadLocked();
$fake->assertNothingDeleted();

Every assertion

Each assertion takes an optional model (matched with is()) or a callback that returns true on a match; with no argument, any recorded call passes. They are also callable statically once faked, e.g. Comments::assertPosted():

Recorded byAssertAssert none
on()->post(), write(), writeComment()assertPosted()assertNothingPosted()
update()assertUpdated()assertNothingUpdated()
delete(), deleteAll()assertDeleted()assertNothingDeleted()
restore()assertRestored()assertNothingRestored()
approve(), approveAll()assertApproved()assertNothingApproved()
hide(), hideAll()assertHidden()assertNothingHidden()
lock($subject)assertLocked()assertNothingLocked()
unlock($subject)assertUnlocked()assertNothingUnlocked()
lockThread(), lockReplies()assertThreadLocked()assertNothingThreadLocked()
unlockThread(), unlockReplies()assertThreadUnlocked()assertNothingThreadUnlocked()
transfer($from, $to)assertTransferred()assertNothingTransferred()
$fake = Comments::fake();

$user->writeComment($post, 'Hi');                  // recorded by the trait path too
Comments::update($comment, 'Edited');
Comments::for($post)->pending()->approveAll();     // one record per approved row
Comments::hide($spam);
Comments::delete($old);
Comments::restore($old);
Comments::lock($post);
Comments::unlock($post);
$comment->lockReplies();
$comment->unlockReplies();
Comments::transfer($duplicate, $original);

$fake->assertPosted(fn (Comment $c) => $c->comment === 'Hi');
$fake->assertUpdated($comment);
$fake->assertApproved();                           // any approval at all
$fake->assertHidden($spam);
$fake->assertDeleted($old);
$fake->assertRestored($old);
$fake->assertLocked($post);
$fake->assertUnlocked(fn ($subject) => $subject->is($post));
$fake->assertThreadLocked($comment);
$fake->assertThreadUnlocked($comment);
$fake->assertTransferred($duplicate, $original);

// In a test where nothing should change:
$fake->assertNothingPosted();
$fake->assertNothingUpdated();
$fake->assertNothingDeleted();
$fake->assertNothingRestored();
$fake->assertNothingApproved();
$fake->assertNothingHidden();
$fake->assertNothingLocked();
$fake->assertNothingUnlocked();
$fake->assertNothingThreadLocked();
$fake->assertNothingThreadUnlocked();
$fake->assertNothingTransferred();

A call that throws records nothing, and reads — isLocked(), isThreadLocked(), blocklisted() and queries — are not recorded. The auto-hide listener moderates on the system’s behalf without a user, so it bypasses the manager and is not recorded — assert CommentHidden with Event::fake() instead.

Asserting transfers

assertTransferred() matches the source with its first argument and, when given, the target with the second — assertTransferred(to: $original) checks only the target. Its callback receives the source, the target and the count:

use Illuminate\Database\Eloquent\Model;
use RoundlyConsulting\Comments\Facades\Comments;

$fake = Comments::fake();

Comments::transfer($duplicate, $original);

$fake->assertTransferred();                      // any transfer
$fake->assertTransferred($duplicate);            // from $duplicate, onto anything
$fake->assertTransferred($duplicate, $original); // from $duplicate onto $original
$fake->assertTransferred(to: $original);         // onto $original, from anything
$fake->assertTransferred(fn (Model $from, Model $to, int $count) => $count === 4);

Factories

Comment::factory(), CommentMention::factory() and CommentLock::factory() ship with the package, and Comment::factory() builds your configured comments.model. Its states cover every shape of comment — on($subject), reply($parent), by($author), pending(), hidden(), locked() and meta($meta). Give a comment its subject with on($subject), reply($parent) — which takes the parent’s subject — or Eloquent’s for($subject, 'commentable'):

use RoundlyConsulting\Comments\Models\Comment;
use RoundlyConsulting\Comments\Models\CommentLock;
use RoundlyConsulting\Comments\Models\CommentMention;

$root = Comment::factory()->on($post)->create();

Comment::factory()->on($post)->pending()->create();
Comment::factory()->on($post)->hidden()->create();
Comment::factory()->on($post)->locked()->create();
Comment::factory()->on($post)->meta(['tickets' => ['internal' => true]])->create();
Comment::factory()->reply($root)->by($user)->create(); // inherits the root's subject
Comment::factory()->for($post, 'commentable')->create(); // the plain Eloquent form works too

Comment::factory()->create(); // no subject: written on a new user of your auth model

CommentMention::factory()->create(['comment_id' => Comment::factory()->on($post)]);
CommentLock::factory()->on($post)->create(); // Comments::isLocked($post) === true

A comment with no subject still works — a bare Comment::factory()->create(), and the comment CommentMention::factory()->create() makes for its comment_id. It is written on a new user of your auth model (the default guard’s auth.providers.<provider>.model), built through that model’s own factory, so a standard app with App\Models\User and its UserFactory needs no setup. Each comment gets its own user unless you pass one with recycle($user), and like any parent factory, the user is created by make() too.

If the default guard has no Eloquent user model, that model has no factory, or an enforced morph map leaves it out, the factory throws a LogicException that points you to on($subject). A Testbench suite is in that position by default — its auth model, Illuminate\Foundation\Auth\User, has no factory — so set your own user model in auth.providers.users.model, or give each comment a subject. CommentLock::factory() works the same way: lock a subject with on($subject) or for($subject, 'lockable'); a bare lock goes on a new user of your auth model.

Database assertions

Use the AssertsComments trait in a test case for readable expectations:

namespace Tests\Feature;

use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use RoundlyConsulting\Comments\Facades\Comments;
use RoundlyConsulting\Comments\Testing\AssertsComments;
use Tests\TestCase;

final class PostCommentsTest extends TestCase
{
    use AssertsComments;
    use RefreshDatabase;

    public function test_a_user_can_comment_on_a_post(): void
    {
        $post = Post::factory()->create();
        $user = User::factory()->create();

        Comments::on($post)->as($user)->body('expected body')->post();

        $this->assertCommented($post);
        $this->assertCommented($post, 'expected body');
        $this->assertCommentCount($post, 1);
    }

    public function test_a_new_post_has_no_comments(): void
    {
        $this->assertNotCommented(Post::factory()->create());
    }
}

The assertions count every non-deleted comment on the subject, whatever its status or depth, and query through your configured comments.model — so they see its table, connection and global scopes.

Faking events

use Illuminate\Support\Facades\Event;
use RoundlyConsulting\Comments\Events\CommentApproved;
use RoundlyConsulting\Comments\Events\CommentCreated;
use RoundlyConsulting\Comments\Facades\Comments;

Event::fake([CommentCreated::class, CommentApproved::class]);

$comment = Comments::on($post)->as($user)->body('Hello')->post();
Comments::approve($comment);

Event::assertDispatched(CommentCreated::class, fn ($e) => $e->comment->is($comment));
Event::assertDispatched(CommentApproved::class);

Run the suite with the package’s migrations published — and those of Likes, Reports, Approvals and Media Library — for example with RefreshDatabase.

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.