【Laravel】追加・変更・削除・閲覧した人の履歴を自動で保存する

こんにちは。フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、幸運にも長らく開発のお仕事をさせていただいているのですが、その中には、「それほど需要が多いわけではないけど、たまに欲しい」と言われる機能があります。

それが・・・・・・

データ操作した人の情報を履歴として残しておく機能

です。

実際にご依頼をいただいたケースでは、「誰がそのデータを操作したのか」が分かるようになるので、使う人間に緊張感をもってもらうことができたり、もしくは珍しいケースでは「不正なデータ処理をしたらすぐバレるからやめとこ」となることが目的でした。(会社によって、いろんなケースがありますね)

そこで❗

今回はこの「操作した人の履歴保存」をLaravelで実装してみます。

ぜひ皆さんのお役にたてると嬉しいです😊✨
(最後の実際に開発したソースコードをダウンロードできますよ👍)

「この間教えてもらった obniz
興味津々です❗」

開発環境: Laravel 8.x

やりたいことの詳細

特定のテーブルに以下4つの処理があったら履歴を残すようにします。

  • データ追加
  • データ変更
  • データ削除
  • データ閲覧

そして、保存する履歴データの中身は以下3つです。

  • いつ: 操作が行われた時間
  • 誰が: ユーザーID
  • 何を: どこのテーブルのどのデータを操作したか

なお、せっかく作るのでどんなテーブルでも汎用的に使えるように実装していきます。

では、楽しんでやっていきましょう❗

前提として

すでにLaravelにログイン機能がインストールされ、さらにテストユーザーが登録済みであることが前提です。

もしまだの方は以下のURLを参考にして先に作業を済ませておいてください。

📝 参考URL

履歴を保存するテーブルを作成する

では、まずは「いつ/誰が/何を操作したか」がわかる履歴を管理するテーブルを作成します。

以下のコマンドを実行してください。

php artisan make:model ModelHistory -m

すると、マイグレーションとモデルが一気に作成されますので、マイグレーションを以下のように変更してください。

// 省略

public function up()
{
    Schema::create('model_histories', function (Blueprint $table) {
        $table->id();
        $table->string('model')->comment('モデル名');
        $table->unsignedBigInteger('model_id')->comment('モデルのID');
        $table->unsignedBigInteger('user_id')->comment('操作したユーザー');
        $table->string('operation_type')->comment('操作のタイプ'); // created, updated, deleted, retrieved
        $table->timestamps();

        $table->foreign('user_id')->references('id')->on('users');
    });
}

// 省略

マイグレーションの変更ができたら以下のコマンドで実際にテーブルを作成してください。

php artisan migrate

完了するとテーブルはこうなります。

Traitをつくる

では、履歴を自動保存する部分をつくっていきますが、使い回しがしやすいようにTraitで実行するようにします。

以下のファイルを作成してください。

app/Traits/ModelHistoryTrait.php

<?php

namespace App\Traits;

use App\Models\ModelHistory;

trait ModelHistoryTrait {

    public static function saveModelHistory($types = ['created', 'updated', 'deleted', 'retrieved']) {

        if(auth()->check()) {

            foreach($types as $type) {

                forward_static_call([__CLASS__, $type], function($model) use($type){

                    $user = auth()->user();
                    $history = new ModelHistory();
                    $history->model = get_class($model);
                    $history->model_id = $model->id;
                    $history->user_id = $user->id;
                    $history->operation_type = $type;
                    $history->save();

                });

            }

        }

    }

    // Relationship
    public function model_histories() {

        return $this->hasMany(ModelHistory::class, 'model_id', 'id')
            ->where('model', __CLASS__);

    }

}

少し見慣れない関数forward_static_callが入っていますが、この中でやっているのは、以下4つのイベントを用意しているだけです。

static::created(function($model){

    //

});
static::updated(function($model){

    //

});
static::deleted(function($model){

    //

});
static::retrieved(function($model){

    //

});

つまり、ループを使って動的に実行するようにし、省コード化しています。(ただ、おそらく私もforward_static_call()を使ったのは初めてです。さすがPHP。関数が豊ですね😂)

そして、model_histories()は、該当データに関連する全履歴データをリレーションシップで取得できるようにしています。

ここで注目してほしいのが、where('model', __CLASS__)の部分です。これをつけていないと、別モデルの同じIDを持つデータまで取得してしまうことになるからです。そのため、クラス名を使って絞り込みをしています。

テスト用モデルをつくる

では、この時点ですでに履歴保存用のコードは完了していますが、テスト用にSong、そしてBookというモデルと、それらに対応するテーブルを作っておきましょう。

※ なお、ほぼ同じ内容ですので、今回はSongのみのご紹介をします。

以下のコマンドを実行してください。

php artisan make:model Song -m

すると、モデルとマイグレーションが作成されるので、まずモデルの中身を以下のようにしてください。

app/Models/Song.php

<?php

namespace App\Models;

use App\Traits\ModelHistoryTrait; // 👈 ここを追加しました
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Song extends Model
{
    use HasFactory, ModelHistoryTrait; // 👈 ここを追加しました

    public static function boot() // 👈 ここを追加しました
    {
        parent::boot();

        self::saveModelHistory();
    }
}

続いてマイグレーションです。(追加するフィールドはお好みで自由に変更してもOKです👍)

