Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Relation Bundle Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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

  1. Clone the repo read-only; do not add it to composer.json.
  2. Read in this order — the relation entity mapping (columns and associations), the relation-manager service (the attach/detach/query API), and any repository/query helper. Skip the DependencyInjection extension and *Bundle class entirely; they are Symfony wiring with no Laravel analogue.
  3. Write a one-page spec: the pivot columns, the symmetry flag's meaning, the status lifecycle, and the query patterns for "related to X."
  4. Build the pivot + service in Laravel (see Implementation Patterns) behind tests written from that spec.

Where to look first

  • The mapped entity for the relation row: this is the schema you are actually porting.
  • The service that performs attach/detach: this is where self-relation rejection, bidirectional dedupe, and status transitions live. Those rules are the valuable part.
  • Any query helper: it tells you which traversals the author considered first-class (typically "list relations for an entity," not "friends-of-friends").

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.

Implementation Patterns

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.

Gotchas and Tips

  • Doctrine-to-Eloquent drift is the real trap. Engineers who read the reference sometimes assume cascade-persist semantics on related collections. Eloquent will happily save a parent and orphan the pivot rows. Write the invariant tests first: no self-relations, no duplicate edges, block beats friend, accept is idempotent.
  • 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.
  • Concurrency. Two simultaneous friend requests from both sides will race the unique index. Catch the QueryException, re-read the row, and return it; do not let a 500 leak out of a "add friend" button.
  • Index the reverse direction. Every "who follows me" query uses 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.
  • String type columns and index length. On MySQL, 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.
  • Block precedence belongs in the read query, not the write path. Do not delete the friend edge when someone blocks; store the block and filter at read time (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.
  • Eager loading. 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.
  • Do not copy code verbatim. The repo is explicitly unmaintained and the license should be verified before pasting anything. A clean-room reimplementation from the schema and behaviour spec sidesteps the question and will be shorter than the original anyway.
  • Extension point worth keeping: relation types as a config array or enum, not a DB table, until you actually need runtime-defined types. When you do, add relation_types with a is_symmetric flag and enforce symmetry in the service.
  • The day-to-day reality: once built, this is ~200 lines you own. The "package" contributes a schema shape and a set of invariants — worth a day of reading, not a line of dependency.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle