【Laravel7でユーザー認証_5】ユーザーを論理削除（SoftDelete）する

# はじめに

ユーザーを削除する方法として、データベースからデータを消してしまう「物理削除（HardDelete）」と、削除フラグを立てて処理をする「論理削除（SoftDelete）」があります。
ここでは、「論理削除」でユーザーを削除する手順をまとめます。

# 環境

XAMPP環境でLaravelが使えるように設定してあります。

- Windows10 Pro 64bit
- PHP 7.3.18
- Laravel 7.12.0
- MariaDB 10.1.32

また、Laravelプロジェクトは以下の手順で、ユーザー認証をユーザー名とパスワードで行えるようにしてあります。

- [【Laravel7でユーザー認証_1】基本のき](https://qiita.com/crosawassant/items/bf3982666b6333bcb8c8)
- [【Laravel7でユーザー認証_2】ユーザー認証を日本語化](https://qiita.com/crosawassant/items/d1f4c5c93f30afa9eb65)
- [【Laravel7でユーザー認証_3】ユーザー認証をメールアドレスからユーザー名に変更する](https://qiita.com/crosawassant/items/2464db8a0fe1c603401a)
- [【Laravel7でユーザー認証_4】パスワード変更フォームを作成する](https://qiita.com/crosawassant/items/04242c8d7a12346dcc20)

#実装

## 論理削除用のカラムを追加する

Laravelで用意されている `$table->softDeletes();` を使って、`users` テーブルに`deleted_at` という論理削除用のカラムを追加します。

```
$ php artisan make:migration add_softdeletes_to_users --table=users
```

```diff:database/migrations/日付_add_softdeletes_to_users.php
      /**
       * Run the migrations.
       *
       * @return void
       */
      public function up()
      {
          Schema::table('users', function (Blueprint $table) {
-             //
+             $table->softDeletes();
          });
      }

      /**
       * Reverse the migrations.
       *
       * @return void
       */
      public function down()
      {
          Schema::table('users', function (Blueprint $table) {
-             //
+             $table->dropColumn('deleted_at');
          });
      }
```

```
$ php artisan migrate
```

実行すると、usersテーブルにdeleted_atというカラムが追加されます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262236/c0fa5515-cb9c-969e-e260-c9c69c8253fe.png" width="600">


## usersテーブルのunique制約を削除する

論理削除は、削除フラグを立てるだけでレコード自体は削除されません。
そのため、メールアドレス（ユーザー名）にunique制約がついていると、削除後に同じメールアドレス（ユーザー名）での登録ができなくなります。
このusersテーブルに設定されているunique制約を削除します。

```
$ php artisan make:migration drop_unique_to_users --table=users
```

```diff:database/migrations/日付_drop_unique_to_users.php
      /**
       * Run the migrations.
       *
       * @return void
       */
      public function up()
      {
          Schema::table('users', function (Blueprint $table) {
-             //
+             $table->dropUnique('users_email_unique');
+             $table->dropUnique('users_username_unique');
          });
      }

      /**
       * Reverse the migrations.
       *
       * @return void
       */
      public function down()
      {
          Schema::table('users', function (Blueprint $table) {
-             //
+             $table->unique('email', 'users_email_unique');
+             $table->unique('username', 'users_username_unique');
          });
      }
```

## usersテーブルに複合unique制約を追加する

unique制約を外してしまったので、このままだと同じメールアドレス（ユーザー名）が登録できてしまいます。

▼現状
※山田太郎も山田商店も同じメールアドレス（ユーザー名）で登録できてしまう。

|名前|ユーザー名|メールアドレス|deleted_at
|:--|:--|:--|:--
|山田太郎|yamada|yamada@example.jp|NULL
|山田商店|yamada|yamada@example.jp|NULL
|太郎|taro|taro@example.com|NULL

これだと山田商店でログインした際、山田太郎のデータを引っ張ってきてしまったり、パスワード再設定メールを発行した際に、希望のアカウントの再設定ができなくなる可能性があります。
「メールアドレス（ユーザー名）の重複は原則禁止、ただしdeleted_atに値が入っていたら許可」という仕様にするため、**deleted_atとメールアドレス（ユーザー名）の複合unique制約**を追加します。

▼複合unique制約で実現したい仕様
※山田商店は削除済アカウントなので、メールアドレス（ユーザー名）の重複が可能。

|名前|ユーザー名|メールアドレス|deleted_at
|:--|:--|:--|:--
|山田太郎|yamada|yamada@example.jp|NULL
|山田商店|yamada|yamada@example.jp|2000-01-01 00:00:00
|太郎|taro|taro@example.com|NULL

```
$ php artisan make:migration add_unique_to_users --table=users
```

```diff:database/migrations/日付_add_unique_to_users.php
      /**
       * Run the migrations.
       *
       * @return void
       */
      public function up()
      {
          Schema::table('users', function (Blueprint $table) {
-             //
+             $table->unique(['email', 'deleted_at'], 'users_email_deleted_at_unique');
+             $table->unique(['username', 'deleted_at'], 'users_username_deleted_at_unique');
          });
      }

      /**
       * Reverse the migrations.
       *
       * @return void
       */
      public function down()
      {
          Schema::table('users', function (Blueprint $table) {
-             //
+             $table->dropUnique('users_email_deleted_at_unique');
+             $table->dropUnique('users_username_deleted_at_unique');
          });
      }
```

```
$ php artisan migrate
```

実行すると、複合unique制約が作成されます。

![Laravel-softdelete-02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262236/c6bde389-4245-b101-82f2-42dc8163f952.png)

## Userモデルの変更

Userモデルで、SoftDeletesトレイトが使用できるよう設定します。

```diff:app/User.php
  namespace App;
  
  use Illuminate\Contracts\Auth\MustVerifyEmail;
  use Illuminate\Foundation\Auth\User as Authenticatable;
  use Illuminate\Notifications\Notifiable;
+ use Illuminate\Database\Eloquent\Model;
+ use Illuminate\Database\Eloquent\SoftDeletes;

  class User extends Authenticatable
  {
+     use Notifiable;
+     use SoftDeletes;
  
      /**
       * The attributes that are mass assignable.
       *
       * @var array
       */
      protected $fillable = [
          'name', 'email', 'username', 'password',
      ];
```

## ユーザー登録時のバリデーション変更

ユーザー登録時のバリデーションチェックとして、複合unique制約でのチェックに変更します。

```diff:app/Http/Controlles/Auth/RegisterController.php
  <?php
  
  namespace App\Http\Controllers\Auth;
  
  use App\Http\Controllers\Controller;
  use App\Providers\RouteServiceProvider;
  use App\User;
  use Illuminate\Foundation\Auth\RegistersUsers;
  use Illuminate\Support\Facades\Hash;
  use Illuminate\Support\Facades\Validator;
+ use Illuminate\Validation\Rule;

  ==（中略）==

      /**
       * Get a validator for an incoming registration request.
       *
       * @param  array  $data
       * @return \Illuminate\Contracts\Validation\Validator
       */
      protected function validator(array $data)
      {
          return Validator::make($data, [
              'name' => ['required', 'string', 'max:255'],
-             'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
+             'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')->whereNull('deleted_at'),],
              'password' => ['required', 'string', 'min:8', 'confirmed'],
-             'username' => ['required', 'string', 'max:32', 'unique:users', 'regex:/^[a-zA-Z0-9-_]+$/'],
+             'username' => ['required', 'string', 'max:32', Rule::unique('users', 'username')->whereNull('deleted_at'), 'regex:/^[a-zA-Z0-9-_]+$/'],
          ]);
      }
```

##一旦確認
ここまでで、実際にユーザーの論理削除ができるかどうか、`php artisan tinker` で検証してみます。
[Laravel Tinker](https://readouble.com/laravel/7.x/ja/artisan.html#tinker)はコマンドラインでLaravelアプリケーション全体を操作できるコマンドです。

▼ユーザーIDが1のユーザーを削除

```
$ php artisan tinker
>>> $user = User::find(1)
　ユーザーIDが1の情報が表示
>>> $user->delete()
>>> $user = User::find(1)
　null　と表示されていたら論理削除成功
```

データベース上では、deleted_atに日時が入っています。
![Laravel-softdelete-03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262236/6f5bf662-e5ed-d8bc-c6c5-acbaf948d8bc.png)

▼削除したID1のユーザーを元に戻す

```
>>> User::onlyTrashed()->where('id',1)->restore()
>>> $user = User::find(1)
　ユーザーIDが1の情報が表示
```
`exit`と打ち込むか、`Ctrl+C`でTinkerが終了します。

## アカウント削除のボタンを実装する

deactiveというページにアカウント削除ボタンを実装します。
ログインパスワードの再入力や、間違いがないかどうかの承認チェックボックスなども必要に思いますが、今回はシンプルにアカウント削除ボタンを押せば削除完了という仕様で進めます。

###コントローラの作成

ユーザー削除用のコントローラを作成します。
Authディレクトリの中に「DeactiveController.php」というファイルを追加しました。

```
$ php artisan make:controller Auth/DeactiveController
```

###ルーティングの設定

以下の仕様で、ルーティングを設定します。

通常のアクセス（GET）の場合は、「Auth\DeactiveController」コントローラの「showDeactiveForm」メソッドを実行。
パスワードを変更の処理（POST）の場合は、「Auth\DeactiveController」コントローラの「deactive」メソッドを実行。
それぞれのルーティングには、「deactive.form」、「deactive」という名前を付けました。

```diff:/routes/web.php
  Route::post('/password/change', 'Auth\ChangePasswordController@ChangePassword')->name('password.change');
+ Route::get('/deactive', 'Auth\DeactiveController@showDeactiveForm')->name('deactive.form');
+ Route::post('/deactive', 'Auth\DeactiveController@deactive')->name('deactive');
```

###viewの作成

viewは、auth の中に deactive.blade.php を作成しました。
formのアクション先は、ルーティングで付けた「deactive」という名前を使います。

```php:resources/views/auth/deactive.blade.php
@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <div class="card">
                <div class="card-header">{{ __('Deactive') }}</div>

                <div class="card-body text-center">
                    <form method="POST" action="{{ route('deactive') }}">
                        @csrf
                        <h2>{{ __('This will deactivate your account.') }}</h2>
                        <p>{{ __('Press the Deactive button to continue the process.') }}</p>
                        <button type="submit" class="btn btn-primary">
                            {{ __('Deactive') }}
                        </button>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
@endsection
```

###翻訳ファイルに追加

view内で使った翻訳文字をja.jsonに追加します。

```diff:resource/lang/ja.json
-     "Your password has been changed.": "パスワードが変更されました。"
+     "Your password has been changed.": "パスワードが変更されました。",
+     "Deactive": "アカウント削除",
+     "This will deactivate your account.": "アカウントを削除しようとしています。",
+     "Press the Deactive button to continue the process.": "アカウント削除の処理を続けるにはアカウント削除のボタンを押してください。"
  }
```

###コントローラにメソッド追加

`DeactiveController` に、ルーティングで指定したメソッドを追加します。

`showDeactiveForm` メソッド内では、作成したview（auth/deactive.blade.php）が表示されるように指定します。

`deactive` メソッドは、ユーザーを論理削除する処理を入れます。
削除処理後は、トップページにリダイレクトされるようにしました。

ログインしていない状態では表示させないようにAuthミドルウェアの指定を忘れずに…。

```diff:app/Http/Controllers/Auth/DeactiveController.php
  <?php

  namespace App\Http\Controllers\Auth;

  use App\Http\Controllers\Controller;
  use Illuminate\Http\Request;
+ use Illuminate\Support\Facades\Auth;
+ use App\User;

  class DeactiveController extends Controller
  {
+     public function __construct()
+     {
+         $this->middleware('auth');
+     }
+ 
+     public function showDeactiveForm()
+     {
+       return view('auth\deactive');
+     }
+ 
+     public function deactive()
+     {
+       User::find(Auth::id())->delete();
+       return redirect('/');
+     }
  }
```

これで実装は完了です。
アカウント削除のボタンを押すと、データベースのdelete_atに削除ボタンを押した日時が入っているのが確認できます。

# 蛇足

- 複合ユニーク制約で、論理削除したユーザーは重複を無視するように設計しましたが、論理削除だとデータベースにいつまでもデータが残ってしまいます。一定期間が経過したデータは削除するように、何らかの処理が必要そうです。
- Twitterの場合、アカウントを削除しても30日間は仮削除の状態になるので、 同じように、論理削除しても30日以内なら復活可能、30日経過したら物理削除　という処理をしてもいいのかなと思いました。

# 参考サイト
- [Eloquent：利用の開始 7.x Laravel](https://readouble.com/laravel/7.x/ja/eloquent.html#soft-deleting)
- [Laravelで論理削除(SoftDelete) | WEBxREAL](https://webxreal.com/laravel-softdelete/)
- [ユーザーの論理削除(ソフトデリート)の実装手順 - Qiita](https://qiita.com/syokichi09/items/2d62df63a55cfff69960)
- [Laravel5.1におけるUserの論理削除機能の実装 - Qiita](https://qiita.com/Shigeo-NAKAMOTO/items/a04aceb6ed554aabffd6)