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

Integrations

Teams integrates six sibling Roundly packages. They’re hard dependencies, so their seams are always available on the bundled Team model:

PackageWhat it adds to a team
enums-for-laravelJoinRequestStatus and JoinPolicy gain labels(), options(), validationRule(), values() and case lookups.
options-for-laravelTyped per-team settings — join policy, seat cap, default role, require-approval.
contacts-for-laravelSupport and billing emails, phones and URLs, with a primary per type.
addresses-for-laravelA billing, physical and mailing address book with typed lookups.
approvals-for-laravelOpt-in multi-admin sign-off on join requests, mirrored back onto the request.
connections-for-laravelTeam-to-team and team-to-user affiliations with permissions and expiry.

Each keeps its own tables — the options, contacts, addresses, connections and approvals migrations you published during Installation.

Multi-admin join sign-off

Opt in with TEAMS_APPROVALS=true, then route a join request through the approvals engine instead of a single responder. The native joinRequests()->approve() / deny() path is untouched:

use RoundlyConsulting\Approvals\Enums\ApprovalRule;
use RoundlyConsulting\Approvals\Facades\Approvals;
use RoundlyConsulting\Teams\Facades\Teams;

// .env → TEAMS_APPROVALS=true
$request = Teams::for($team)->joinRequests()
    ->requireApprovalFrom([$admin1, $admin2])
    ->rule(ApprovalRule::Quorum)
    ->quorum(2)
    ->open($user);   // opens pending; an ApprovalRequest is created

// Admins sign off through the approvals engine:
Approvals::for($request)->as($admin1)->approve();
Approvals::for($request)->as($admin2)->approve();   // quorum reached — the member is added

requireApprovalFrom(), rule() and quorum() return a new handle, so a staged handle can be kept and reused. When the engine resolves, SyncJoinRequestStatusFromApproval mirrors the outcome through Teams::for($team)->joinRequests() — so Teams::fake() records it: approved runs the add-member path and fires JoinRequestApproved; rejected marks the request Denied and fires JoinRequestDenied; cancelled and expired are no-ops. A join request already resolved by hand (or by expiry) keeps that outcome — the engine never overturns it. The listener acts only while teams.approvals.enabled is on. rule() and quorum() fall back to teams.approvals.rule and .quorum; with approvals disabled, open() follows the native flow.

Contacts

use RoundlyConsulting\Contacts\Enums\ContactType;
use RoundlyConsulting\Teams\Facades\Teams;

// The team's ContactBook — the same as Contacts::for($team):
Teams::for($team)->contacts()->email('[email protected]')->label('billing')->primary()->add();
Teams::for($team)->contacts()->phone('+441234567891')->label('billing')->add();
Teams::for($team)->contacts()->url('https://status.example.com')->label('status')->add();
Teams::for($team)->contacts()->primary(ContactType::Email);   // Contact|null

// The HasContacts trait on Team works too:
$team->addEmail('[email protected]', 'support', primary: true);
$team->addPhone('+441234567890', 'ops');
$team->addUrl('https://example.com', 'website');
$team->primaryEmail();                                        // Contact|null

Addresses

use RoundlyConsulting\Addresses\DataTransferObjects\AddressData;
use RoundlyConsulting\Addresses\Enums\AddressType;
use RoundlyConsulting\Teams\Facades\Teams;

// The team's AddressBook — the same as Addresses::for($team):
Teams::for($team)->addresses()->add(new AddressData(
    city: 'London', street: '1 King St', postalCode: 'EC1A 1AA',
    countryIso: 'GB', type: AddressType::Billing, isPrimary: true,
));
Teams::for($team)->addresses()->primary(AddressType::Billing);

// The HasAddresses trait on Team works too:
$team->createAddress(city: 'Berlin', street: 'Example Str. 1', postalCode: '10115', countryIsoCode: 'DE');
$team->getPrimaryAddressOfType(AddressType::Billing);

contacts() and addresses() hand you the team’s ContactBook and AddressBook, so those packages’ facades — and their fakes — apply. Both packages ship an addAddress() trait method; on Team the structured addresses version wins, and the contacts free-text variant is available as addContactAddress().

Team connections

use RoundlyConsulting\Teams\Facades\Teams;
use RoundlyConsulting\Teams\Models\Team;

// The connections facade scoped to the team — the same as Connections::from($team):
Teams::for($team)->connections()->to($partnerTeam)->withPermissions('share:roster')->connect();
Teams::for($team)->connections()->to($partnerTeam)->disconnect();

// The HasConnections trait on Team works too:
$team->connectTo($partnerTeam, ['share:roster']);   // team ↔ team
$team->inviteConnection($partnerTeam);              // a pending invitation
$partnerTeam->acceptConnectionFrom($team);

$team->isConnectedTo($partnerTeam);
$team->connectablesOfType(Team::class);             // partner teams
$team->hasPermissionThroughConnection($partnerTeam, 'share:roster');

Team implements Connectable, so it can connect to other teams or to any Connectable model, such as a user using the connections package’s trait.

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.