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

The facade is the recommended default, not the only way in. Three equivalent entry points run the same code:

  • The Connections facade — the shortest form, used throughout these docs.
  • The manager, RoundlyConsulting\Connections\ConnectionManager — the facade root, a container singleton, injected through the constructor. Same API, an explicit dependency and no static calls.
  • Actions — single-purpose classes with execute(), for composing into your own actions, jobs and commands.

Inject the manager, or call an action

use RoundlyConsulting\Connections\Actions\GrantPermissions;
use RoundlyConsulting\Connections\ConnectionManager;

final class ShareTeam
{
    public function __construct(private ConnectionManager $connections) {}

    public function __invoke(User $user, Team $team): void
    {
        $this->connections->between($user, $team)->permissions()->grant('view');
    }
}

// The raw action.
app(GrantPermissions::class)->execute($user, $team, 'view');

Connections::fake() swaps the container binding as well as the facade, so an injected ConnectionManager — and every HasConnections trait write — is recorded in tests. An action you resolve and call yourself runs normally but isn’t recorded.

Facade method → action

Facade methodAction
between()->connect() · invite()CreateConnection (execute / executeData)
between()->accept()AcceptConnection
between()->block()BlockConnection
between()->disconnect()DisconnectConnection
between()->reconnect() · restore()RestoreConnection
between()->extend()ExtendConnection
between()->permissions()->grant()GrantPermissions
between()->permissions()->revoke()RevokePermissions
between()->permissions()->sync()SyncPermissions
between()->permissions()->clear()ClearPermissions
from()->toMany()->connectAll()BulkConnect
from()->toMany()->disconnectAll()BulkDisconnect
from()->toMany()->grantAll() · revokeAll()GrantPermissions · RevokePermissions (per target)
from()->sync()SyncConnections
Connections::prune()PruneConnections

exists(), find(), toggle(), expiring(), flushCache() and the permission reads (all(), has(), hasAny(), hasAll()) have no action of their own: they are queries, cache housekeeping or — for toggle() — a connect() or disconnect() chosen at call time.

Calling actions directly

Resolve an action from the container to run one operation:

use RoundlyConsulting\Connections\Actions\CreateConnection;
use RoundlyConsulting\Connections\Actions\GrantPermissions;
use RoundlyConsulting\Connections\Actions\RevokePermissions;
use RoundlyConsulting\Connections\Actions\SyncPermissions;
use RoundlyConsulting\Connections\Actions\ClearPermissions;
use RoundlyConsulting\Connections\Actions\ExtendConnection;
use RoundlyConsulting\Connections\Actions\DisconnectConnection;
use RoundlyConsulting\Connections\Actions\PruneConnections;
use RoundlyConsulting\Connections\Actions\SyncConnections;
use RoundlyConsulting\Connections\DataTransferObjects\SyncTarget;

app(CreateConnection::class)->execute($user, $team, collect(['view']), now()->addMonth());
app(GrantPermissions::class)->execute($user, $team, 'publish');
app(RevokePermissions::class)->execute($user, $team, 'publish');
app(SyncPermissions::class)->execute($user, $team, 'view', 'edit');
app(ClearPermissions::class)->execute($user, $team);
app(ExtendConnection::class)->execute($user, $team, now()->addYear());
app(DisconnectConnection::class)->execute($user, $team);
app(PruneConnections::class)->execute();
app(SyncConnections::class)->execute($user, [new SyncTarget($team)]);

grant and sync auto-create the connection when none exists. disconnect, revoke, clear and extend throw ConnectionNotFound when there is no connection. CreateConnection::execute() takes the two models plus optional permissions, expiry, status, meta and a meta-replacement flag; executeData() takes the same input as a ConnectionData DTO. It follows the re-connect rules — a null argument keeps the stored value on an existing connection — and an explicit status it cannot move to (see canTransitionTo(), and never out of blocked) throws InvalidStatusTransition:

use RoundlyConsulting\Connections\Actions\CreateConnection;
use RoundlyConsulting\Connections\Enums\ConnectionStatus;

