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

Shops::fake()

Shops::fake() swaps in RoundlyConsulting\Shops\Testing\ShopsFake and returns it. It is a spy, not a stub: every operation still runs — carts, orders and stock rows are real and events fire, and a charge still goes through the bound PaymentGateway (the default NullPaymentGateway charges nothing). Each state change is recorded, whether it came through the facade, an injected ShopsManager (the fake is a subtype, so injected managers get it too) or a model method such as $cart->add() or $order->markPaid(). A call that throws is not recorded.

use RoundlyConsulting\Shops\Cart\Cart;
use RoundlyConsulting\Shops\Facades\Shops;
use RoundlyConsulting\Shops\Orders\Order;
use RoundlyConsulting\Shops\Products\ProductVariant;
use RoundlyConsulting\Shops\Testing\CartChange;

it('adds to the cart, checks out and charges', function () {
    Shops::fake();   // a spy — carts, orders and stock rows are still real

    $variant = ProductVariant::factory()->withEurPrice('1990')->withStock(5)->create();
    $cart = Cart::factory()->create();

    $this->post("/cart/{$cart->id}/items", ['variant' => $variant->id, 'quantity' => 2]);
    $this->post("/cart/{$cart->id}/checkout")->assertRedirect();

    $order = Order::query()->latest('id')->firstOrFail();

    Shops::assertCartChanged($cart, CartChange::Added);   // Added, Updated, Removed or Cleared
    Shops::assertOrderPlaced($cart);                      // the stock checkout reserves is part of this
    Shops::assertCharged($order);                         // and so are the transitions a charge makes
    Shops::assertNothingTransitioned();
    Shops::assertNothingStockAdjusted();
});

Only the calls your code makes are recorded: the stock a checkout reserves is part of assertOrderPlaced(), and the transitions a successful charge makes are part of assertCharged(). Assert on the facade or on the returned fake:

use RoundlyConsulting\Shops\Facades\Shops;
use RoundlyConsulting\Shops\Inventory\Enums\StockReason;
use RoundlyConsulting\Shops\Orders\Enums\Status;
use RoundlyConsulting\Shops\Orders\Order;
use RoundlyConsulting\Shops\Products\ProductVariant;

it('ships an order and books a delivery', function () {
    $fake = Shops::fake();   // the ShopsFake — assert on it or on the facade

    $order = Order::factory()->paid()->create();
    $variant = ProductVariant::factory()->withStock(0)->create();

    $this->post("/admin/orders/{$order->number}/ship");
    $this->post("/admin/variants/{$variant->id}/receive", ['quantity' => 10]);

    $fake->assertTransitioned($order, Status::Fulfilled);   // any order / any status when omitted
    $fake->assertStockAdjusted($variant, delta: 10, reason: StockReason::Received);
    $fake->assertNothingPlaced();
    $fake->assertNothingCharged();
    $fake->assertNothingCartChanged();
});

Every assertion, with its negative:

AssertionNegativePasses when
assertOrderPlaced(?Cart $cart = null)assertNothingPlaced()Any checkout, or one from the cart.
assertCharged(?Order $order = null)assertNothingCharged()Any charge, or of the order — failed charges count too.
assertTransitioned(?Order $order = null, ?Status $to = null)assertNothingTransitioned()Any transition, narrowed by order and/or target status.
assertStockAdjusted(?ProductVariant $variant = null, ?int $delta = null, ?StockReason $reason = null)assertNothingStockAdjusted()Any inventory write, narrowed by variant, exact delta and/or reason.
assertCartChanged(?Cart $cart = null, ?CartChange $change = null)assertNothingCartChanged()Any cart change, narrowed by cart and/or CartChange (Added, Updated, Removed — also update($item, 0) — Cleared).

Provider fakes compose: shops resolves each action per call, so Coupons::fake() sees the redemption a checkout makes and Credits::fake() the store-credit debit a charge makes.

Model factories

Every shops model has a factory, reachable through Model::factory(). Use them in your tests and seeders:

ModelDefaultsStates
Shops\ShopUnique company name, no currencycurrency(string $currency)
Shops\TaxRateClass standard, 2000 bp, no countrydefault(), reduced(), forCountry(string $iso)
Products\ProductUnique name, random optional published_at, default variantwithPrice(string $price, string $currency = 'EUR'), withVariant(ProductVariantFactory $variant), published(), unpublished()
Products\ProductVariantNew product, random SKU, EUR or USD price, tracked, stock 0–100withEurPrice(string $price), untracked(), withStock(int $stock)
Products\ProductOptionNew product, Size, Colour or Material—
Products\ProductOptionValueNew option, a unique word—
Products\CategoryUnique name, random optional published_atpublished(), unpublished()
Inventory\StockAdjustmentNew variant, +1–50 Received—
Cart\CartUUID token, EUR—
Cart\CartItemNew cart, EUR price, quantity 1–5—
Orders\OrderStatus New, a notewithStatus(Status $status), inProgress(), paid(), fulfilled(), canceled(), withDiscount(...)
Orders\ItemNew order, EUR or USD price, quantity 1–10withEurPrice(string $price), withUsdPrice(string $price)

A full checkout without the fake — run it with the published migrations loaded (RefreshDatabase):

use Illuminate\Support\Facades\Event;
use RoundlyConsulting\Shops\Cart\Cart;
use RoundlyConsulting\Shops\Facades\Shops;
use RoundlyConsulting\Shops\Orders\Events\OrderPlaced;
use RoundlyConsulting\Shops\Products\Product;
use RoundlyConsulting\Shops\Products\ProductVariant;

it('places an order and reserves stock', function () {
    Event::fake([OrderPlaced::class]);

    $product = Product::factory()
        ->published()
        ->withVariant(ProductVariant::factory()->withEurPrice('1990')->withStock(5))
        ->create();
    $variant = $product->variants()->first();

    $cart = Cart::factory()->create();   // EUR, guest token
    Shops::cart($cart)->add($variant, 2);

    $order = Shops::cart($cart)->checkout();

    expect($order->price->getSubtotal()->minor())->toBe('3980')
        ->and($variant->refresh()->availableStock())->toBe(3);

    Event::assertDispatched(OrderPlaced::class);
});

More states:

use RoundlyConsulting\Shops\Orders\Order;
use RoundlyConsulting\Shops\Shops\Shop;
use RoundlyConsulting\Shops\Shops\TaxRate;

$shop = Shop::factory()->currency('EUR')->create();
TaxRate::factory()->for($shop)->default()->create();                // standard, 2000 bp
TaxRate::factory()->for($shop)->reduced()->forCountry('DE')->create();

$order = Order::factory()->paid()->create();                        // status columns only
$order = Order::factory()->withDiscount('500', 'EUR', couponCode: 'WELCOME10')->create();

Order factory states set status columns directly — they do not run transitions, reserve stock or fire events. Use the lifecycle methods when the side effects matter. Order numbers are assigned on insert even under Event::fake().

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.