bernskiold / laravel-collaboration
Share Eloquent records with other users. The package has permissions, events, notifications and an activity log. It has no user interface.
Package info
github.com/bernskiold/laravel-collaboration
pkg:composer/bernskiold/laravel-collaboration
Requires
- php: ^8.4
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/database: ^12.0 || ^13.0
- illuminate/events: ^12.0 || ^13.0
- illuminate/notifications: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
Requires (Dev)
- laravel/pint: ^1.0
- nunomaduro/collision: ^8.0 || ^9.0
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^4.0 || ^5.0
- pestphp/pest-plugin-laravel: ^4.0 || ^5.0
- phpstan/phpstan: ^2.1
- spatie/laravel-activitylog: ^4.10 || ^5.0
Suggests
- spatie/laravel-activitylog: Records each change to the shares of a record in the activity log of the record.
Provides
None
Conflicts
None
Replaces
None
README
The owner of a record can share it with other users. Each user gets a permission: "view" or "edit". The policy of the record controls what each permission lets a user do.
The package has no user interface. It has no views and no components. It gives the data, the rules, the events, the notification and the activity log. Your application has the interface.
$document->shareWith($ada, SharePermission::Edit); $document->isSharedWith($ada); // true $document->isSharedWith($ada, SharePermission::Edit); // true Document::query()->sharedWith($ada)->get(); $document->stopSharingWith($ada);
What the package does
- It keeps the shares in one table. The table has one row for each user and record.
- It gives the
CanBeSharedtrait for your models. The trait has a scope, permission checks and methods that change the shares. - It writes all changes with one action,
SyncShares. This makes sure that each change has the same rules, the same events and the same activity entries. - It sends a notification to each user that gets access. Each type of record can use a different notification, or no notification.
- It records each change in the activity log of the record, if
spatie/laravel-activitylogis installed. - It moves the shares of a user to a different user. Use this function when you merge two accounts.
- It gives helpers for your interface. It finds a record from a morph type and a key. It finds the people that can get access to a record.
Installation
Install the package with Composer:
composer require bernskiold/laravel-collaboration
Publish the migration:
php artisan vendor:publish --tag=collaboration-migrations
Run the migration:
php artisan migrate
If your application enforces a morph map, add the share model to it:
Relation::enforceMorphMap([ 'share' => \Bernskiold\LaravelCollaboration\Models\Share::class, // ... ]);
Make a model shareable
Implement Shareable. Use CanBeShared. Then give the column that keeps the key of the owner:
use Bernskiold\LaravelCollaboration\Concerns\CanBeShared; use Bernskiold\LaravelCollaboration\Contracts\Shareable; class Document extends Model implements Shareable { use CanBeShared; public function shareOwnerColumn(): ?string { return 'owner_id'; } }
isOwnedBy() and the ownedBy() scope use this column. If the owner is not in one column, override isOwnedBy() and the ownedBy() scope. Add the #[Scope] attribute to your ownedBy() method. A record without an owner column has no owner.
The package does not share a record with its owner. When you delete a record, the package deletes its shares. A soft-deleted record keeps its shares until you force-delete it.
You can override these methods of the trait:
| Method | Default |
|---|---|
sharePermissions() |
View and edit. The first permission is the default. |
shareableName() |
The name attribute. |
shareableUrl() |
null. The notification has a link to this URL. |
shareNotification() |
The notification in the configuration. |
If sharePermissions() returns one permission, your interface does not ask for a permission:
public function sharePermissions(): array { return [SharePermission::View]; }
Use the permissions in a policy
The package does not give access. Your policy reads the shares:
class DocumentPolicy { public function view(User $user, Document $document): bool { return $document->isOwnedBy($user) || $document->isSharedWith($user); } public function update(User $user, Document $document): bool { return $document->isOwnedBy($user) || $document->isSharedWith($user, SharePermission::Edit); } public function share(User $user, Document $document): bool { return $document->isOwnedBy($user); } }
If you eager load the shares, isSharedWith() and permissionFor() use the loaded shares. Then a list does not make one query for each record.
Find records
Document::query()->sharedWith($user)->get(); // shared with the user Document::query()->sharedWith($user, SharePermission::Edit)->get(); // shared at edit Document::query()->ownedBy($user)->get(); // the user is the owner Document::query()->ownedByOrSharedWith($user)->get(); // the user is the owner, or it is shared with the user
Each scope accepts a model, a key or null. With null, the scope finds no records. You can give auth()->user() also when nobody is signed in.
The sharedWith() scope uses whereHas(). For this reason, the shares table must be on the same database server as the table of the record.
Change the shares
// One person. The other shares do not change. $record->shareWith($user, SharePermission::View, $sharedBy); $record->stopSharingWith($user, $removedBy); // The full list. The package removes the access of each person that is not in the list. $record->syncShares([ $ada->id => SharePermission::Edit, $alan->id => SharePermission::View, ], $performedBy);
The person that makes the change is optional. If you do not give this person, the package uses the signed-in user.
shareWith() and stopSharingWith() return a ShareOutcome: Added, Changed, Removed or Unchanged. Use it to tell the user what happened:
if ($record->shareWith($user, SharePermission::Edit) === ShareOutcome::Unchanged) { // The user already had edit access. }
syncShares() returns ShareChanges. It has the added, changed and removed permissions, keyed by the key of each person, and all the shares after the change. outcomeFor($person) returns the ShareOutcome of one person.
A permission that the record does not offer causes an InvalidArgumentException. The package does not share a record with its owner or with an unknown user.
Events
| Event | The package dispatches the event when… |
|---|---|
RecordShared |
A user gets access. |
SharePermissionChanged |
A user with access gets a different permission. |
RecordUnshared |
The package removes the access of a user. |
The package dispatches the events after the transaction commits. The events are in the Bernskiold\LaravelCollaboration\Events namespace.
Use the events to refresh your interface. You can also use them to clear caches that use the access of a user.
Notifications
When a user gets access, the package sends the notification from the shareNotification() method of the record. The default notification is SharedWithYou. It is a queued email with a link to shareableUrl().
To send a different notification for one type of record, override the method. Return null to send no notification:
public function shareNotification(Model $sharedWith, ?Model $sharedBy, SharePermission $permission): ?Notification { return new DocumentShared($this, $sharedBy); }
The text of the notification is in lang/en/collaboration.php. To change the text, publish the translations:
php artisan vendor:publish --tag=collaboration-translations
To change the notification for all records, set notifications.default in the configuration. The constructor gets the record, the user that shared the record and the permission. To send no notifications, set notifications.enabled to false.
Activity log
If spatie/laravel-activitylog is installed, the package records the changes in the activity log of the record. Each call to SyncShares makes one entry for each type of change. The events of the entries are shared, share-changed and unshared. The people property has the names of the users. The people_ids property has their keys.
ShareActivity::describe($entry) returns a description of an entry, as a verb and an object:
ShareActivity::describe($entry); // ['verb' => 'shared it with', 'object' => 'Ada Lovelace, Alan Turing']
Make your interface
The package has no interface. These helpers keep your components small.
Find the record
A component or an API tool can get the record as a morph type and a key. findShareable() returns the record. It returns null if the type is not a shareable model or if the record does not exist. Then authorize the user:
use Bernskiold\LaravelCollaboration\Collaboration; $record = Collaboration::findShareable($this->type, $this->key); abort_if($record === null, 404); $this->authorize('share', $record);
The method finds the record by its route key. To use a different column, give the field, for example findShareable($type, $id, 'id').
The type can be a morph alias or a class name. In a Livewire component, add #[Locked] to the type and the key. Then the browser cannot change the record after you authorize it.
Find people
PeopleDirectory searches all users by name and email address. The results do not include the owner of the record:
$people = Collaboration::people()->search($term, $record, limit: 15);
resolve() returns the person for the value that the user selected. The value is a key, or an email address to invite. If the person does not exist, resolve() returns null:
$person = Collaboration::people()->resolve($this->person); if ($person === null || $record->isOwnedBy($person)) { $this->addError('person', __('Select a person.')); return; } $record->shareWith($person, SharePermission::from($this->permission));
To change the search, extend PeopleDirectory. Then set your class in the people configuration:
class ActivePeople extends PeopleDirectory { protected function query(): Builder { return User::query()->active(); } }
Users can also share a record with an email address that has no account. To let them do this, override canInvite() and invite(). If the address has an account, invite() returns that user:
public function canInvite(): bool { return true; } public function invite(string $email): ?Model { return User::query()->firstOrCreate(['email' => $email], [/* ... */]); }
name() returns the name of a person. The activity log and the notification use this name.
Show the permissions
SharePermission has a label and a description from the translations of the package:
foreach ($record->sharePermissions() as $permission) { $permission->value; // "view" $permission->label(); // "Can view" $permission->description(); // "Can open it, but cannot change it." }
Test your application
Share::factory() makes shares for your tests. The factory writes the share directly. It does not record activity, dispatch events or send notifications:
Share::factory()->on($document)->sharedWith($user)->edit()->create();
The states are on($record), sharedWith($user), sharedBy($user), view(), edit() and permission($permission). Without sharedWith(), the factory makes a user with the factory of your user model.
Merge two users
HandOverShares moves the shares of one user to a second user. If the two users had access to a record, the second user keeps the larger permission. If the second user is the owner of a record, the package deletes the share on that record.
app(HandOverShares::class)->execute(from: $duplicate, to: $user);
Some merge tools move all the relations of a user. If you use such a tool, do not add a shares relation to your user model. A direct update can break the unique index of the table.
Stability
These values are kept in your database or in your activity log. They do not change in a minor or a patch release:
- The permission values
viewandedit. The package has no other permissions. - The columns of the shares table.
- The events of the activity entries:
shared,share-changedandunshared. - The properties of the activity entries:
peopleandpeople_ids.
Configuration
To change the configuration, publish the configuration file:
php artisan vendor:publish --tag=collaboration-config
return [ 'user_model' => null, // null: the model of the default auth provider 'share_model' => Share::class, 'table' => 'shares', 'connection' => null, // null: the default connection 'people' => PeopleDirectory::class, 'notifications' => ['enabled' => true, 'default' => SharedWithYou::class], 'activity_log' => ['enabled' => true, 'log_name' => null], // null: the morph class of the record ];
Testing
composer test
Changelog
Refer to the CHANGELOG for the recent changes.
Contributing
Refer to CONTRIBUTING for the procedure.
Security Vulnerabilities
Refer to our security policy for how to report a security vulnerability.
Credits
License
The MIT License (MIT). Refer to the License File for more information.