sly/relation-bundle
A lightweight PHP package for defining and managing relationships between models. It simplifies linking records, querying related data, and keeping relation logic reusable, with a clean API that fits naturally into existing Eloquent workflows.
The one-sentence orientation: this is a Symfony 2 bundle, so there is no composer require step in a Laravel app. Your "day-to-day leverage" is 30–60 minutes of reading its entity definitions and relation-manager service, then a single short build cycle reimplementing the schema in Eloquent. Budget it as a spike, not an integration.
Minimal steps
composer.json.DependencyInjection extension and *Bundle class entirely; they are Symfony wiring with no Laravel analogue.Where to look first
First use case
Pick the smallest real one in your app — "user follows user," or "contact belongs to a user's address book." Model it with a typed, statusful polymorphic pivot in Eloquent:
Schema::create('relations', function (Blueprint $table) {
$table->id();
$table->string('source_type');
$table->unsignedBigInteger('source_id');
$table->string('target_type');
$table->unsignedBigInteger('target_id');
$table->string('relation_type', 32); // friend | follow | contact | block
$table->string('status', 16)->default('pending'); // pending | accepted | blocked | revoked
$table->unsignedBigInteger('created_by')->nullable();
$table->json('meta')->nullable();
$table->timestamps();
$table->unique(
['source_type','source_id','target_type','target_id','relation_type'],
'relations_edge_unique'
);
$table->index(['target_type','target_id','relation_type','status'], 'relations_reverse_idx');
$table->index(['source_type','source_id','relation_type','status'], 'relations_source_idx');
});
That table covers friends, followers, contacts, blocks, and org membership with one write path. That is the entire "package." Everything below is how to keep it from rotting.
Single write path. Never let controllers or models write to relations directly. One service class owns attach/detach/accept/block; everything else reads.
final class RelationService
{
public function attach(Model $source, Model $target, string $type, string $status = 'pending'): Relation
{
if ($source->is($target) || ($source::class === $target::class && $source->getKey() === $target->getKey())) {
throw new InvalidArgumentException('Self-relations are not allowed.');
}
[$from, $to] = $this->canonicalise($source, $target);
return DB::transaction(fn () => Relation::updateOrCreate(
[
'source_type' => $from::class,
'source_id' => $from->getKey(),
'target_type' => $to::class,
'target_id' => $to->getKey(),
'relation_type' => $type,
],
['status' => $status, 'created_by' => auth()->id()]
));
}
/** Order endpoints deterministically so an undirected edge is stored exactly once. */
private function canonicalise(Model $a, Model $b): array
{
return [$a::class, (string) $a->getKey()] <=> [$b::class, (string) $b->getKey()] <= 0
? [$a, $b]
: [$b, $a];
}
}
The canonical ordering is the single most important porting decision. It halves row count for symmetric relations and makes duplicate edges structurally impossible rather than a reconciliation job.
Reading relations back. Add scopes to the Relation model rather than scattering where clauses:
public function scopeFor(Builder $q, Model $model, ?string $type = null): Builder
{
return $q->where(fn ($w) => $w
->where('source_type', $model::class)->where('source_id', $model->getKey())
->orWhere('target_type', $model::class)->where('target_id', $model->getKey()))
->when($type, fn ($q) => $q->where('relation_type', $type));
}
Traits on the domain models. morphToMany works for the directional case; for the canonical-undirected case, a small HasRelations trait wrapping the service and scopes is clearer than fighting morph semantics:
trait HasRelations
{
public function relationsTo(string $type = 'friend', array $statuses = ['accepted']): Builder
{
return Relation::query()->for($this, $type)->whereIn('status', $statuses);
}
public function friends(): Collection
{
return $this->relationsTo('friend')
->get()
->map(fn (Relation $r) => $r->other($this))
->filter()->values();
}
}
State transitions as explicit methods, not arbitrary update() calls — request(), accept(), block(), revoke() — each idempotent so repeated clicks or retried jobs are harmless. accept() should be a conditional update (where('status','pending')) so a concurrent revoke cannot be silently overwritten.
Traversal. A one-hop join is fine; two hops (friends-of-friends) is where you plan ahead. Either add staudenmeir/eloquent-has-many-deep and accept recursive CTE/join performance, or memoise a relation_edges cache table if the query is on a hot path. Do not assume the bundle's query helpers give you traversal — they generally don't, and this is the requirement most likely to outgrow the pivot.
Events over coupling. Fire RelationAttached / RelationAccepted / RelationRevoked from the service. Notifications, feed fan-out, and cache invalidation subscribe; the graph stays clean and testable.
morphToMany enforces no referential integrity. Deleting a User leaves relations rows pointing at a ghost. Handle it in a deleting model event and a scheduled integrity sweep — belt and braces, because soft deletes and bulk deletes bypass events.QueryException, re-read the row, and return it; do not let a 500 leak out of a "add friend" button.target_type, target_id. Omitting that index is the most common performance regression in this design and it only bites at thousands of edges, i.e. in production.VARCHAR polymorphic columns plus a five-column unique key can blow the 3072-byte index limit. Use a short relation_type, or map class names to a small int via a morphMap, which also buys you the ability to rename classes without a data migration.whereNotExists on a block of the same pair). Deleting loses history and makes unblock unimplementable.status on symmetric edges is a single row's status. In a "friend request" flow the requester and addressee have different roles; keep created_by and derive direction from it rather than adding a direction column that can disagree with the canonical ordering.with('relations') on a page of users will N+1 across the morph if you then call ->other($this) per row. Batch-load by type and key them in memory.relation_types with a is_symmetric flag and enforce symmetry in the service.How can I help you explore Laravel packages today?