app(CreateConnection::class)->execute(
    $user,
    $team,
    collect(['view']),            // ?Collection — null: default_permissions when new, kept when existing
    now()->addMonth(),            // ?CarbonInterface — null: expiry.default when new, kept when existing
    ConnectionStatus::Pending,    // ?ConnectionStatus — null: default_status when new, kept when existing
    ['source' => 'import'],       // ?array meta — null keeps the stored meta
    replaceMeta: false,           // true overwrites the stored meta instead of merging
);

Status, restore, bulk and sync actions

use RoundlyConsulting\Connections\Actions\AcceptConnection;
use RoundlyConsulting\Connections\Actions\BlockConnection;
use RoundlyConsulting\Connections\Actions\RestoreConnection;
use RoundlyConsulting\Connections\Actions\BulkConnect;
use RoundlyConsulting\Connections\Actions\BulkDisconnect;
use RoundlyConsulting\Connections\Actions\SyncConnections;
use RoundlyConsulting\Connections\DataTransferObjects\SyncTarget;

app(AcceptConnection::class)->execute($user, $team);                       // Connection
app(BlockConnection::class)->execute($user, $team);                        // Connection
app(RestoreConnection::class)->execute($user, $team);                      // Connection
app(BulkConnect::class)->execute($user, [$teamA, $teamB], collect(['view'])); // Collection<Connection>
app(BulkDisconnect::class)->execute($user, [$teamA, $teamB]);              // void
app(SyncConnections::class)->execute($user, [new SyncTarget($teamA, ['view'])]); // SyncResult
ActionBehaviour
CreateConnectionCreate the pair or change only what is supplied; revives a soft-deleted pair. Fires ConnectionCreated (+ ConnectionInvited when pending), ConnectionUpdated, or ConnectionRestored for a blocked pair. Throws InvalidStatusTransition.
DisconnectConnectionSoft-delete; fires ConnectionRemoved. Throws ConnectionNotFound.
GrantPermissionsAdd permissions, creating the connection when absent.
RevokePermissionsRemove permissions. Throws ConnectionNotFound.
SyncPermissionsReplace the set, creating the connection when absent.
ClearPermissionsEmpty the set; never creates a connection. Throws ConnectionNotFound.
ExtendConnectionSet or clear expires_at; fires ConnectionUpdated. Throws ConnectionNotFound.
PruneConnectionsSoft-delete every expired connection, drop the in-request cache; returns the count.
AcceptConnection / BlockConnectionChange the status (no-op when unchanged); AcceptConnection is the one explicit unblock. Throws ConnectionNotFound.
RestoreConnectionRestore the trashed row as it was (ConnectionRestored) or create a fresh one.
BulkConnect / BulkDisconnectRun CreateConnection / DisconnectConnection over a list in one transaction.
SyncConnectionsReconcile to a list of SyncTarget in one transaction, reviving detached targets; returns SyncResult.

Data transfer objects

ConnectionData carries a full connection write into CreateConnection::executeData(); PermissionSet is the immutable, de-duplicated permission list the actions work with. SyncTarget and SyncResult are covered in Bulk operations & sync.

use RoundlyConsulting\Connections\Actions\CreateConnection;
use RoundlyConsulting\Connections\DataTransferObjects\ConnectionData;
use RoundlyConsulting\Connections\DataTransferObjects\PermissionSet;
use RoundlyConsulting\Connections\Enums\ConnectionStatus;

$data = ConnectionData::fromModels(
    $user,
    $team,
    permissions: PermissionSet::make('view', 'edit'),
    expiresAt: now()->addMonth(),
    status: ConnectionStatus::Pending,
    meta: ['source' => 'import'],
);

app(CreateConnection::class)->executeData($data, $user, $team);

$set = PermissionSet::make('view')->add('edit', 'view')->remove('view');
$set->all();          // ['edit'] — always de-duplicated
$set->has('edit');    // true
$set->isEmpty();      // false
$set->toCollection(); // Collection<int, string>

Exceptions

ExceptionThrown when
ConnectionsExceptionAbstract base of the package exceptions — catch it to handle all three below.
ConnectionNotFounddisconnect, revoke, clear, extend, accept or block runs on a pair with no connection.
InvalidStatusTransitionA status change canTransitionTo() refuses, or a connect-side verb would lift a block — e.g. invite() over an accepted or blocked connection.
MissingConnectableA terminal verb runs without a connectable, or a bulk verb without toMany() targets.

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.