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

The recording fake

Contacts::fake() swaps the manager for ContactsFake and returns it — constructor-injected managers get it too. Nothing is written and no event fires: added contacts come back unsaved (a structured address is rendered into the value, never stored). Every write is recorded — through the facade, an injected manager, a contact book, verification(), the HasContacts trait or $contact->requestVerification() / confirmVerification(). Reads still hit the database:

use Illuminate\Database\Eloquent\Model;
use RoundlyConsulting\Contacts\DataTransferObjects\ContactData;
use RoundlyConsulting\Contacts\Facades\Contacts;

$fake = Contacts::fake();

$user->addEmail('[email protected]');
$contact->requestVerification();

$fake->assertAdded(fn (ContactData $data, Model $owner) => $data->value === '[email protected]');
$fake->assertVerificationRequested($contact);   // the Contact argument is optional
$fake->assertNothingDeleted();
RecordsAssertAssert none
for()->…->add(), for()->add(), trait add*()assertAdded(?Closure(ContactData, Model))assertNothingAdded()
for()->sync()assertSynced(?ContactType, ?Closure(list<ContactData>, Model))assertNothingSynced()
update()assertUpdated(?Contact, ?Closure(ContactData))assertNothingUpdated()
delete()assertDeleted(?Contact)assertNothingDeleted()
setPrimary()assertPrimarySet(?Contact)assertNothingPrimarySet()
verification()->markVerified()assertVerified(?Contact)assertNothingVerified()
verification()->request(), $contact->requestVerification()assertVerificationRequested(?Contact)assertNoVerificationRequested()
verification()->confirm(), $contact->confirmVerification()assertVerificationConfirmed(?Contact)assertNoVerificationConfirmed()
  • Every Contact argument is optional — pass one to match a specific contact.
  • Closures receive the recorded ContactData (before normalization) and the owner; assertUpdated() passes the ContactData only.
  • verification()->request() returns fake-token under the fake.
use Illuminate\Database\Eloquent\Model;
use RoundlyConsulting\Contacts\DataTransferObjects\ContactData;
use RoundlyConsulting\Contacts\Enums\ContactType;
use RoundlyConsulting\Contacts\Facades\Contacts;
use RoundlyConsulting\Contacts\Models\Contact;

it('stores the signup email as a contact', function () {
    $fake = Contacts::fake();

    $user = User::factory()->create();
    $user->addEmail('[email protected]', label: 'Work');

    $fake->assertAdded(fn ($data) => $data->value === '[email protected]');
});

it('requests verification and promotes the email', function () {
    $fake = Contacts::fake();
    $contact = Contact::factory()->email()->create();

    Contacts::verification()->request($contact);   // the fake returns 'fake-token'
    Contacts::setPrimary($contact);

    $fake->assertVerificationRequested($contact);
    $fake->assertPrimarySet($contact);
    $fake->assertNothingAdded();
});

it('replaces the phone list', function () {
    $fake = Contacts::fake();
    $user = User::factory()->create();

    Contacts::for($user)->sync(ContactType::Phone, [
        new ContactData(ContactType::Phone, '+421900000000', isPrimary: true),
    ]);

    $fake->assertSynced(ContactType::Phone, fn (array $items, Model $owner) => count($items) === 1);
    $fake->assertNothingUpdated();
});

Factories

The model factory ships states for ergonomic setup — email(), phone(), url(), address(), primary(), verified(), pendingVerification(), forOwner($model) and ofType($type):

use RoundlyConsulting\Contacts\Enums\ContactType;
use RoundlyConsulting\Contacts\Models\Contact;

Contact::factory()->email()->primary()->verified()->forOwner($user)->create();
Contact::factory()->phone()->forOwner($user)->create();
Contact::factory()->url()->create();
Contact::factory()->address()->create();
Contact::factory()->ofType(ContactType::Social)->create();

$pending = Contact::factory()->email()->pendingVerification()->create();
$pending->confirmVerification('123456');   // the state stores the hash of '123456'

Pest expectations

Register the package’s custom Pest matchers from your app’s tests/Pest.php:

// tests/Pest.php
use RoundlyConsulting\Contacts\Testing\ContactExpectations;

ContactExpectations::register();

Then assert against any owner model. Expected values are normalized for their kind before comparison:

use RoundlyConsulting\Contacts\Enums\ContactType;

expect($company)
    ->toHaveContactOfType(ContactType::Email)
    ->toHavePrimaryEmail('[email protected]')
    ->toHavePrimaryContact(ContactType::Phone, '+421900000000')
    ->toHaveVerifiedContact('[email protected]');

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.