Featured image of post Eloquent Macro:saveQuietly 後手動觸發特定 Event

Eloquent Macro:saveQuietly 後手動觸發特定 Event

用 saveQuietly 存資料不觸發任何事件,再透過 Builder macro 定義 fire 方法,選擇性地手動觸發 created 或其他 Eloquent event。

有時候我們會用 saveQuietly() 來存資料,刻意不觸發 Eloquent event。但存完之後又想手動觸發某個特定的 event,例如只觸發 created 而不觸發 creating

Laravel 沒有內建這個功能,得自己想辦法。

saveQuietly 做了什麼

saveQuietly() 會暫時把 event dispatcher 拿掉,存完再裝回去。所以 creatingcreatedupdatingupdated 這些 event 通通不會被觸發。

1
2
3
4
5
// Laravel 原始碼簡化版
public function saveQuietly(array $options = [])
{
    return static::withoutEvents(fn () => $this->save($options));
}

問題來了:如果我在 creating event 裡做了一些 validation 或副作用,但在某些情境下想跳過 creating 直接存,存完再手動觸發 created 通知其他 listener,該怎麼做?

用 Builder macro 加一個 fire 方法

AppServiceProviderboot 裡定義 macro:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Builder;

public function boot(): void
{
    Builder::macro('fire', function (string $event) {
        /** @var Builder $this */
        $model = $this->getModel();
        $dispatcher = $model::getEventDispatcher();

        // Eloquent event 的命名格式是 "eloquent.{event}: App\Models\User"
        return $dispatcher->dispatch(
            "eloquent.{$event}: " . get_class($model),
            $model
        );
    });
}

用法很直覺:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$user = new User([
    'name' => 'Recca',
    'email' => 'recca@example.com',
    'password' => Hash::make('password'),
]);

// 靜靜地存,不觸發任何 event
$user->saveQuietly();

// 存完之後,手動觸發 created event
$user->newQuery()->fire('created');

測試驗證

寫個測試確認 saveQuietly 不會觸發 event,但 fire 可以手動觸發:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
namespace Tests\Feature;

use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;

class EloquentFireEventTest extends TestCase
{
    use RefreshDatabase;
    use WithFaker;

    public function test_save_quietly_does_not_fire_event(): void
    {
        $callback = \Mockery::spy(fn () => null);
        User::creating($callback);

        $user = new User([
            'name' => $this->faker->name,
            'email' => $this->faker->email,
            'password' => Hash::make('password'),
        ]);
        $user->saveQuietly();

        // saveQuietly 不會觸發 creating
        $callback->shouldNotHaveBeenCalled();
    }

    public function test_fire_dispatches_event_manually(): void
    {
        $callback = \Mockery::spy(fn () => null);
        User::created($callback);

        $user = new User([
            'name' => $this->faker->name,
            'email' => $this->faker->email,
            'password' => Hash::make('password'),
        ]);
        $user->saveQuietly();

        // 手動觸發 created event
        $user->newQuery()->fire('created');

        $callback->shouldHaveBeenCalled()->once();
    }
}

另一種寫法

如果覺得每次都要 $user->newQuery()->fire(...) 太囉唆,也可以直接在 Model 上加 trait:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
trait FiresEvents
{
    public function fireModelEvent(string $event): mixed
    {
        return static::getEventDispatcher()->dispatch(
            "eloquent.{$event}: " . static::class,
            $this
        );
    }
}

這樣就可以直接 $user->fireModelEvent('created') 了。不過 Laravel 的 Model 其實已經有一個 protectedfireModelEvent 方法,所以要注意命名衝突,可能改叫 dispatchModelEvent 比較安全。