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

Reviews::fake() swaps in a recording ReviewsFake — a subtype of ReviewsManager, so an injected manager gets it too — and returns it. Operations still run against the database, so reads, aggregates and events behave normally, while every mutation is recorded — whether it came through the facade, an injected manager, a for()->by() builder, the HasReviews / CanVoteOnReviews traits or a Review model method:

use RoundlyConsulting\Reviews\Facades\Reviews;
use RoundlyConsulting\Reviews\Models\Review;

it('records the review and the owner reply', function () {
    $fake = Reviews::fake();

    $restaurant = Restaurant::factory()->create();
    [$user, $owner] = User::factory()->count(2)->create();

    $review = $restaurant->addReview($user)->rating(5)->create();
    $review->respond($owner, 'Thanks!');

    $fake->assertReviewCreated();
    $fake->assertReviewCreated(fn (Review $review) => $review->rating === 5);
    $fake->assertReviewResponded(fn (Review $response, Review $parent) => $parent->is($review));
    $fake->assertNothingApproved();
});

Every assertion

AssertOppositeCallback receives
assertReviewCreated()assertNothingReviewed()the review
assertReviewApproved()assertNothingApproved()the review
assertReviewRejected()assertNothingRejected()the review
assertReviewUpdated()assertNothingUpdated()the review
assertReviewDeleted()assertNothingDeleted()the review
assertReviewResponded()assertNothingResponded()the response, the parent review
assertReviewVoted()assertNothingVoted()the review, the voter
assertReviewVoteRemoved()assertNoVoteRemoved()the review, the voter
assertReviewVerified()assertNothingVerified()the review
assertReviewUnverified()assertNothingUnverified()the review
use Illuminate\Database\Eloquent\Model;
use RoundlyConsulting\Reviews\Models\Review;

$fake->assertReviewCreated(fn (Review $review) => $review->verified);
$fake->assertReviewApproved(fn (Review $review) => $review->is($pending));
$fake->assertReviewRejected();
$fake->assertReviewUpdated();
$fake->assertReviewDeleted();
$fake->assertReviewResponded(fn (Review $response, Review $parent) => $parent->is($review));
$fake->assertReviewVoted(fn (Review $review, Model $voter) => $voter->is($user));
$fake->assertReviewVoteRemoved(fn (Review $review, Model $voter) => $voter->is($user));
$fake->assertReviewVerified();
$fake->assertReviewUnverified();

// The opposites pass when nothing of that kind was recorded:
$fake->assertNothingReviewed();
$fake->assertNothingApproved();
$fake->assertNothingRejected();
$fake->assertNothingUpdated();
$fake->assertNothingDeleted();
$fake->assertNothingResponded();
$fake->assertNothingVoted();
$fake->assertNoVoteRemoved();
$fake->assertNothingVerified();
$fake->assertNothingUnverified();

Every assert*() takes an optional callback and passes when at least one recorded call returns true. Only calls that change something are recorded: approving an approved review, rejecting a rejected one, verifying a verified one (or unverifying an unverified one) and removing a vote that was never cast are no-ops — no write, no event — so they are not. Because operations really run, keep the package’s migrations loaded (RefreshDatabase).

Pest expectations

Register the matchers once in tests/Pest.php:

// tests/Pest.php
RoundlyConsulting\Reviews\Testing\ReviewExpectations::register();

// In any test:
expect($product)->toHaveReview();
expect($product)->toHaveReview($author);
expect($product)->toHaveApprovedReview();

Both matchers look at top-level reviews only, so owner responses never satisfy them.

InteractsWithReviews

Mix the trait into a test case for actingAsReviewer() / reviewAs() ergonomics:

use RoundlyConsulting\Reviews\Testing\InteractsWithReviews;

uses(InteractsWithReviews::class);

it('queues a new review for moderation', function () {
    $review = $this->actingAsReviewer(User::factory()->create())
        ->reviewAs(Restaurant::factory()->create(), rating: 4, content: 'Great brunch');

    expect($review->isPending())->toBeTrue();
});

Factories

Both models ship factories with states for every shape of review:

use RoundlyConsulting\Reviews\Models\Review;
use RoundlyConsulting\Reviews\Models\ReviewVote;

$review = Review::factory()
    ->forReviewable($restaurant)
    ->byAuthor($user)
    ->approved()
    ->verified()
    ->rating(5)
    ->create();

Review::factory()->response($review)->byAuthor($owner)->approved()->create();
Review::factory()->forReviewable($restaurant)->withoutRating()->pending()->create();
Review::factory()->forReviewable($restaurant)->helpfulVotes(12, 3)->create();

ReviewVote::factory()->forReview($review)->byVoter($user)->unhelpful()->create();

Factories write rows directly — they skip validation, moderation and events. Use the facade when the behaviour under test depends on those.

The package’s own suite

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.