業務システム開発・保守の実務メモを発信中

Laravelのuniqueで論理削除済みデータが重複扱いになる理由

Laravelで SoftDeletes を使っていると、削除したレコードは通常のEloquent検索に出てこなくなります。

メールアドレスを例とすると、画面の一覧からも消えるので、「このメールアドレスはもう使われていない」ものと考えたくなります。

ところが、同じメールアドレスで新規登録しようとすると、unique バリデーションで弾かれることがあります。

原因は、論理削除ではDB上の行が消えていないことです。通常のEloquent検索では deleted_at に値が入った行を除外しますが、unique バリデーションはデフォルトでは論理削除済みレコードも重複チェックに含めます。

この記事では、通常の Rule::unique('users', 'email') と、論理削除済みレコードを除外する Rule::unique('users', 'email')->withoutTrashed() の違いを、小さなサンプルで確認します。

目次

画面から消えたデータ

まず、論理削除で何が起きているかを小さく確認します。

使うテーブルは、emaildeleted_at を持つだけの users テーブルです。実アプリではmigration、Model、Form Request、Controllerに分けますが、ここでは重複判定の対象だけを見やすくするため、画面やControllerは作りません。

PHP
$schema->create('users', function ($table): void {
    $table->increments('id');
    $table->string('email');
    $table->timestamp('deleted_at')->nullable();
    $table->timestamps();
});

class SoftDeleteUniqueUser extends Model
{
    use SoftDeletes;

    protected $table = 'users';

    protected $fillable = ['email'];
}

SoftDeletes を使うモデルでは、delete() してもDBの行そのものは削除されません。Laravelは deleted_at に削除日時を入れ、通常のEloquent検索からその行を外します。

次のコードでは、1件作成してから論理削除し、DB上の行とEloquentからの見え方を確認しています。

PHP
$email = 'deleted-user@example.com';
$original = SoftDeleteUniqueUser::create(['email' => $email]);
$original->delete();

$allRowsAfterDelete = $capsule->table('users')
    ->select(['id', 'email', 'deleted_at'])
    ->orderBy('id')
    ->get()
    ->map(fn ($row): array => (array) $row)
    ->all();

// SoftDeletesの通常検索とonlyTrashedで、同じレコードの見え方が変わることを確認する。
$visibility = [
    'normal_count' => SoftDeleteUniqueUser::where('email', $email)->count(),
    'only_trashed_count' => SoftDeleteUniqueUser::onlyTrashed()->where('email', $email)->count(),
];

結果はこうなりました。

JSON
"record_after_soft_delete": [
    {
        "id": 1,
        "email": "deleted-user@example.com",
        "deleted_at": "2026-08-15 05:36:00"
    }
],
"eloquent_visibility_after_soft_delete": {
    "normal_count": 0,
    "only_trashed_count": 1
}

DB上には id = 1 の行が残っています。deleted_at に値が入っているので、通常の where('email', $email) では0件です。一方、onlyTrashed() では1件として見えます。

画面の一覧が通常のEloquent検索で作られているなら、削除済みデータは見えません。でも、DBから消えたわけではありません。

通常のunique

次に、論理削除済みレコードと同じメールアドレスを unique でチェックします。

PHP
$normalUnique = $validatorFactory->make(
    ['email' => $email],
    ['email' => [Rule::unique('users', 'email')]],
);

$normalUniqueResult = [
    'passes' => $normalUnique->passes(),
    'errors' => $normalUnique->errors()->toArray(),
];

この Rule::unique('users', 'email') は、users.email に同じ値があるかを見ます。ここでは論理削除済みの行もDBに残っているため、同じメールアドレスが存在する扱いになります。

結果です。

JSON
"normal_unique": {
    "passes": false,
    "errors": {
        "email": [
            "validation.unique"
        ]
    }
}

passesfalse でした。つまり、通常の unique では、論理削除済みレコードと同じメールアドレスも重複として扱われます。

tomo

errorsvalidation.unique になっているのは、単体スクリプトでは表示用の翻訳ファイルを読ませていないためです。ここで見るべきなのは文言ではなく、同じ入力が passes: false になっている点です。

この挙動は、一覧画面の見え方とズレます。通常検索では0件なのに、バリデーションでは重複です。おかしな挙動に見えますが、見ている対象が違います。

withoutTrashed

現在有効なレコードだけを重複チェックしたい場合は、withoutTrashed() を付けます。

PHP
$withoutTrashedUnique = $validatorFactory->make(
    ['email' => $email],
    ['email' => [Rule::unique('users', 'email')->withoutTrashed()]],
);

$withoutTrashedUniqueResult = [
    'passes' => $withoutTrashedUnique->passes(),
    'errors' => $withoutTrashedUnique->errors()->toArray(),
];

withoutTrashed() を付けると、論理削除済みレコードを unique のチェック対象から外します。標準の論理削除カラムなら deleted_at が基準になります。

同じメールアドレスで実行した結果です。

JSON
"without_trashed_unique": {
    "passes": true,
    "errors": []
}

今度は passestrue になりました。通常の unique では失敗した同じ入力が、withoutTrashed() 付きでは通っています。削除済み行を「重複チェックの相手にするかどうか」だけで、結果が変わります。

通常の uniquewithoutTrashed() 付きの unique は、どちらも同じ users.email を見ています。ただし、重複とみなす範囲が違います。

ルール論理削除済みレコード今回の結果
Rule::unique('users', 'email')含める失敗
Rule::unique('users', 'email')->withoutTrashed()除外する成功

「画面に出ている有効ユーザーだけ重複チェックしたい」なら、ここで使うのは withoutTrashed() です。

再利用と復元

ただ、withoutTrashed() を付ければ終わり、ではありません。

削除済みレコードと同じメールアドレスを新しいユーザーで使えるようにすると、あとで元のレコードを復元するときに問題が出ます。どちらも同じメールアドレスの有効ユーザーになるからです。

再利用後の状態を見るため、withoutTrashed() が通ったあとに同じメールアドレスで新しい行を作り、元の論理削除済みレコードを restore() しました。

PHP
// withoutTrashedで再利用を許すと、同じemailを持つ有効レコードを作れる状態になる。
$reusedUser = null;
if ($withoutTrashedUniqueResult['passes']) {
    $reusedUser = SoftDeleteUniqueUser::create(['email' => $email]);
}

$restoreOutcome = [];
try {
    $original->restore();
    $restoreOutcome = [
        'restored' => true,
        'message' => 'restore() completed',
    ];
} catch (Throwable $exception) {
    $restoreOutcome = [
        'restored' => false,
        'message' => $exception::class . ': ' . $exception->getMessage(),
    ];
}

結果は次の通りです。

JSON
"reuse_and_restore": {
    "reused_user_id": 2,
    "restore_outcome": {
        "restored": true,
        "message": "restore() completed"
    },
    "rows_after_restore": [
        {
            "id": 1,
            "email": "deleted-user@example.com",
            "deleted_at": null
        },
        {
            "id": 2,
            "email": "deleted-user@example.com",
            "deleted_at": null
        }
    ],
    "active_rows_with_same_email": 2
}

DB側に一意制約を置いていない構成では、同じメールアドレスの有効レコードが2件になりました。

tomo

これは withoutTrashed() が悪いという話ではありません。withoutTrashed() は、削除済み行をバリデーション対象から外すための指定です。復元時に同じ値の有効レコードがすでにあったらどうするかまでは決めてくれません。

DBにUNIQUE制約がある構成なら、復元時にDB側で止まることがあります。逆に、DB制約がなければ今回のように同じ値の有効行が残ります。withoutTrashed() を足す前に、復元時の扱いを決めておいたほうがいいです。

たとえば、次のどれで止めるかはアプリケーション側のルールです。

  • 削除済みユーザーのメールアドレスは再利用させない
  • 再利用は許すが、元ユーザーの復元時にメールアドレス変更を求める
  • 復元前に同じメールアドレスの有効ユーザーがいないか確認する

ここを決めないまま withoutTrashed() だけ足すと、あとで復元処理が曖昧になります。

uniqueとDB制約

ここまで見たのは、Laravelのバリデーションです。DB側の一意制約とは別です。

このサンプルでは、あえてDBに email のUNIQUE制約を置いていません。unique バリデーションとDB制約を混ぜると、どちらが止めたのか分かりにくくなるためです。

実際の業務システムでは、バリデーションだけで一意性を守るのは危ういです。同時リクエストや直接DB更新を考えるなら、DB側の制約も別に考えます。

ただし、論理削除を含む一意制約はDBごとに設計が変わります。deleted_at を含めるのか、部分インデックスを使うのか、復元時にどう扱うのか。ここは withoutTrashed() とは別の設計判断です。

まず見るべきなのは、Laravelの unique がどの範囲を見ているかです。そこを分けておくと、バリデーションで止める話と、DB制約で守る話を混同しにくくなります。

まとめ

Laravelで SoftDeletes を使っていると、削除済みレコードは通常のEloquent検索から除外されます。ただし、DB上の行は残っています。

通常の Rule::unique('users', 'email') は、その論理削除済みレコードも重複チェックに含めます。そのため、画面には見えないメールアドレスでも、unique では重複扱いになることがあります。

現在有効なレコードだけを対象にしたいなら、Rule::unique('users', 'email')->withoutTrashed() を使います。

ただし、削除済みデータの値を再利用できるようにするなら、復元時の扱いも決めておきます。withoutTrashed() は重複チェックの対象を変える指定であって、復元時の業務ルールまで解決するものではありません。

よかったらシェアしてね!
  • URLをコピーしました!
目次