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

The Contacts facade

The Contacts facade is the recommended entry point. Contacts::for($owner) returns the owner’s contact book, Contacts::verification() runs the token and code flow, and single-contact operations are flat. Every write goes through a dedicated action, so normalization, validation, primary handling and events are identical whichever entry point you use:

use RoundlyConsulting\Contacts\DataTransferObjects\ContactData;
use RoundlyConsulting\Contacts\Enums\ContactType;
use RoundlyConsulting\Contacts\Facades\Contacts;

// Add — normalized + validated, primary guaranteed unique per kind.
Contacts::for($user)->email('[email protected]')->label('Work')->primary()->add();
Contacts::for($user)->phone('+421 900 000 000')->label('Mobile')->add();
Contacts::for($user)->type('whatsapp')->value('+421900123456')->add();   // a registered custom kind
Contacts::for($user)->add(new ContactData(ContactType::Url, 'example.com'));

// Read and export.
Contacts::for($user)->all();                        // ordered by position
Contacts::for($user)->ofType(ContactType::Email);
Contacts::for($user)->primary(ContactType::Email);  // ?Contact
Contacts::for($user)->vCard();                      // vCard 3.0 string

// One contact.
Contacts::update($contact, new ContactData(ContactType::Email, '[email protected]'));
Contacts::setPrimary($contact);
Contacts::delete($contact);

// Verification.
$token = Contacts::verification()->request($contact);
Contacts::verification()->confirm($contact, $token);
Contacts::verification()->markVerified($contact);

Facade methods

MethodReturnsDescription
for(Model $owner)ContactBookThe owner’s contact book — fluent builders, add, sync, reads and vCard export.
verification()ContactVerificationThe verification accessor: request(), confirm(), markVerified().
update(Contact $contact, ContactData $data)ContactOverwrite a contact; a primary flag promotes it, a new value or kind drops the verification. Fires ContactUpdated.
delete(Contact $contact)voidSoft delete; deleting the primary promotes the next one (auto_primary). Fires ContactDeleted.
setPrimary(Contact $contact)ContactPromote, demoting the owner’s other primary of the same kind; fires PrimaryContactChanged.
sharedWith(Connectable $owner)CollectionContacts connected to an owner (see Affiliations).
validationRules(string $key = 'contacts')arraycontacts.* rules for a repeatable form.
fake()ContactsFakeSwap in the recording fake (see Testing).

The contact book

Contacts::for($owner) — or $owner->contactBook() on a HasContacts model — returns a ContactBook scoped to that owner:

MethodReturnsDescription
email() / phone() / url() / address(string $value)PendingContactStart a fluent contact of that kind.
structuredAddress(AddressData|array $data)PendingContactStart an address contact backed by a structured address.
type(ContactType|string $type)PendingContactStart a contact of any kind — a ContactType or a registered custom kind.
add(ContactData $data)ContactAdd a contact from a DTO; fires ContactAdded.
sync(ContactType $type, array $items)EloquentCollectionReconcile the owner’s contacts of one kind to a list; a synced-away primary hands over to the first synced contact.
all()EloquentCollectionEvery contact of the owner, by position.
ofType(ContactType|string $type)EloquentCollectionOne kind, by position.
primary(ContactType|string $type)?ContactThe primary contact of a kind, or null.
vCard()stringA vCard 3.0 string of the owner’s contacts.

The verification accessor

Contacts::verification() returns a ContactVerification (see Verification):

MethodReturnsDescription
request(Contact $contact)stringIssue a code or token and return the plaintext; fires ContactVerificationRequested.
confirm(Contact $contact, string $token)ContactConfirm the token and mark the contact verified; fires ContactVerified.
markVerified(Contact $contact, ?CarbonInterface $at = null)ContactMark verified without a token (idempotent); fires ContactVerified.

The fluent builder

The book’s email(), phone(), url(), address(), structuredAddress() and type() start a PendingContact. Chain the setters, then call add():

$contact = Contacts::for($company)
    ->email('[email protected]')      // or ->phone() / ->url() / ->address()
    ->label('Sales')                  // free-text label for this contact
    ->name('Northwind sales desk')    // display name
    ->category('Suppliers')           // group contacts
    ->meta(['hours' => '9-17'])       // arbitrary JSON metadata
    ->primary()                       // ->primary(false) to opt out
    ->add();                          // terminal: validates, saves, returns Contact

// Explicit type + value
Contacts::for($company)->type(ContactType::Social)->value('@northwind')->add();
  • email(), phone(), url(), address() — set the kind and the value in one call.
  • type(ContactType|string) + value(string) — set them separately; a registered custom kind keeps its raw name, and without a type the kind is custom.
  • label(), name(), category(), meta(array) — descriptive fields.
  • primary(bool $primary = true) — request primary status.
  • structuredAddress(AddressData|array) — attach a structured address (see Structured addresses).
  • add() — terminal: validates, saves and returns the Contact.

ContactData

ContactData is the readonly DTO every write accepts. Build it with named arguments or from an array; normalized() returns a normalized copy:

use RoundlyConsulting\Contacts\DataTransferObjects\ContactData;
use RoundlyConsulting\Contacts\Enums\ContactType;

$data = new ContactData(
    type: ContactType::Phone,
    value: '+421 900 000 000',
    label: 'Office',
    name: null,
    category: 'Headquarters',
    isPrimary: false,
    position: null,           // null = next free position within the kind
    meta: ['ext' => '204'],
    kind: null,               // null = the type's own value; set it for custom kinds
);

$data = ContactData::fromArray([
    'type' => 'phone',        // ContactType or string
    'value' => '+421 900 000 000',
    'is_primary' => true,     // or 'isPrimary'
]);

$data->normalized()->value;   // '+421900000000'

Updating and deleting

use RoundlyConsulting\Contacts\Models\Contact;

$updated = Contacts::update($contact, new ContactData(
    ContactType::Email,
    '[email protected]',
    label: 'Work',
));

$updated->isVerified();                        // false — a new value needs a new verification

Contacts::delete($contact);                    // soft delete + ContactDeleted
Contact::withTrashed()->find($contact->id)->restore();   // addresses + connections are back

update() re-normalizes and re-validates the value. The kind, value, label, category and meta are replaced with the DTO’s values — a null label or category clears it — while name and position change only when set. A changed value or kind drops the verification and voids any pending token. Passing isPrimary: true promotes the contact.

Moving a contact to another kind keeps one primary per kind: a primary entering a kind that already has one is demoted (unless isPrimary asks for it, which demotes the other instead). With auto_primary on, the kind it left promotes its next contact, and a kind it enters without a primary gets one. Deleting a primary promotes the next contact of its kind the same way.

Low-level access

// The raw relation still works — but it skips normalization, validation,
// auto-primary and events. Prefer the trait, the facade or the builder.
$user->contacts()->create([
    'type' => 'email',
    'name' => 'Jane Doe',
    'value' => '[email protected]',
]);

The contacts() relation, the scopes, the meta collection cast and direct create() calls all still work. Normalization, validation and auto-primary apply only through the action, facade and trait-sugar path. Dropping the verification on a value change applies everywhere, direct writes included.

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.