database/migrations/****_**_**_******_create_songs_table.php

// 省略

public function up()
{
    Schema::create('songs', function (Blueprint $table) {
        $table->id();
        $table->string('name');
        $table->timestamps();
    });
}

// 省略

では、Bookも同様に作業をしてから以下のコマンドを実行してください。

php artisan migrate

実行が完了するとテーブルはそれぞれ以下のようになります。

テストする

では、実際にテストしてみましょう❗
実行手順は以下のとおりです。

  1. ログイン
  2. データ追加
  3. データ変更
  4. データ閲覧
  5. データ削除

ルートに以下のコードを追加してください。

routes/web.php

Route::get('model_history', function(){

    // ログイン
    auth()->loginUsingId(1);

    // データ追加
    $song = new \App\Models\Song();
    $song->name = 'テスト曲名1';
    $song->save();

    sleep(1);

    // データ変更
    $song->name = 'テスト曲名2';
    $song->save();

    sleep(1);

    // データ閲覧
    $song = \App\Models\Song::first();

    sleep(1);
    
    // データ削除
    $song->delete();

});

内容としては、「IDが1のユーザーで、songsテーブルに対し各4つの操作を行う」です。

※ なお、履歴データの時間が同じにならないように各操作の間にsleep()をはさんでいます。

では「http://******/model_history」にアクセスしてしてみましょう。

すると・・・・・・

はい❗

うまくテーブルmodel_historiesに各データが登録が登録されています。

では続いて、ルートの中身を「IDが2のユーザーで、今度はbooksテーブルに対して各4つの操作する」に変更して再度実行してみましょう。

routes/web.php

Route::get('model_history', function(){

    // ログイン
    auth()->loginUsingId(2);

    // データ追加
    $book = new \App\Models\Book();
    $book->name = 'テスト本の名前1';
    $book->save();

    sleep(1);

    // データ変更
    $book->name = 'テスト本の名前2';
    $book->save();

    sleep(1);

    // データ閲覧
    $book = \App\Models\Book::first();

    sleep(1);

    // データ削除
    $book->delete();

});

すると・・・・・・

別のモデルでも履歴を登録することができました。
成功です😊✨

では、せっかくですのでモデルに追加したリレーションシップもチェックしておきましょう。

まず、データが物理削除されてしまうとリレーションシップどころかデータ取得ができなくなってしまいますので、ソフトデリートを有効にしておきます。

まず、マイグレーションにsoftDeletes()を追加してください。

database/migrations/****_**_**_******_create_songs_table.php

Schema::create('songs', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->softDeletes();  // 👈 ここを追加しました
    $table->timestamps();
});

次に、以下のコマンドでテーブルを再構築します。

php artisan migrate:fresh --seed

するとテーブルは以下のように変更になりました。

続いて、Songモデルがソフトデリートに対応するようにしておきます。

<?php

namespace App\Models;

use App\Traits\ModelHistoryTrait;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes; // 👈 ここを追加しました

class Song extends Model
{
    use HasFactory, ModelHistoryTrait, SoftDeletes; // 👈 ここを追加しました

    // 省略
}

では、この状態で以下のコードを実行してみましょう。

routes/web.php

Route::get('model_history', function(){

    // ログイン
    auth()->loginUsingId(1);

    // データ追加
    $song = new \App\Models\Song();
    $song->name = 'テスト曲名1';
    $song->save();

    sleep(1);

    // データ変更
    $song->name = 'テスト曲名2';
    $song->save();

    sleep(1);

    // データ削除(ソフトデリート)
    $song->delete();

    sleep(1);

    // データ閲覧
    $song = \App\Models\Song::withTrashed()->with('model_histories')->first();
    dd($song->toArray());

});

なお、この中で重要なのがwithTrashed()です。
これをつけていると、すでにソフトデリートされたデータも取得することができるようになります。

そして、さらにwith('model_histories')でリレーションシップで履歴データを呼び出しています。

では、実行してみます。

すると・・・・・・

はい❗

関連する履歴データがmodel_historiesとして取得できています。
成功です😊👍✨

ちなみに

先ほどつくったModelHistoryTraitsaveModelHistory()は、引数で「どの履歴を保存するか」を指定することができます。

例えば、deletedだけ履歴を残したい場合は以下のようになります。

app/Models/Song.php

<?php

// 省略

class Song extends Model
{
    use HasFactory, ModelHistoryTrait, SoftDeletes;

    public static function boot()
    {
        parent::boot();

        self::saveModelHistory(['deleted']); // 👈 ここです
    }
}

ダウンロードする

今回実際に開発したソースコード一式を以下からダウンロードすることができます。

追加・変更・削除・閲覧した人の履歴を自動で保存する

※ ただし、マイグレーション等はご自身で行っていただく必要があります。

開発のご依頼お待ちしております
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします! by 九保すこひ

おわりに

ということで、今回はデータの操作履歴を残すための機能を作ってみました。

Laravelにはイベントなど「ラク」できる機能がたくさんついていますので、予想以上にシンプルなコードで完成させることができました👍

なお、この機能を拡張するアイデアとしては、どのデータからどのデータに変更されたかというold_value, new_value みたいなフィールドをつくっておけば、あとでより詳しい履歴データを確認することができるかと思います。

ぜひみなさんも、いろいろとアイデアを考えてみてくださいね。

ではでは〜❗

「あぁ〜、ジェフ・ベゾスがお兄ちゃんだったらな〜
ゴロゴロ〜(笑) by ずん 飯尾さん」

このエントリーをはてなブックマークに追加       follow us in feedly