A polymorphic friendship system for Laravel: friend requests, accept/deny, blocking, mutual friends, counts, and events. Any Eloquent model can befriend any other model type — not just users.
Requires PHP 8.1+ and Laravel 10, 11, 12, or 13.
| Laravel | PHP |
|---|---|
| 10 | 8.1 – 8.4 |
| 11 | 8.2 – 8.4 |
| 12 | 8.2 – 8.4 |
| 13 | 8.3 – 8.4 |
Laravel 10 and 11 are past Laravel's own security-support window. This package is tested against them on every release — with real tagged framework versions, not branch heads — but the framework itself no longer receives security patches, so Composer flags every published 10.x and 11.x release. Prefer 12 or 13 for new projects.
composer require ghanem/friendshipThe service provider is auto-discovered. Publish and run the migration:
php artisan vendor:publish --tag=friendship-migrations
php artisan migrateAdd the trait to any model:
use Ghanem\Friendship\Contracts\Friendable as FriendableContract;
use Ghanem\Friendship\Traits\Friendable;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable implements FriendableContract
{
use Friendable;
}Implementing the contract is optional but recommended — it guarantees your model exposes the full API.
$me->befriend($you); // send a request (returns ?Friendship)
$you->acceptFriendRequest($me); // only the recipient can accept
$you->denyFriendRequest($me); // deny — $me may send a new request later
$me->unfriend($you); // remove an accepted friendshipbefriend() returns null (no-op) when a pending, accepted, or blocked
relationship already exists in either direction.
$me->block($you); // works with or without a prior friendship
$me->unblock($you); // only the blocker can unblockBlocking removes any pending, accepted, or denied row between the pair and
remembers who did the blocking. Calling block() again on someone you already
blocked is a no-op that keeps the original row.
Blocks are per-direction. A → B and B → A are separate rows and can
both exist at once. Being blocked does not let you clear the other party's
block: if A blocks B and B then blocks A, B's later unblock($a) removes only
B's own block — A's block stays in force, and B still cannot befriend A. Only
the blocker can lift their own block.
With a mutual block in place there are two rows for the pair, one per
direction. getFriendship() returns the caller's own row ($me->getFriendship($you)
returns the row $me sent), so it never tells you who blocked whom — use
hasBlocked() / isBlockedBy() for that.
$me->isFriendsWith($you); // accepted friendship exists
$me->hasSentFriendRequestTo($you); // outgoing pending request
$me->hasFriendRequestFrom($you); // incoming pending request
$me->hasBlocked($you); // $me blocked $you
$me->isBlockedBy($you); // $you blocked $meAll checks return bool and are safe to call when no relationship exists.
$me->getFriends(); // Collection of friend MODELS (accepted only)
$me->getFriends(perPage: 15); // LengthAwarePaginator of friend models
$me->getFriendship($you); // the Friendship row for the pair, or null
$me->getAllFriendships(); // every Friendship row involving $me
$me->getPendingFriendships();
$me->getAcceptedFriendships();
$me->getDeniedFriendships();
$me->getBlockedFriendships();
$me->getFriendRequests(); // incoming pending Friendship rowsList methods return real Collections with sender/recipient eager-loaded —
no N+1 queries.
$me->getMutualFriends($you); // Collection of shared friends
$me->getMutualFriends($you, limit: 10);
$me->getMutualFriendsCount($you);
$me->getFriendsCount();
$me->getPendingRequestsCount(); // incoming pending requestsEvery action fires an event carrying the Friendship model
($event->friendship):
| Event | Fired when |
|---|---|
Ghanem\Friendship\Events\FriendRequestSent |
a request is sent |
Ghanem\Friendship\Events\FriendRequestAccepted |
a request is accepted |
Ghanem\Friendship\Events\FriendRequestDenied |
a request is denied |
Ghanem\Friendship\Events\Unfriended |
an accepted friendship is removed |
Ghanem\Friendship\Events\UserBlocked |
a model is blocked |
Ghanem\Friendship\Events\UserUnblocked |
a model is unblocked |
Listen to them like any Laravel event, e.g. to send a notification when a request arrives.
Ghanem\Friendship\Status is a backed enum: Pending (0), Accepted (1),
Denied (2), Blocked (3). The status attribute on Friendship is cast
to it automatically.
v2 is a clean break. Database rows are compatible (same table, same status values), but the API changed:
-
Requires PHP 8.1+ and Laravel 10+ (was PHP 5.5 / Laravel 5).
-
Model
Ghanem\Friendship\Models\Friendis nowFriendship. -
Statusclass constants are now enum cases (Status::Pendinginstead ofStatus::PENDING); stored integer values are unchanged. -
blockFriendRequest()/unblockFriendRequest()are nowblock()/unblock(). Unblocking deletes the row instead of resetting it to pending. -
isFriendsWith()returnsbooland matches accepted friendships only (it previously counted rows of any status). -
Accept/deny now verify direction — only the recipient of a request can accept or deny it.
-
A denied request no longer prevents sending a new one.
-
List methods return Collections of
Friendshipmodels (previously arrays) and no longer take$limit/$offsetarguments; usegetFriends(perPage:)for pagination. -
The
friends()morphMany relation was removed; usegetFriends(). -
New: events,
getMutualFriends(), counts,hasFriendRequestFrom(),hasSentFriendRequestTo(). -
Existing installs: the migration now declares a unique index on (sender, recipient). Your existing table works without it, but you can add it in a new migration for duplicate protection.
Warning: v1 had no duplicate protection, so an existing table may already contain duplicate (sender, recipient) rows. Adding the index on such a table fails mid-migration with
SQLSTATE[23000]. Check first, and de-duplicate before adding the index.Check for duplicates:
SELECT sender_id, sender_type, recipient_id, recipient_type, COUNT(*) AS n FROM friendships GROUP BY sender_id, sender_type, recipient_id, recipient_type HAVING n > 1;
De-duplicate, keeping the lowest id of each group (MySQL syntax):
DELETE f1 FROM friendships f1 JOIN friendships f2 ON f1.sender_id = f2.sender_id AND f1.sender_type = f2.sender_type AND f1.recipient_id = f2.recipient_id AND f1.recipient_type = f2.recipient_type WHERE f1.id > f2.id;
Multi-table
DELETE ... JOINis MySQL-only. On PostgreSQL and SQLite use a subquery instead:DELETE FROM friendships WHERE id NOT IN ( SELECT MIN(id) FROM friendships GROUP BY sender_id, sender_type, recipient_id, recipient_type );
Review what the duplicates are before deleting — keeping the lowest id keeps the oldest row, which may not be the row with the status you want. Then add the index:
Schema::table('friendships', function (Blueprint $table) { $table->unique( ['sender_id', 'sender_type', 'recipient_id', 'recipient_type'], 'friendships_sender_recipient_unique' ); });
ghanem/friendship-filament adds a Filament panel plugin for this package:
- a read-only Friendships resource for browsing and moderating every row, filterable by status and sender type;
- a relation manager you attach to your own
UserResource, listing a model's friendships in both directions with the other party resolved; - moderation actions — approve a pending request, unfriend, lift a block — all routed through this package's public API, so your event listeners still fire;
- stats and chart widgets for the dashboard.
composer require ghanem/friendship-filamentIt requires Filament 4 or 5, which means Laravel 11.28+ — this core package still supports Laravel 10, the plugin does not.
The unique index is directional — A → B and B → A are distinct rows.
Two befriend() calls in opposite directions racing at the same instant can
therefore both succeed, leaving two pending requests for the same pair; the
database cannot reject the second one. Everything else stays consistent
(accepting either one makes them friends), but if you expect that kind of
concurrency, wrap the call in your own lock keyed on the pair.
composer test- ghanem/friendship-filament — Filament 4 & 5 admin panel for this package
- ghanem/rating — polymorphic ratings and reviews for Eloquent models
- ghanem/rating-filament — Filament 4 & 5 components for
ghanem/rating
This package began life in 2015 as an MIT-licensed package by DraperStudio / PackageBackup, whose original repository is no longer published. It has been maintained by GAIT ever since, across Laravel 5 through 13. The original copyright notice is retained in LICENSE.
MIT. See LICENSE.
