条件の入力から検証、PHPサービスでの生成、結果表示とコピーまでの構成

入力のあるWebアプリを作ってみたくて、最初の題材にパスワード生成を選びました。
情報漏洩の話題をきっかけに、ネットで必要なときにパスワードを生成できると便利だと考えたからです。

作ったのは、文字数と使う文字種を選び、生成した値をコピーする小さなツールです。

この記事では、Codexと実装したコードをもとに、入力の検証、生成処理、画面への表示とコピーのつながりを説明します。
生成履歴を持たせず、生成値は結果画面で表示するためにだけ扱う構成です。
実装と検証結果は2026年10月時点のものです。

文字数と使う文字を指定する

生成画面では文字数と文字種を選びます。
最初は16文字で、英小文字・英大文字・数字が選ばれた状態です。
記号は使うサービスの条件に合わせて追加できます。

375px幅の実画面で撮影した文字数と文字種の入力部分
項目 今回の仕様
文字数 8~64文字、初期値16文字
文字種 英小文字、英大文字、数字、記号
含める文字 選んだ文字種を、それぞれ最低1文字含める
コピー ボタンでコピー。利用できなければ手動コピーを案内
履歴 保存しない

文字数の範囲は、このツールの入力条件です。
登録先のサービスで使える文字や必要な長さは別に確認する必要があり、この設定だけで安全性を保証するものではありません。

ファイルの役割と処理の流れを確認する

以下は、実装済みのLaravelアプリから要点を抜き出したコードです。
Laravelのプロジェクトがある前提で、フォームを送るとどのファイルへ進むのかを追います。
ファイルのパスは、artisanのあるプロジェクトのルートから見たものです。

ファイル 役割
routes/web.php フォームのGETと生成のPOSTを登録
app/Http/Requests/GeneratePasswordRequest.php 入力条件の検証
app/Http/Controllers/PasswordGeneratorController.php 検証済みの入力と結果画面の受け渡し
app/Services/PasswordGenerator.php 文字の選択と並べ替え
resources/views/tools/password-form.blade.php 条件・エラー・結果の表示
resources/js/app.js コピーと画面を離れる際の結果欄の消去

/password-generator/generateをGETで開くとフォームを表示し、同じURLへのPOSTで生成します。
ルートは、ミドルウェアの指定を除くと次の形です。

Route::get('/password-generator/generate', [PasswordGeneratorController::class, 'form'])
    ->name('password.form');
Route::post('/password-generator/generate', [PasswordGeneratorController::class, 'generate'])
    ->name('password.generate');

フォームではmethod="POST"とaction="{{ route('password.generate') }}"で送信先を指定し、@csrfを入れています。
route()はルート名からURLを作るための関数で、POSTされたURLに応じてコントローラーのgenerate()が呼ばれます。

入力検証と生成処理を分ける

条件を送信したあと、まずLaravelのFormRequestで入力を検証します。
文字数が整数か、8~64文字の範囲か、文字種が用意した選択肢に含まれるかを確認します。
文字種が未選択の場合や、同じ種類を重複して指定した場合もエラーにしています。

app/Http/Requests/GeneratePasswordRequest.phpのrules()は、次の検証ルールを返します。
メソッド内の抜粋です。
RuleはIlluminate\Validation\Rule、PasswordGeneratorは生成サービスをuseで読み込んでいます。

        return [
            'length' => ['required', 'integer', 'between:8,64'],
            'groups' => ['required', 'array', 'min:1', 'max:4'],
            'groups.*' => ['required', 'string', 'distinct', Rule::in(array_keys(PasswordGenerator::CHARACTER_SETS))],
        ];

groups.*は配列の各要素に対する指定です。
distinctで重複を防ぎ、Rule::in()でサービスに定義した文字種だけを受け付けます。

ブラウザー側にも数値の範囲を指定していますが、送信内容を書き換えられることを考え、サーバー側でも検証する構成です。

生成処理はapp/Services/PasswordGenerator.phpに置きました。
Bladeやコントローラーの中へ生成のロジックを書かず、将来APIから呼ぶ場合も再利用できる形にしています。
API自体は、まだ作成していません。

コントローラーの生成メソッドは次のとおりです。
クラス全体ではなく、PasswordGeneratorControllerからの抜粋です。

    public function generate(GeneratePasswordRequest $request, PasswordGenerator $generator): View
    {
        $length = $request->integer('length');
        $groups = array_values($request->validated('groups'));

        return view('tools.password-form', [
            'length' => $length,
            'groups' => $groups,
            'password' => $generator->generate($length, $groups),
        ]);
    }

GeneratePasswordRequestの検証を通ってからこのメソッドが呼ばれます。
$generatorはLaravelが用意するサービスで、生成処理を呼び、その結果をビューのpasswordへ渡します。
tools.password-formはresources/views/tools/password-form.blade.phpを指します。

選んだ文字種を必ず含めて生成する

生成にはPHPのrandom_int()を使っています。
整数の範囲から値を選ぶ関数で、ここでは文字列の何番目の文字を使うか、どの位置と交換するかを決めるために使います。
選んだ文字の集合から必要な長さを取るだけでは、特定の文字種が一つも含まれない結果もあり得ます。
今回は「選んだ種類を最低1文字ずつ含める」仕様なので、最初に各文字種から1文字ずつ選びます。

foreach (array_unique($groups) as $group) {
    $characters = self::CHARACTER_SETS[$group];
    $password[] = $characters[random_int(0, strlen($characters) - 1)];
    $pool .= $characters;
}

残りを選択した文字の集合から埋め、最後に並び順をシャッフルします。
ここでもrandom_int()を使い、文字種を選んだ順に結果が並ばないようにしました。
次は同じサービスの、残りを埋めて並べ替える部分の抜粋です。

        while (count($password) < $length) {
            $password[] = $pool[random_int(0, strlen($pool) - 1)];
        }

        for ($index = count($password) - 1; $index > 0; $index--) {
            $swap = random_int(0, $index);
            [$password[$index], $password[$swap]] = [$password[$swap], $password[$index]];
        }

        return implode('', $password);

$poolは選んだ文字種をつないだ文字列です。
必要な文字数まで配列へ追加し、末尾からランダムな位置と交換します。
最後のimplode()で配列を1本の文字列へ戻します。

コードの確認では、4種類の選択パターン15通りと、8・16・64文字の組み合わせをテストしています。
選んだ種類が含まれ、選んでいない種類が混ざらないことも確認しました。

生成値を結果画面だけで扱う

生成値は、サーバーで作ってから結果画面へ返しています。
ブラウザーの中だけで生成する方式ではありません。

生成前の結果欄とコピーボタン。実際の生成値を載せずに画面を撮影したもの

resources/views/tools/password-form.blade.phpでは、受け取った値を読み取り専用の欄へ表示します。
結果欄の実コードの抜粋です。

<textarea id="generated-password" rows="3" readonly autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="生成すると、ここに表示します。">{{ $password }}</textarea>

{{ $password }}でHTML用にエスケープし、readonlyで画面からの編集を防ぎます。
この指定自体が保存を防ぐ仕組みではないため、保存処理を設けないことや応答ヘッダーも別に対応しています。

このアプリには、生成結果をデータベースやセッションの履歴へ書き込む処理を設けていません。
生成値をURLのパラメーターに入れる処理もありません。
JavaScriptにもWeb Storageへ保存する処理は設けていません。
画面の移動時には、表示欄の値を消す処理を入れています。

応答にはCache-Control: no-storeを付け、ブラウザー等へ保存しないよう指示します。
生成画面には広告・解析・外部フォントを読み込まず、画面を開いて生成する操作でも第三者への通信がないことを確認しました。
本番環境のアクセスログや監視設定、HTTPSでの配信確認は、本番へ配置する段階で別途確認します。

コピーにはClipboard APIを使っています。
writeText()は安全なコンテキストで利用するAPIで、本番ではHTTPSなどの条件も確認する必要があります。
コピーが許可されない場合は生成値を選択し、手動でコピーする案内を表示します。
resources/js/app.jsのコピーボタン処理から、成功・失敗の分岐を抜粋します。
resultは結果欄、statusは案内文の要素です。

        try {
            if (!navigator.clipboard?.writeText) throw new Error('Clipboard unavailable');
            await navigator.clipboard.writeText(result.value);
            status.textContent = 'コピーしました。';
        } catch {
            result.focus();
            result.select();
            status.textContent = '自動でコピーできませんでした。選択した生成値を手動でコピーしてください。';
        }

writeText()に成功すると案内を更新し、利用できないときはselect()で生成値を選択します。
コピーできた場合も、その値は端末のクリップボードへ入るため、貼り付け先の確認が必要です。

正常系だけでなくエラーと回数制限を確認する

ローカルで画面を開く場合は、CSSとJavaScriptをビルドしたLaravelプロジェクトでサーバーを起動します。
既に同じアプリが起動していれば、そのサーバーを使います。

php artisan serve --host=127.0.0.1 --port=8000

http://127.0.0.1:8000/password-generator/generateを開き、文字数と文字種を選んで「生成する」を押します。
指定した長さの値が表示され、「コピーする」でコピーできることを確認します。
ブラウザー側の文字数制限だけでなく、サーバー側の入力検証も確認する構成です。

生成画面の送信にはCSRF保護を使い、1つのIPアドレスからの生成は1分30回までにしました。
実ブラウザーの確認では、CSRFトークンを付けない送信がHTTP 419で拒否されています。
回数制限はLaravel側のテストで、30回までは通り、次の要求が429になることを確認しました。

最初のテストでは、回数制限のエラー応答にno-storeが付かない問題が見つかりました。
通常の画面応答だけでなく、例外として処理された応答にも同じヘッダーを付けるよう修正しています。

今回、生成サービスと入力・画面応答に絞ったPHPテストを実行し、68件・338アサーションが成功しました。

php artisan test --compact tests/Unit/Services/PasswordGeneratorTest.php tests/Feature/PasswordGenerationTest.php

ブラウザーでは320・375・768・1280px幅で、64文字の生成、コピー、コピーできない場合の案内、未選択のエラーを確認しました。
入力エラーが表示されたときのフォーカス移動と、キーボードでのメニュー移動も確認しています。
実機のスマートフォンや、本番サイトでの確認を済ませたという意味ではありません。

まとめ

文字数と文字種を選び、生成した値をコピーできるところまで実装できました。
画面だけでなく、条件の検証と生成処理を分けたので、どこで入力を確認し、どこで値を作るかも追いやすくなっています。

現時点ではローカルの動作確認までです。
入力を受けて処理し、結果を表示する流れを、パスワード生成という題材で形にできました。
本番公開時のHTTPS、Web設定、ログの扱いは未確認です。

参考: Laravelの入力検証、PHPのrandom_int、Clipboard APIのwriteText