環境
$ sail php -v
PHP 8.2.13 (cli) (built: Nov 24 2023 08:47:18) (NTS)
$ sail artisan --version
Laravel Framework 10.39.0
課題
Laravel の Eloquent にはイベントという便利な機能があるが、処理の記述方法によってはイベントが発火しないケースもある。
ドキュメント(後述)では何故そのような違いがあるのか一切言及されていないため、フレームワークの処理を追って違いを確認する必要がある。
- 追記
- ドキュメント(後述)にて言及がありました(普通に見落としておりましたが、コメントでご指摘を頂戴しました)。
- ただ、イベントが発火する/しないの理由が何故「モデルが実際には取得されないから」なのかは、やはり処理を追わないと分からないため当記事はこのまま残します。
例1 : イベントが発火する更新クエリの発行
use App\Models\User;
$user = User->where(...)->first();
$user->update(...);
例2 : イベントが発火しない更新クエリの発行
use App\Models\User;
User::where(...)->update(...);
公式ドキュメント & 日本語ドキュメント
When issuing a mass update via Eloquent, the saving, saved, updating, and updated model events will not be fired for the updated models. This is because the models are never actually retrieved when issuing a mass update.
Eloquentを介して一括更新を発行する場合、更新されたモデルに対して、saving、saved、updating、updatedモデルイベントは発生しません。これは一括更新を実行する場合に、モデルが実際には取得されないからです。
イベントが発火する(or しない)理由
- 以下の通り、更新クエリの発行方法によって処理の流れが全く異なるから
イベントが発火するときのメソッドの呼び出しを追ってみる
use App\Models\User;
$user = User->where(...)->fist();
$user->update(...);
上記の例では、App\Models\User は Illuminate/Database/Eloquent/Model を継承しているので、$user->update() では Illuminate/Database/Eloquent/Model@update() を呼ぶ。
/**
* Update the model in the database.
*
* @param array $attributes
* @param array $options
* @return bool
*/
public function update(array $attributes = [], array $options = [])
{
if (! $this->exists) {
return false;
}
// fill() は 操作対象のインスタンス($this)を返すので、save() は Model クラスのメソッドである
return $this->fill($attributes)->save($options);
}
以下の通り、save() 内で saving イベント, saved イベントが発火することが分かる。
/**
* Save the model to the database.
*
* @param array $options
* @return bool
*/
public function save(array $options = [])
{
$this->mergeAttributesFromCachedCasts();
$query = $this->newModelQuery();
// saving イベント発火
if ($this->fireModelEvent('saving') === false) {
return false;
}
if ($this->exists) {
// updating, updated イベント発火
$saved = $this->isDirty() ?
$this->performUpdate($query) : true;
}
else {
// creating, created イベント発火
$saved = $this->performInsert($query);
if (! $this->getConnectionName() &&
$connection = $query->getConnection()) {
$this->setConnection($connection->getName());
}
}
if ($saved) {
// saved イベント発火
$this->finishSave($options);
}
return $saved;
}
performUpdate() メソッドにて updating, updated イベントが発火することが分かる。
/**
* Perform a model update operation.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @return bool
*/
protected function performUpdate(Builder $query)
{
// updating イベント発火
if ($this->fireModelEvent('updating') === false) {
return false;
}
if ($this->usesTimestamps()) {
$this->updateTimestamps();
}
$dirty = $this->getDirtyForUpdate();
if (count($dirty) > 0) {
$this->setKeysForSaveQuery($query)->update($dirty);
$this->syncChanges();
// updated イベント発火
$this->fireModelEvent('updated', false);
}
return true;
}
ちなみに、save() メソッドは $user->create() でも呼ばれる。新規作成時(performInsert() 時) には creating, created イベントが発火することも確認できる。
/**
* Perform a model insert operation.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @return bool
*/
protected function performInsert(Builder $query)
{
if ($this->usesUniqueIds()) {
$this->setUniqueIds();
}
// creating イベント発火
if ($this->fireModelEvent('creating') === false) {
return false;
}
if ($this->usesTimestamps()) {
$this->updateTimestamps();
}
$attributes = $this->getAttributesForInsert();
if ($this->getIncrementing()) {
$this->insertAndSetId($query, $attributes);
}
else {
if (empty($attributes)) {
return true;
}
$query->insert($attributes);
}
$this->exists = true;
$this->wasRecentlyCreated = true;
// created イベント発火
$this->fireModelEvent('created', false);
return true;
}
イベントが発火しないときのメソッドの呼び出しを追ってみる
use App\Models\User;
$user = User::where(...)->update(...);
上記の例では、最初に User クラスの where() メソッドを static に呼び出しているが、User クラス や継承元の Illuminate/Database/Eloquent/Model クラスには where() という static メソッドはない。
そのため、まず __callStatic() というマジックメソッドが呼び出され、その中で Model クラスのwhere() メソッドを呼ぶ。
しかし、Model クラスには where() というメソッドはない。
/**
* Handle dynamic static method calls into the model.
*
* @param string $method
* @param array $parameters
* @return mixed
*/
public static function __callStatic($method, $parameters)
{
// (new static)->where(.. $parameters)
return (new static)->$method(...$parameters);
}
そのため、__call() というマジックメソッドが呼び出される。その中で forwardCallTo() メソッドを呼ぶ。
/**
* Handle dynamic method calls into the model.
*
* @param string $method
* @param array $parameters
* @return mixed
*/
public function __call($method, $parameters)
{
if (in_array($method, ['increment', 'decrement', 'incrementQuietly', 'decrementQuietly'])) {
return $this->$method(...$parameters);
}
if ($resolver = $this->relationResolver(static::class, $method)) {
return $resolver($this);
}
if (Str::startsWith($method, 'through') &&
method_exists($this, $relationMethod = Str::of($method)->after('through')->lcfirst()->toString())) {
return $this->through($relationMethod);
}
// forwardCallTo() : Illuminate/Support/Traits/ForwardsCalls トレイトのメソッド
// $this->newQuery() : Illuminate\Database\Eloquent\Builder のインスタンス
// $method : 呼び出しているメソッド名。ここでは where
return $this->forwardCallTo($this->newQuery(), $method, $parameters);
}
上記の通り、$object は Illuminate\Database\Eloquent\Builder クラスのインスタンスなので、forwardCallTo() メソッドでは Builder クラスの where() メソッドを呼ぶことになる。
/**
* Forward a method call to the given object.
*
* @param mixed $object
* @param string $method
* @param array $parameters
* @return mixed
*
* @throws \BadMethodCallException
*/
protected function forwardCallTo($object, $method, $parameters)
{
try {
// Illuminate\Database\Eloquent\Builder の where() メソッドを呼ぶ
return $object->{$method}(...$parameters);
} catch (Error|BadMethodCallException $e) {
$pattern = '~^Call to undefined method (?P<class>[^:]+)::(?P<method>[^\(]+)\(\)$~';
if (! preg_match($pattern, $e->getMessage(), $matches)) {
throw $e;
}
if ($matches['class'] != get_class($object) ||
$matches['method'] != $method) {
throw $e;
}
static::throwBadMethodCallException($method);
}
}
Illuminate/Database/Query/Builder クラスの where() メソッドでは色々やった後に(今回の記事には関係ないので略) 自分自身、すなわち __call() メソッド内の forwardCallTo() の際に
newQuery() で生成した Illuminate/Database/Query/Builder を返している。
つまり、冒頭の User::where(...)->update(...) の update() メソッドは Illuminate/Database/Query/Builder クラスのメソッドであることが分かる。
/**
* Add a basic where clause to the query.
*
* @param \Closure|string|array|\Illuminate\Contracts\Database\Query\Expression $column
* @param mixed $operator
* @param mixed $value
* @param string $boolean
* @return $this
*/
public function where($column, $operator = null, $value = null, $boolean = 'and')
{
if ($column instanceof Closure && is_null($operator)) {
$column($query = $this->model->newQueryWithoutRelationships());
// $this->query は Illuminate/Database/Eloquent/Builder クラスのインスタンス
$this->query->addNestedWhereQuery($query->getQuery(), $boolean);
} else {
// 同上
$this->query->where(...func_get_args());
}
// 操作対象の Illuminate/Database/Eloquent/Builder クラスのインスタンス
return $this;
}
以下の通り、toBase() にて Illuminate/Database/Eloquent/Builder クラスのプロパティである
Illuminate\Database\Query\Builder クラスのインスタンスを取得し、それに対して update() を呼んでいることが分かる。
/**
* Update records in the database.
*
* @param array $values
* @return int
*/
public function update(array $values)
{
// $this->toBase() の戻り値は Illuminate\Database\Query\Builder のインスタンス(where メソッド内の $this->query にスコープを適用したもの)
return $this->toBase()->update($this->addUpdatedAtColumn($values));
}
最終的に、User::where(...)->update(...) という記述の際は Illuminate\Database\Query\Builderクラスの update() メソッドが呼ばれることが確認できる。
ここまでに Illuminate/Database/Eloquent/Model クラスのインスタンスは登場せず、かつイベント発火に関する記述も一切ないことも分かる。
当然ながら saving, saved, updating, updated といったイベントは発火しない。
/**
* Update records in the database.
*
* @param array $values
* @return int
*/
public function update(array $values)
{
$this->applyBeforeQueryCallbacks();
$sql = $this->grammar->compileUpdate($this, $values);
return $this->connection->update($sql, $this->cleanBindings(
$this->grammar->prepareBindingsForUpdate($this->bindings, $values)
));
}
結論
以下の記述は似ているが、処理内容は全然異なる。
フレームワークの処理内容をブラックボックスにせずにきちんと処理を追ってみるのは大事。
use App\Models\User;
// 各種イベントが発火する
$user = User->where(...)->first();
$user->update(...);
use App\Models\User;
// 各種イベントが発火しない
User::where(...)->update(...);
その他
- 一括削除の際に deleting, deleted イベントが発火しないことがある理由もおそらく同じと思われる(未調査)。