Skip to content

Conversation

@binaryfire
Copy link
Contributor

@binaryfire binaryfire commented Dec 30, 2025

Summary

This PR adds support for firing model events on custom pivot models when using ->using() on BelongsToMany and MorphToMany relationships, matching Laravel's behavior.

The Problem

When using a custom pivot model via ->using(CustomPivot::class) on a BelongsToMany or MorphToMany relationship, Hyperf uses raw queries for attach(), detach(), and updateExistingPivot(). This means model events (creating, created, deleting, deleted, saving, saved, etc.) never fire on the pivot model.

The Solution

Following Laravel's implementation pattern, this PR:

  1. Creates InteractsWithPivotTable trait - Overrides attach(), detach(), updateExistingPivot(), and newPivot() to check if $this->using is set. When a custom pivot class is specified, operations use model methods (save(), delete()) instead of raw queries, enabling model events to fire.

    See Laravel's InteractsWithPivotTable

  2. Updates BelongsToMany - Uses the new trait.

  3. Updates MorphToMany - Uses the new trait, with an override of getCurrentlyAttachedPivots() to properly set morph type/class on the pivot models (matching Laravel's pattern).

  4. Fixes Pivot class - Two issues were discovered and fixed:

    • Missing event dispatcher: Hypervel's Pivot extends Hyperf\Database\Model\Relations\Pivot which extends Hyperf\Database\Model\Model directly. Unlike regular models (which go through Hyperf\DbConnection\Model\Model), it didn't have the HasContainer trait that provides getEventDispatcher() from the container. Added HasContainer to fix this.
    • Missing delete events for composite keys: Hyperf's AsPivot::delete() only fires events when the pivot has a primary key. Most pivot tables use composite foreign keys instead. Added a delete() override that manually fires deleting/deleted events for composite key pivots (matching Laravel's behavior).
  5. Fixes MorphPivot class - Same issues as Pivot, plus:

    • Added HasContainer and HasCallbacks traits
    • Override delete() to fire events AND include the morph type constraint (so only the correct polymorphic records are deleted)
  6. Adds HasCallbacks - Enables registerCallback() on pivot models for event registration.

Performance Consideration

This is opt-in and preserves existing performance characteristics:

Scenario Behavior
Without ->using() Bulk queries (single INSERT/DELETE) - no change
With ->using(CustomPivot::class) N queries via model save()/delete() - events fire

Users choose the tradeoff. If you need pivot model events, use ->using(). If you need maximum performance and don't need events, don't use ->using().

Example Usage

BelongsToMany

// Define a custom pivot model
class RoleUser extends Pivot
{
    protected function boot(): void
    {
        parent::boot();

        static::registerCallback('creating', function ($pivot) {
            Log::info("Assigning role {$pivot->role_id} to user {$pivot->user_id}");
        });

        static::registerCallback('deleting', function ($pivot) {
            Log::info("Removing role {$pivot->role_id} from user {$pivot->user_id}");
        });
    }
}

// Use the custom pivot on the relationship
class User extends Model
{
    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class)
            ->using(RoleUser::class);  // Events will fire
    }
}

// These operations now fire pivot model events
$user->roles()->attach($roleId);           // Fires creating, created
$user->roles()->detach($roleId);           // Fires deleting, deleted
$user->roles()->sync([$role1, $role2]);    // Fires appropriate events
$user->roles()->updateExistingPivot($id, ['active' => true]); // Fires saving, updating, updated, saved

MorphToMany

// Define a custom morph pivot model
class Taggable extends MorphPivot
{
    protected function boot(): void
    {
        parent::boot();

        static::registerCallback('creating', function ($pivot) {
            Log::info("Tagging {$pivot->taggable_type}:{$pivot->taggable_id} with tag {$pivot->tag_id}");
        });
    }
}

// Use the custom pivot on a polymorphic relationship
class Post extends Model
{
    public function tags(): MorphToMany
    {
        return $this->morphToMany(Tag::class, 'taggable')
            ->using(Taggable::class);  // Events will fire
    }
}

// These operations now fire morph pivot model events
$post->tags()->attach($tagId);   // Fires creating, created
$post->tags()->detach($tagId);   // Fires deleting, deleted (correctly scoped to Post type only)

Files Changed

  • src/core/src/Database/Eloquent/Relations/Concerns/InteractsWithPivotTable.php (new)
  • src/core/src/Database/Eloquent/Relations/BelongsToMany.php (modified)
  • src/core/src/Database/Eloquent/Relations/MorphToMany.php (modified)
  • src/core/src/Database/Eloquent/Relations/Pivot.php (modified)
  • src/core/src/Database/Eloquent/Relations/MorphPivot.php (modified)
  • tests/Core/Database/Eloquent/Relations/BelongsToManyPivotEventsTest.php (new)
  • tests/Core/Database/Eloquent/Relations/MorphToManyPivotEventsTest.php (new)
  • tests/Core/Database/Eloquent/Relations/migrations/ (new/modified)

Tests

25 tests covering:

BelongsToMany (13 tests):

  • attach() - single and multiple, with and without custom pivot
  • detach() - single, multiple, and all, with and without custom pivot
  • updateExistingPivot() - fires events when dirty, skips when not dirty
  • sync() - fires events for attach, detach, and update operations
  • toggle() - fires events for both attach and detach

MorphToMany (12 tests):

  • attach() - single and multiple, with and without custom morph pivot
  • detach() - single, multiple, and all, with and without custom morph pivot
  • updateExistingPivot() - fires events when dirty, skips when not dirty
  • sync() - fires events for attach, detach, and update operations
  • toggle() - fires events for both attach and detach
  • Morph type constraint - verifies detach only deletes for the correct polymorphic type (e.g., detaching from Post doesn't affect Video's tags)

When using a custom pivot class via ->using(), attach/detach/sync/toggle
now fire model events (creating, created, deleting, deleted, etc.) on
the pivot model. Without ->using(), bulk queries are preserved.

- Add InteractsWithPivotTable trait to override attach/detach/updateExistingPivot
- Add HasContainer trait to Pivot for container-based event dispatcher
- Add HasCallbacks trait to Pivot for registerCallback() support
- Override Pivot::delete() to fire events for composite key pivots
- Add HasCallbacks and HasContainer traits to MorphPivot
- Override MorphPivot::delete() to fire events with morph type constraint
- Add InteractsWithPivotTable trait to MorphToMany
- Override MorphToMany::getCurrentlyAttachedPivots() to set morph type/class
- Add comprehensive tests for MorphToMany pivot events (12 tests)
@binaryfire binaryfire changed the title Add pivot model events support for BelongsToMany relationships feat: Add pivot model events support for BelongsToMany and MorphToMany relationships Dec 30, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant