LoginSignup
20
18

初めてLaravelを実装した時にレビューで指摘されたこと〜レビューの数だけ強くなれる〜

Last updated at Posted at 2024-03-19

はじめに

こんにちは、mamiと申します。
メインはフロントエンドエンジニアをやっております。
最近社内のプロジェクトでバックエンドの実装全般を担当させてもらっているのですが、レビュー内容が非常に勉強になったため今後の自分の糧とする目的+同じようにバックエンドがジュニアレベルのエンジニアの方にお役に立てればいいなと思いこの記事を書いております。

レビュアーの方は常々Laravel公式ドキュメントに則った指摘をしてくださるので、その点においても非常に参考になる部分が多いはずです。
Laravelを仕事で使う予定、もしくは現在使っている方は是非参考にしてみてください!

人の失敗は蜜の味同じ轍を踏まない為にも、私の屍をどうぞ超えていってください!

対象読者

  • Laravelについて実務レベルで知りたい方
  • Laravelをもっと深く知りたい方
  • バックエンドの駆け出しエンジニアの方
  • バックエンドも知りたいフロントエンドエンジニアの方

レビュー内容

では早速ですが実際のレビュー内容を見ていきましょう!

Enumの設定

カテゴリーIDとかステータスNOとかって事前に入る値が決められていることが多いので、単に数字のみをバリデーションするのではなく、Enumを使用して想定外の値が入らないように制限しましょうというやつですね。
こうすることでパッとコードを見た時にも、どんな値が入るのかが分かりやすくなりますね!

指摘されたコード

class CreateRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'category_id' => 'required|integer',
        ];
    }
 }

レビュー内容

「カテゴリIDに想定外の値が入らないようバリデーションを追加したいです。
configenumでの定義を避けたいのであれば、DBアクセスが発生しますがexistsルールを使用するのもありです。」

レビュー後のコード

enum CategoryIdEnum: int
{
    case ExampleCategory1 = 1;
    case ExampleCategory2 = 2;
    case ExampleCategory3 = 3;
}

class CreateRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'category_id' => ['required', 'integer', new EnumRule(CategoryIdEnum::class)],
        ];
    }
}

マジックナンバー化

すんごい初歩の初歩である間違いを犯していますね。。暖かく見守ってあげましょう😇
こちらも先ほどのEnumで解消することができます!

指摘されたコード

class CreateRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'status_no' => 'required|integer|in:10,20,100',
        ];
    }
 }

レビュー内容

「マジックナンバーの解消をお願いします。
カテゴリIDと同じように対応してみてください。」

レビュー後のコード

enum StatusEnum: int
{
    case Pending = 10;
    case Active = 20;
    case Closed = 100;
}

class CreateRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'status_no' => ['required', 'integer', new EnumRule(StatusEnum::class)],
        ];
    }
}

tinyint型をbooleanに

私は最初知らなかったのですが、MySQLでは tinyint型 になるようで、デフォルトで0か1かの値が入る様になっているみたいです。
なのでbooleanとして扱いたい場合は$castsプロパティに boolean を追加する必要があるようです!

レビュー内容

「MySQLでは tinyint型 になりますので、bool型 にする場合は、 $casts プロパティに boolean を追加してください。」

レビュー後のコード

class ExsampleModel extends Model
{
    protected $casts = [
        'is_active' => 'boolean', // 'is_active' 属性をbool型にキャスト
    ];
}

外部キー制約

外部キー制約を追加することで、データベースの整合性を保つことができます。これは、参照されるテーブル(親テーブル)に存在しない値を参照テーブル(子テーブル)が持つことを防いでくれます!

指摘されたコード

class CreateGenericTable extends Migration
{
    public function up()
    {
        Schema::create('generic_table', function (Blueprint $table) {
            $table->unsignedBigInteger('related_column_id');
        });
    }
}

レビュー内容

m_ticket_tag.id に対する外部キー制約の追加をお願いします」

レビュー後のコード

class CreateGenericTable extends Migration
{
    public function up()
    {
        Schema::create('generic_table', function (Blueprint $table) {
            $table->unsignedBigInteger('foreign_key_column');
            // 外部キー制約の追加
            $table->foreign('foreign_key_column')->references('id')->on('related_table')->onDelete('cascade');
        });
    }
}

ちなみにforeignIdメソッドを使うことで上記コードを1行で書くことが可能です。
外部キーが参照するテーブルを自動で推測してくれます。ただし、constrainedメソッドはテーブル名がLaravelの命名規則に従っている必要があります。

class CreateGenericTable extends Migration
{
    public function up()
    {
        Schema::create('generic_table', function (Blueprint $table) {
            // 外部キー制約の追加(省略形)
            $table->foreignId('foreign_key_column')->constrained('related_table')->onDelete('cascade');
        });
    }
}

補足

すこし補足になりますが、SQLアンチパターンとか読むと、「外部キー制約を使わないなんて頭おかしい」と言わんばかりに推奨されていますが、何故か案件とかでは外部キー制約を使っているところは少ない、というお話をよく聞きます。

そこで色々と調べていたら下記の記事が非常に興味深かったので、気になる方は読んでみてください。

外部キー制約はプロジェクトの思想によって、付けるのか付けないのかは変わってくるでしょう。
この筆者の方が言われている通り、単純に「外部キー制約はできる限り付ける」ではなく、プロダクトのスケールも考慮しながらその場その場で最適な選択が出来るといいですね!

カスケード

そもそもカスケードとは、MySQL の 公式マニュアル によると

CASCADE: 親テーブルの行を削除または更新し、子テーブル内の一致する行を自動的に削除または更新します。ON DELETE CASCADE と ON UPDATE CASCADE の両方がサポートされています。

とのことです。
まあ要は、削除または更新されたときに、それに関連するレコードも自動で同じ操作をしてくれる機能です!

指摘されたコード

Schema::create('relation_table', function (Blueprint $table) {
    $table->unsignedBigInteger('related_id');
    $table->foreign('related_id')->references('id')->on('related_table');
});

レビュー内容

「チケットの物理削除と同時に削除されるよう、カスケード参照アクションの追加をお願いします」

レビュー後のコード

Schema::create('generic_relation_table', function (Blueprint $table) {
    $table->unsignedBigInteger('foreign_key_id');
    $table->foreign('foreign_key_id')->references('id')->on('related_table')->onDelete('cascade');
});

テストのseedについて

下記のようにテストメソッドごとにSeederの実行処理を書いていたら、親クラスで自動的に実行してくれる機能があるようです。
勉強不足ですね。

機能テスト中にデータベースシーダ(初期値設定)を使用してデータベースへデータを入力する場合は、seedメソッドを呼び出してください。seedメソッドはデフォルトで、DatabaseSeederを実行します。これにより、他のすべてのシーダが実行されます。または、特定のシーダクラス名をseedメソッドに渡します。

また、ログの出力をしておくとテストがこけた時などに原因箇所を特定しやすくなると教えていただきました!

指摘されたコード

protected function setUp(): void
    {
        parent::setUp();
        // DatabaseSeeder を実行
        Artisan::call('db:seed');
    }

レビュー内容

「テストメソッドごとに毎回シーダーが実行されることになりますので、親クラスの $seed プロパティで対応しましょう。

実行クエリをログに出力するようにしておくと、分かりやすいと思います。
開発も非常に捗りますので、ぜひ以下の記事を参考に設定してみてください。」

レビュー後のコード

tests/TestCase.php
abstract class TestCase extends BaseTestCase
{
    use CreatesApplication;

    protected $seed = true;
}
app/Providers/AppServiceProvider.php
class AppServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap any application services.
     */
    public function boot()
    {
        if (config('app.debug')) {
            DB::listen(function ($query) {
                Log::info($query->sql, $query->bindings, $query->time);
            });
        }
    }
}

エラーハンドリングについて

私はLaravelのこれらの機能をろくに知らずに、フロントエンドのノリで try catch文で書き殴っていっていました。
しかも統一感のない書き方をしていたので、レビュアーの方にはさぞ読みにくいコードを読ませてしまっていたことでしょう。。。
私がレビュアーの立場であれば「クソコードを書くんじゃない!!!」と罵っていたことでしょうが、レビュアーの方はお優しいのでそんな下品な振る舞いはしません。

すべての例外は、App\Exceptions\Handlerクラスが処理します。このクラスは、カスタム例外レポートとレンダリングコールバックを登録できるregisterメソッドを持っています。

指摘されたコード

public function getResource(ActionClass $action, Request $request, $resourceId): JsonResponse
{
    $page = (int) $request->query('page', 1);
    $perPage = (int) $request->query('per_page', 50);

    try {
        $resourcePaginator = $action->__invoke($resourceId, $page, $perPage);

        return response()->json($resourcePaginator->toArray(), JsonResponse::HTTP_OK);
    } catch (ModelNotFoundException $e) {
        return response()->json(['error' => true, 'message' => 'Resource not found'], JsonResponse::HTTP_NOT_FOUND);
    } catch (Exception $e) {
        return response()->json(['error' => 'Internal Server Error'], JsonResponse::HTTP_INTERNAL_SERVER_ERROR);
    }
}

レビュー内容

「APIごとに try catch で指定すると、レスポンス構造の一貫性を維持することが難しくなり、見通しも悪くなりますので、例外的なレスポンスデータが必要なときに限定しておきたいです。

Laravelではキャッチされなかった例外のレポート(ログ出力など)やレンダリング(レスポンス変換)を例外ハンドラ( \App\Exceptions\Handler )でまとめる設計になっており、これにより一貫したエラーハンドリングを容易に実現することができます。」

レビュー後のコード

app/Exceptions/Handler.php
public function register(): void
    {
        $this->renderable(function (HttpException $e, $request) {
            if ($request->expectsJson()) {
                return response()->json([
                    'error' => true,
                    'message' => $e->getMessage(),
                ], $e->getStatusCode());
            }
        });
    }
public function getPaginatedResource(ActionClass $action, Request $request, $resourceId): JsonResponse
{
    $page = (int) $request->query('page', 1);
    $perPage = (int) $request->query('per_page', 50);

    $paginatedResult = $action->__invoke($resourceId, $page, $perPage);

    return response()->json($paginatedResult->toArray(), JsonResponse::HTTP_OK);
}

reportという便利なヘルパーメソッド

わざわざLogで出力処理を書かなくても、Laravelにはreportという便利なヘルパーメソッドがあるようです。便利ですね!

場合により、例外を報告する必要はあるが、現在のリクエストの処理を続行する必要がある場合もあります。reportヘルパ関数を使用すると、エラーページをユーザーに表示せずに、例外ハンドラを介して例外をすばやく報告できます。

指摘されたコード

try {
    // 省略
} catch (Exception $e) {
    Log::error('Server error', [
        'message' => $e->getMessage(),
        'trace' => $e->getTraceAsString(),
    ]);
}

レビュー内容

「reportという便利なヘルパーメソッドがあるので紹介しておきます。」

レビュー後のコード

try {
    // 省略
} catch (Exception $e) {
    report($e);
}

クエリビルダ

元のコードではループの各イテレーションでデータベースへのインサート処理を実行していました。これではレコード数が多くなってくると、パフォーマンスの低下を招く可能性があるようです。
なのでループを使用して二次元連想配列を構築し、ループの完了後にその配列をinsertメソッドの引数として渡すことで、データベースへのインサート処理を一度にまとめて行うことが出来ます。
これにより、データベースへの接続が一度だけで済み、複数のレコードを一括で挿入することができるため、全体の処理効率が向上すると言うわけですね!

クエリビルダは、データベーステーブルにレコードを挿入するために使用できるinsertメソッドも提供します。insertメソッドは、カラム名と値の配列を引数に取ります。

指摘されたコード

foreach ($items as $item) {
    DB::table('generic_table_name')->insert([
        'key1' => $item['key1'],
        'key2' => $item['key2'],
        'created_at' => now(),
        'updated_at' => now(),
    ]);
}

レビュー内容

「ループ内でインサートを実行せず二次元連想配列を作成しておいて、ループ後にinsertメソッドの引数に渡すことで一括インサートになるため効率が良くなります。」

レビュー後のコード

$data = [];
$now = now();
foreach ($items as $item) {
    $data[] = [
        'key1' => $item['key1'],
        'key2' => $item['key2'],
        'created_at' => $now,
        'updated_at' => $now,
    ];
}
DB::table('your_table_name')->insert($data);

設定キャッシュ

設定キャッシュとは、公式によると

アプリケーションの速度を上げるには、config:cache Artisanコマンドを使用してすべての設定ファイルを1つのファイルへキャッシュする必要があります。これにより、アプリケーションのすべての設定オプションが1ファイルに結合され、フレームワークによってすばやくロードされます。

と説明されています。
Laravelでは設定値のキャッシュを利用してアプリケーションの起動速度を向上させることが推奨されているようです。
要は速度を重視するにはenvファイルから直接取るのではなく、設定ファイルを経由して取得するほうがいいよ!と言うことですね。勉強になりますね。

指摘されたコード

 $frontendUrl = env('FRONTEND_URL');

レビュー内容

「本番環境で設定キャッシュを使用できるように、環境変数の値は env() から直接取得せず、config('app.frontend_url') のように設定ファイルを経由して取得するようにしてください。」

レビュー後のコード

config/app.php
'frontend_url' => env('FRONTEND_URL', 'http://frontend-url.com'),
$frontendUrl = config('app.frontend_url');

トランザクション

データベーストランザクションは、複数のデータベース操作が一連の流れとして扱われ、全てが成功するか、全てが失敗する(ロールバックされる)かのどちらかとなる仕組みです。これは一部だけが成功すると、データの整合性が失われる可能性があるため重要です。

指摘されたコードはトランザクションがされていなかったので、データの一貫性を保つ為にもやろうね!というお話です。
考えてみればたしかにその通りで、むしろ何故やっていないのか自分にツッコミを入れるべき案件ですね。

指摘されたコード

$model = ModelName::findOrFail($resourceId);

if ($model->owner_id !== $currentUser->id) {
    abort(404, 'Resource not found.');
}

$model->update($dataToUpdate);

レビュー内容

「データの整合性を保証するため、トランザクションを使用しましょう。」

レビュー後のコード

$model = ModelName::where('attribute1', $attribute1Value)
                   ->where('attribute2', $attribute2Value)
                   ->where('attribute3', $attribute3Value)
                   ->first();

if (is_null($model)) {
    throw new ModelNotFoundException('Resource not found.');
}

DB::transaction(function () use ($model, $updateData) {
    $model->update($updateData);

    $relatedModel = $model->relatedModel;
    $relatedModel->fill($updateData)->save();

    if (! empty($updateData['relatedField'])) {
        $relatedModelIds = RelatedModel::whereIn('attribute', $updateData['relatedField'])->pluck('id');
        $relatedModel->relatedModels()->sync($relatedModelIds);
    }
});

自動整形

フロントで言うprettierなものがあるそうです。
私は諸事情があり前者を採用しましたが、後者の方が便利そう。。。

レビュー内容

「Laravel Pintのコマンドを都度手動で実行するか、 vscodeにphp-cs-fixerの拡張機能を入れてファイル保存時に自動的に整形するか、どちらかは採用しておきたいです。

vscodeに必要な設定をした後、.php-cs-fixer.php'no_unused_imports' => true を追加すると、ファイル保存時に自動的に未使用のuse文が削除されるようになりますので、試してみてください。」

1ページあたりの取得制限

per_page に取得上限をつけましょうというお話!
誰かに悪さされたら困りますからね!
ついうっかり忘れてました😅

指摘されたコード

$page = (int) $request->query('page', '1');
$perPage = (int) $request->query('per_page', '20');

レビュー内容

「1ページあたりの表示件数 per_page に上限を設けておきたいです。
無駄に大きい値が指定されるとDBに無駄な負荷をかけてしまうので。」

レビュー後のコード

$page = (int) $request->query('page', '1');
$perPage = (int) $request->query('per_page', '20');
$perPage = min($perPage, 20);

排他ロック

排他ロックというものがあるそうで、

クエリビルダには、selectステートメントを実行するときに「悲観的ロック」を行うために役立つ関数も含まれています。「共有ロック」を使用してステートメントを実行するには、sharedLockメソッドを呼び出すことができます。共有ロックは、トランザクションがコミットされるまで、選択した行が変更されないようにします。
または、lockForUpdateメソッドを使用することもできます。「更新用」ロックは、選択したレコードが変更されたり、別の共有ロックで選択されたりするのを防ぎます。

要は複数のトランザクションが同時に同一のデータレコードにアクセスする際の競合を防ぐために使用されるそうです。

なるほど。。バックエンドはその辺りも考慮しないといけないんですね。ふむふむ勉強になりますねぇ

指摘されたコード

$model = ModelName::where('attribute1', $attribute1Value)
                   ->where('attribute2', $attribute2Value)
                   ->firstOrFail();

return DB::transaction(function () use ($model, $relatedAttribute, $conditionValue) {
    $relatedData = $model->relatedMethod()->where('relatedAttribute', $conditionValue)->first();
});

レビュー内容

「募集人数が残りわずかなときに複数の参加申し込みがほぼ同時にきた場合を想定しましょう。」

レビュー後のコード

return DB::transaction(function () use ($attribute1Value, $attribute2Value, $relatedAttribute, $conditionValue) {
    $model = ModelName::where('attribute1', $attribute1Value)
                       ->where('attribute2', $attribute2Value)
                       ->lockForUpdate()
                       ->firstOrFail();
    $relatedData = $model->relatedMethod()->where('relatedAttribute', $conditionValue)->first();
});

不要なEagerロード

カテゴリで絞り込んだ場合に不要なリレーションのEagerロードが発生しないようにする必要があるようです!
コードを見るとなるほどと思えますね。

指摘されたコード

public function fetchData($currentUser, $limit = 20, $lastItemId = null, $categoryId = null)
{
    $query = Model::query()
        ->when($lastItemId, function ($query) use ($lastItemId) {
            return $query->where('id', '<', $lastItemId);
        })
        ->when($categoryId, function ($query) use ($categoryId) {
            $query->where('category_id', $categoryId);
        })
        ->with(['relation1', 'relation2' => function ($query) use ($currentUser) {
            $query->where('user_id', $currentUser->id);
        }])
        ->orderByDesc('id')
        ->limit($limit)
        ->get();

    return $query;
}

レビュー内容

「カテゴリが指定された場合、不要なリレーションのEagerロードが発生しないように制御したいですね。」

レビュー後のコード

public function fetchData($currentUser, $limit = 20, $lastItemId = null, $categoryId = null)
{
    $relations = ['user'];

    if ($categoryId === SpecificCategoryEnum::Category1) {
        $relations[] = 'relation1';
    } elseif ($categoryId === SpecificCategoryEnum::Category2) {
        $relations['relation2'] = function ($query) use ($currentUser) {
            $query->with(['subRelation' => fn ($query) => $query->where('user_id', $currentUser->id)]);
        };
    } else {
        $relations = array_merge($relations, ['relation1', 'relation2' => function ($query) use ($currentUser) {
            $query->with(['subRelation' => fn ($query) => $query->where('user_id', $currentUser->id)]);
        }]);
    }

    $query = Model::query()
        ->when($lastItemId, function ($query) use ($lastItemId) {
            return $query->where('id', '<', $lastItemId);
        })
        ->when($categoryId, function ($query) use ($categoryId) {
            $query->where('category_id', $categoryId);
        })
        ->with($relations)
        ->orderByDesc('id')
        ->limit($limit)
        ->get();

    return $query;
}

ローカルスコープ

続いてローカルスコープについて。

ローカルスコープを使用すると、アプリケーション全体で簡単に再利用できる、共通のクエリ制約を定義できます。たとえば、「人気がある(popular)」と思われるすべてのユーザーを頻繁に取得する必要があるとしましょう。スコープを定義するには、Eloquentモデルメソッドの前にscopeを付けます。

まあ要はクエリを再利用可能な形でカプセル化するための機能です。
メソッド名をscopeで始めることで定義され、このメソッドを呼び出す際にはscopeプレフィックスを省略して使用します。

ローカルスコープを使用する主な目的は、複雑なクエリのロジックをモデル内に組み込むことで、アプリケーション全体でそのクエリを簡単に再利用できるようにすることです。これにより、コードの可読性と保守性が向上するという訳ですね!

指摘されたコード

$relations = ['user'];

if (条件A) {
    $relations[] = 'relationA';
} elseif (条件B) {
    $relations[] = ['relationB' => function ($query) use ($条件) {
        $query->with(['nestedRelation' => fn ($query) => $query->where('条件', '値')]);
    }];
} else {
    $relations = array_merge($relations, ['relationA', 'relationB' => function ($query) use ($条件) {
        $query->with(['nestedRelation' => fn ($query) => $query->where('条件', '値')]);
    }]);
}

$result = Model::query()
    ->when($条件X, function ($query) use ($条件Xの値) {
        return $query->where('フィールド', '<', $条件Xの値);
    })
    ->when($条件Y, function ($query) use ($条件Yの値) {
        $query->where('フィールド', $条件Yの値);
    })
    ->where(function ($query) {
        // 省略
    })
    ->with($relations) 
    ->orderByDesc('フィールド')
    ->limit($取得数)
    ->get();

レビュー内容

「カテゴリごとに必要なリレーションの知識を \App\Models\Ticket にカプセル化するなら、ローカルスコープの使用をおすすめします。」

レビュー後のコード

Model
public function scopeWithConditionalRelations(Builder $query, $condition, $context): Builder
{
    if ($condition === 'ConditionTypeA') {
        return $query->with('relationA');
    } elseif ($condition === 'ConditionTypeB') {
        return $query->with(['relationB' => function ($query) use ($context) {
            $query->with(['nestedRelation' => fn ($query) => $query->where('contextField', $context->id)]);
        }]);
    } else {
        return $query->with([
            'relationA',
            'relationB' => function ($query) use ($context) {
                $query->with(['nestedRelation' => fn ($query) => $query->where('contextField', $context->id)]);
            }
        ]);
    }
}

$result = Model::query()
    ->when($condition1, function ($query) use ($condition1Value) {
        return $query->where('field1', '<', $condition1Value);
    })
    ->when($condition2, function ($query) use ($condition2Value) {
        return $query->where('field2', $condition2Value);
    })
    ->withDynamicRelations($condition2, $context) 
    ->where(function ($query) {
        // 省略
    })
    ->orderByDesc('sortableField')
    ->limit($limitValue)
    ->get(); 

フロントに丸投げせずにバックエンドでやっちまおう

フロントとバックエンドどっちがやるのか問題ですね。これについてはプロジェクトの思想が強く反映されそうですね。
私たちのチームでは明言化はされておらずよしなにやっているのですが、バックエンド側で予め用意してあげることでフロントの実装はもちろん楽になりますし、仕様変更時の影響範囲をバックエンドに限定できるのもメリットと言えるでしょう。

ちなみにAPIがサードパーティ的に使用されるのであれば、ある程度の柔軟性を持たせる必要があるのでフロントで自由に設定できるできる方が好ましいのですが、今回はファーストパーティとしてのAPIなのでバックエンドで予め用意してあげる方がいいかもしれませんね。

指摘されたコード

 ->when(!empty($conditionValues), function ($query) use ($conditionValues) {
    $query->where(function ($subQuery) use ($conditionValues) {
        $subQuery->whereHas('relatedModel1', function ($q) use ($conditionValues) {
            $q->whereIn('attribute', $conditionValues);
        })->orWhereHas('relatedModel2', function ($q) use ($conditionValues) {
            $q->whereIn('attribute', $conditionValues);
        });
    });
})

レビュー内容

「クライアントにステータス番号を選択させるより、「発行中」か「発行済」かを切り替えるフラグにしたほうがわかりやすいように思いますが、いかがでしょうか?」

レビュー後のコード

->when($conditionFlag, function ($query) {
    // フラグがtrueの場合、特定の条件を満たすレコードを取得
    $query->where(function ($subQuery) {
        $subQuery->whereHas('relation1', function ($q) {
            $q->where('attribute', '!=', EnumClass::Value1->value);
        })->orWhereHas('relation2', function ($q) {
            $q->whereIn('attribute', [EnumClass::Value2->value, EnumClass::Value3->value]);
        });
    });
}, function ($query) {
    // フラグがfalseの場合、別の条件を満たすレコードを取得
    $query->where(function ($subQuery) {
        $subQuery->whereHas('relation1', function ($q) {
            $q->where('attribute', '=', EnumClass::Value1->value);
        })->orWhereHas('relation2', function ($q) {
            $q->whereIn('attribute', [EnumClass::Value4->value, EnumClass::Value5->value]);
        });
    });
})

ユーザーの退会について

これはレビュー内容ではないですが、チーム内で「ユーザー退会をどう実装するか」という論点で議論したので共有として記しておきます。
結論から言うと、ユーザー退会処理は複合ユニーク制約を利用しています。複数のカラムの組み合わせでユニーク制約を設定する方法です。

論理削除をしても実際にはレコードがテーブルに存在しているため、同一の値を設定するとユニーク制約に引っかかります。論理削除したユーザーのメールアドレスが登録できなくなってしまうんですよね。なので複合ユニーク制約を用いてそれを回避しているわけです。

複合ユニーク制約は簡潔にいうと「退会したユーザーが再度同じメアドで登録してきたとしても、ユニーク制約に引っ掛かることなく新たなユーザーを作成することできる」機能です。

existという新しい生成列を用意し、deleted_atがNULLの場合(レコードが存在している場合)はexist1に、NULLでない場合(レコードが論理削除されている場合)はexistNULLに設定します。この方法により、emailexistの複合ユニーク制約を設定することで、論理削除されたレコードについてはユニーク制約が無効になり、同じメールアドレスを再登録することが可能になります。

ユーザーが存在する(つまり論理削除されていない)場合

  1. deleted_at カラム: NULL。レコードが現存していることを示す。
  2. exist カラム: existカラムは、deleted_atがNULLの場合に1に設定される生成列。したがって、ユーザーが存在している場合(deleted_atがNULLの場合)、existカラムは1になります。
  3. 複合ユニーク制約: emailexistの組み合わせで複合ユニーク制約が設定されているため、ユーザーが存在する場合(existが1の場合)、このユニーク制約が機能し、同じemail値を持つ新しいレコードの挿入はユニーク制約違反になります。

ユーザーが存在しない(つまりレコードが論理削除されている)場合

  1. deleted_at カラム:日付が入る。レコードが論理削除されたことを示す。
  2. exist カラム: NULLになる。
  3. 複合ユニーク制約: existカラムがNULLの場合、複合ユニーク制約は無効になります。これにより、同じemail値を持つ新しいユーザーを再度登録することが可能になります。

コードで表すとこんな感じです

Schema::table('users', function (Blueprint $table) {
            $table->dropUnique('users_email_unique');
            $table->boolean('exist')->nullable()->storedAs('case when deleted_at is null then 1 else null end');
            $table->unique(['email', 'exist']);
        });

おわりに

如何だったでしょうか?
常に公式ドキュメントを参照しながら、パフォーマンス面などを考慮したレビューをいただけるので非常に勉強になりましたね!

弊社では現在、社内プロジェクトとして自社プロダクトの開発に力を入れております!
毎週定例を実施し、仕様について議論を重ねたり、新機能についての意見やサービスをよりよくするにはどうしたらいいかなどを日々議論しております!

他にも受託チームを組んで開発したりなど、和気藹々としています
絶賛採用強化中ですので、ご興味のある方は下記よりお気軽にお問い合わせください😊

20
18
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
18