ghanem/friendship
A Laravel package for managing friendships with an Eloquent-friendly API. Send, accept, or deny friend requests, unfriend, block users, and query friends, pending requests, and relationship status in your application.
Install and publish.
composer require ghanem/friendship
php artisan vendor:publish --provider="Ghanem\Friendship\FriendshipServiceProvider"
php artisan migrate
Publishing should give you both a config file (config/friendship.php) and the migrations for the friendships table. If the provider isn't auto-discovered, register it in config/app.php under providers.
Where to look first, in this order:
config/friendship.php — model class overrides and table name. Touch this before anything else if you plan to namespace models under App\Models or use a non-standard users table.src/Traits/Friendable.php — this is the entire public API surface. Everything you'll call lives here; read it once, top to bottom, before designing your controllers.src/Models/Friendship.php — the polymorphic pivot model. Understanding its columns (sender_type, sender_id, recipient_type, recipient_id, status) tells you what queries you can write directly when the trait methods aren't enough.Events/ directory — the list of dispatched events tells you what extension points exist without forking.First use case — the "Add friend" button. Add the trait to your User model:
use Ghanem\Friendship\Traits\Friendable;
class User extends Authenticatable
{
use Friendable;
}
Then the whole flow is three calls:
// Sender side
$user->befriend($recipient); // creates a pending request
// Recipient side
$recipient->acceptFriendRequest($user);
// or
$recipient->denyFriendRequest($user);
// Either side, later
$user->unfriend($recipient);
That's the 80% case. Get this working end-to-end with two seeded users before touching blocking, mutual friends, or events — those are additive, and validating the base flow in isolation saves you from debugging authorization bugs through the lens of a half-built friend graph.
Wrap mutations in your own service, not in controllers. The trait is a data layer. Controllers that call $user->befriend($other) directly tend to accumulate duplicated guards (self-check, block-check, duplicate-check). Centralize:
class FriendshipService
{
public function request(User $sender, User $recipient): Friendship
{
abort_if($sender->is($recipient), 422, 'Cannot befriend yourself.');
abort_if($sender->hasBlocked($recipient) || $recipient->hasBlocked($sender), 403);
abort_if($sender->isFriendWith($recipient), 409, 'Already friends.');
abort_if($sender->hasSentFriendRequestTo($recipient), 409, 'Request pending.');
return $sender->befriend($recipient);
}
}
This is also where you hang transactional boundaries and post-commit event dispatch.
Authorization belongs in a policy, not in the trait. The package will happily let any User accept any request addressed to them — it doesn't know about your tenancy, roles, or account states. Add a FriendshipPolicy with accept, deny, and unfriend methods and call $this->authorize() in the controller. Forgetting this is the most common way a "friends" feature leaks cross-tenant relationships.
Treat getFriends() as a query, not an array. In the typical implementation it merges both directions of the pivot (you may be sender or recipient) and hydrates full models. For a profile page with 50 friends that's fine; for a "friends of friends" suggestion engine it is not. When you need scale, drop to a query builder against the friendships table joining on both sides:
Friendship::query()
->where('status', 'accepted')
->where(fn ($q) => $q->where('sender_id', $user->id)->where('sender_type', $user->getMorphClass()))
->orWhere(fn ($q) => $q->where('recipient_id', $user->id)->where('recipient_type', $user->getMorphClass()))
->pluck(...); // pull the *other* side's id
Always compare on getMorphClass(), never on get_class(), so morph maps don't silently return zero rows.
API layer: return IDs, not models. Friendship endpoints that return hydrated User models couple your mobile clients to your internal serializer. Map to a resource:
'friends' => $user->getFriends()->map(fn ($f) => [
'id' => $f->id,
'name' => $f->name,
'mutual' => $user->getMutualFriends($f)->count(),
])->values(),
If mutual counts matter in a list, precompute them with a single grouped query — calling getMutualFriends() inside a map over 50 friends is 50 queries.
Events are the notification seam. Listen for the package's request/accepted/denied events and fan out to mail, broadcast, and database channels there. Keep listeners queued (implements ShouldQueue) and make them idempotent — a duplicate accept notification is far more likely than you'd expect from double-clicked UI. If you're running inside a transaction, use DB::afterCommit() or ShouldHandleEventsAfterCommit so a rollback doesn't ship a notification for a relationship that never existed.
Polymorphism is the reason to use this package. Because sender and recipient are morphs, the same trait on User, Team, and Page gives you a unified graph. Design your UI around "friendable" as a concept from day one; retrofitting a third friendable type onto code that assumed User everywhere is a rewrite of every query and Blade component.
No pair-level uniqueness means duplicates are your problem. Two rapid POSTs can create two pending rows. Guard in the service and add a defensive composite unique index on (sender_type, sender_id, recipient_type, recipient_id) in a follow-up migration if your app is exposed to retries. Conversely, don't add a plain unique index if you intend to support block-then-refriend cycles.
Directionality is asymmetric — learn which is which. hasSentFriendRequestTo($u) and hasFriendRequestFrom($u) are not interchangeable, and isFriendWith($u) typically accepts either direction while hasBlocked does not. Write two or three feature tests that pin this behavior down; it's cheap insurance against a version bump that flips the semantics.
Blocking may not delete the friendship. Most implementations add a block row and refuse new requests, but leave the existing accepted friendship in the table. That means getFriends() still lists a blocked user. If your product means "blocking severs the relationship," do it explicitly in the service: unfriend() then block(). Decide and document this before launch, because users notice.
Denied requests linger. A denied row blocks a fresh request from the same sender in some versions, or allows it in others. Either way, getPendingFriendships() vs. raw table counts diverge. When writing an "incoming requests" badge counter, count from the same code path the list uses.
Self-friending and morph-map drift are the two silent bugs. The first produces a user who is a friend of themselves with a weird mutual-friend count; the second produces a graph that returns empty lists after you introduce Relation::morphMap(). If you add a morph map, backfill existing sender_type/recipient_type values in a migration, and keep the map entry stable forever.
Indexes matter more than you think. The default table indexes on sender_id/recipient_id only. Add (recipient_type, recipient_id, status) — that's the exact shape of "my pending requests" and "my friends." Without it, the notification badge does a full scan on every page load.
Config caching will bite you during development. Changing the model class in config/friendship.php while config:cache is active silently keeps the old class. Run php artisan config:clear after edits and add it to your deployment script.
Vendor-risk is real here. With 18 stars and zero dependents, treat this as a small, single-maintainer dependency. Pin the exact version in composer.json, wrap it behind the FriendshipService above so a fork is a one-file swap, and write integration tests covering request → accept → unfriend → block. If the package stalls, the trait is a few hundred lines — forking into app/Support/Friendship and keeping the same interface is a weekend, not a rewrite. Do not let its models leak into your Blade views or API resources.
How can I help you explore Laravel packages today?