1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Laravelの電話番号バリデーションから考えた「入力・正規化・保存・本人確認」

1
Posted at

この記事は、Laravelで電話番号のバリデーションを学んだことをきっかけに、入力値の正規化、E.164、SMS認証、個人情報を持たない設計まで掘り下げた内容をまとめたものです。

目次

  1. この記事で扱うこと
  2. きっかけ:Laravelで電話番号の正規表現を書いた
  3. バリデーションと正規化は役割が違う
  4. str_replace()だけで十分とは限らない
  5. LaravelではForm Requestで正規化と検証を分離できる
  6. digits_between:10,11だけでは「正しい電話番号」とは限らない
  7. E.164とは
  8. E.164へ揃えると何が便利になるのか
  9. 本格的に扱うなら電話番号用ライブラリを検討する
  10. 「形式が正しい」と「本人の番号である」は別問題
  11. SMSによる二段階認証ではE.164が役立つ
  12. Laravel Jetstreamの標準2FAはSMSではない
  13. 保存形式と表示形式を分ける
  14. データベース設計で考えること
  15. ログへ電話番号を出さない
  16. 個人開発では「そもそも持たない」選択肢がある
  17. 今回の学び
  18. まとめ
  19. 補足:この記事で示したコードについて
  20. 参考資料

この記事で扱うこと

電話番号を扱うときは、単に「正規表現に一致するか」を確認するだけでは不十分です。

実際には、次の問題を分けて考える必要があります。

  1. 入力された文字列を受け付けられるか
  2. 表記揺れをどのように正規化するか
  3. 電話番号として妥当そうか
  4. どの形式で保存するか
  5. その番号を本人が本当に利用できるか
  6. そもそも電話番号を保存する必要があるか

この記事では、Laravelのバリデーションを出発点に、それぞれの役割を整理します。


きっかけ:Laravelで電話番号の正規表現を書いた

お問い合わせフォームに、次のような電話番号バリデーションを追加しました。

$validated = $request->validate([
    'phone' => [
        'nullable',
        'regex:/^0(\d-?\d{4}|\d{2}-?\d{3}|\d{3}-?\d{2}|\d{4}-?\d|\d0-?\d{4})-?\d{4}$/',
    ],
]);

この実装でも電話番号の形式は確認できますが、実務や個人開発で電話番号を扱うなら、正規表現だけにすべてを任せる設計は保守しづらくなりそうです。


バリデーションと正規化は役割が違う

バリデーション

バリデーションは、入力された値が決められた条件を満たしているか確認する処理です。

例:

  • 必須項目か
  • 文字列か
  • 最大文字数を超えていないか
  • 指定した形式に合っているか

Laravelでは、regexdigits_betweenrequirednullableなどのルールを組み合わせられます。

正規化

正規化は、意味が同じ値を、一定の表記へ揃える処理です。

たとえば、次の電話番号は同じ番号を表しています。

090-1234-5678
09012345678
090-1234-5678
090 1234 5678

これらをそのまま保存すると、重複チェックや検索が難しくなります。

そこで、検証の前後で入力を一定の形式へ揃えます。

090-1234-5678
↓
09012345678

つまり、次のように役割を分けます。

入力
↓
正規化
↓
バリデーション
↓
保存または外部API連携

str_replace()だけで十分とは限らない

最初に思いつきやすいのは、ハイフンを削除する方法です。

$phone = str_replace('-', '', $request->input('phone'));

ただし、実際の入力には次のような揺れがあります。

  • 半角ハイフン -
  • 全角ハイフン
  • 長音記号
  • 半角・全角スペース
  • 丸括弧
  • 全角数字

国内番号だけを扱う比較的シンプルなアプリであれば、入力値を正規化して数字列へ揃える方法もあります。

private function normalizeJapanesePhone(?string $value): ?string
{
    if ($value === null || trim($value) === '') {
        return null;
    }

    $value = mb_convert_kana($value, 'n');

    return preg_replace(
        '/[\s\p{Zs}\-‐‑‒–—―ー-()()]/u',
        '',
        $value
    );
}

ただし、この処理はあくまで「表記を揃える」ためのものです。

この処理だけでは、その番号が実在するか、現在割り当て可能な番号か、本人が所有している番号かまでは分かりません。


LaravelではForm Requestで正規化と検証を分離できる

コントローラー内に処理が増えてきた場合は、Form Requestへ分離すると見通しがよくなります。

sail artisan make:request StoreContactRequest

Form Requestでは、prepareForValidation()を使って、バリデーション前に値を整形できます。

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreContactRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    protected function prepareForValidation(): void
    {
        $phone = $this->input('phone');

        if ($phone === null || trim($phone) === '') {
            $this->merge([
                'phone' => null,
            ]);

            return;
        }

        $phone = mb_convert_kana($phone, 'n');

        $phone = preg_replace(
            '/[\s\p{Zs}\-‐‑‒–—―ー-()()]/u',
            '',
            $phone
        );

        $this->merge([
            'phone' => $phone,
        ]);
    }

    public function rules(): array
    {
        return [
            'phone' => [
                'nullable',
                'digits_between:10,11',
            ],
        ];
    }
}

コントローラー側は、検証済みの電話番号を受け取る処理に集中できます。

public function sendMail(StoreContactRequest $request)
{
    $validated = $request->validated();

    $phone = $validated['phone'] ?? null;

    return to_route('contact.complete');
}

Laravel公式ドキュメントでも、バリデーション前に入力を準備・サニタイズしたい場合は、Form RequestのprepareForValidation()を利用できると説明されています。


digits_between:10,11だけでは「正しい電話番号」とは限らない

ハイフンを除去したあとに、次のルールを使うと処理はかなり読みやすくなります。

'phone' => ['nullable', 'digits_between:10,11'],

ただし、これは「10桁または11桁の数字である」ことしか確認していません。

たとえば、次のような値でも桁数条件だけなら通る可能性があります。

0000000000
01234567890

電話番号には、国・地域・用途ごとの番号計画があります。

  • 固定電話
  • 携帯電話
  • IP電話
  • フリーダイヤル
  • 緊急通報番号
  • 国際電話番号

そのため、本格的な電話番号判定を長大な自作正規表現だけで維持するのは難しくなります。

学習用や限定的な国内フォームなら、要件に合わせた簡易チェックでもよいでしょう。一方、SMS送信、国際対応、会員登録、重要な本人確認などで使う場合は、電話番号の重要度や対応範囲に応じて、電話番号用ライブラリやSMS事業者の検証機能を利用する選択肢もあります。


E.164とは

E.164は国際電話番号の表記ルールです。

ITU-Tが定める国際公衆電気通信番号計画として、国番号を含む電話番号の構造を扱います。

一般的なE.164表記は、次のような形です。

+819012345678

日本国内で入力される次の番号を例にします。

090-1234-5678

国番号を付け、国内用の先頭0を外すと、次の形式になります。

+81 90 1234 5678

区切りを除いた保存・API連携用の表記例は次のとおりです。

+819012345678

E.164番号は最大15桁で、国番号を含む国際的な番号構造を扱います。

ただし、文字列の先頭に+81を付けただけでは、正しいE.164番号になったとは限りません。国や地域ごとの番号計画を考慮して解析・検証する必要があります。


E.164へ揃えると何が便利になるのか

1. 表記揺れを減らせる

次の入力を同じ形式へ統一できます。

090-1234-5678
09012345678
+81 90 1234 5678

統一後:

+819012345678

2. 重複チェックを行いやすい

保存形式が揃っていれば、同じ電話番号の重複登録を検出しやすくなります。

Rule::unique('users', 'phone_e164')

ただし、共有電話、家族共用番号、番号の再割り当てなどもあるため、「電話番号が同じなら必ず同一人物」とは限りません。

3. SMS APIへ渡しやすい

SMS事業者のAPIでは、宛先電話番号にE.164形式を要求する場合があります。

たとえばTwilioの公式ドキュメントでは、Messaging APIのToにE.164形式の電話番号を指定するよう案内されています。

+819012345678

4. 国際対応しやすい

国内形式だけを保存すると、国が増えたときに解釈が難しくなります。

09012345678

この値だけでは、どの国の番号かを完全には判断できません。

国番号を含む形式へ統一しておけば、国際対応や外部API連携の設計を整理しやすくなります。


本格的に扱うなら電話番号用ライブラリを検討する

Googleのlibphonenumberは、国際電話番号の解析、整形、検証を行うためのライブラリです。

PHPでは、Googleのlibphonenumberを基にしたgiggsey/libphonenumber-for-phpがあります。

composer require giggsey/libphonenumber-for-php

利用イメージは次のとおりです。

use libphonenumber\PhoneNumberFormat;
use libphonenumber\PhoneNumberUtil;

$phoneUtil = PhoneNumberUtil::getInstance();

$phoneNumber = $phoneUtil->parse(
    '090-1234-5678',
    'JP'
);

if (! $phoneUtil->isValidNumber($phoneNumber)) {
    throw ValidationException::withMessages([
        'phone' => '有効な電話番号を入力してください。',
    ]);
}

$phoneE164 = $phoneUtil->format(
    $phoneNumber,
    PhoneNumberFormat::E164
);

結果例:

+819012345678

ライブラリを利用する利点は、国や地域ごとの番号情報を、自作正規表現だけで抱え込まずに済むことです。

一方で、次の点には注意が必要です。

  • ライブラリの依存関係と更新方針を確認する
  • 番号計画の更新に合わせてライブラリを更新する
  • isValidNumber()が成功しても、本人所有の確認にはならない
  • 短縮番号や特殊番号など、用途によって扱いが異なる

「形式が正しい」と「本人の番号である」は別問題

電話番号の処理では、次の3段階を混同しないことが重要です。

1. 構文上の確認

数字だけか
長さは範囲内か

2. 電話番号としての妥当性確認

国・地域の番号計画に照らして妥当そうか

3. 本人が利用できる番号かの確認

SMSで認証コードを送り、本人が受信できるか

E.164への変換やlibphonenumberによる検証は、主に1と2を助けます。

本人がその番号を所有・利用しているかを確認するには、SMS認証コードなど、別の確認手段が必要です。


SMSによる二段階認証ではE.164が役立つ

SMS認証の一般的な流れは次のとおりです。

ユーザーが電話番号を入力
↓
電話番号を解析・正規化
↓
E.164形式へ変換
↓
SMS APIへ送信先として渡す
↓
認証コードを送信
↓
ユーザーがコードを入力
↓
一致すれば電話番号確認済みにする

このとき、E.164は外部SMS APIへ電話番号を渡すための統一形式として役立ちます。

ただし、SMS認証には次のような別の設計課題もあります。

  • 認証コードの有効期限
  • 再送回数の制限
  • 総当たり攻撃対策
  • 同一IP・同一番号へのレート制限
  • コードの安全な保存
  • 電話番号変更時の再確認
  • SIMスワップなどを考慮したリスク評価
  • SMS送信費用
  • SMSが届かない場合の代替手段

E.164へ揃えるだけで、二段階認証全体の安全性が保証されるわけではありません。


Laravel Jetstreamの標準2FAはSMSではない

Laravel Fortifyや、Laravel 13の公式スターターキット(React・Svelte・Vue・Livewire)に標準搭載される二要素認証は、認証アプリを使うTOTP方式です。なお、Jetstreamはこれらとは別系統のスターターキットですが、こちらもFortifyを土台にしたTOTP方式の二要素認証を提供しており、SMSは使用しません。

認証アプリ
↓
一定時間ごとに変わるコードを生成
↓
ログイン時にコードを入力

この方式では電話番号を使わないため、E.164も不要です。

一方、SMSでコードを送る二段階認証を実装する場合は、SMS事業者との連携を追加し、電話番号を適切に正規化・保存・検証する必要があります。

つまり、次のように分けて考えます。

認証方式 電話番号 E.164
認証アプリによるTOTP 不要 不要
SMS認証コード 必要 API連携で必要になりやすい
メール認証コード 不要 不要

保存形式と表示形式を分ける

電話番号を保存する場合は、保存形式と画面表示形式を分けると扱いやすくなります。

保存形式

+819012345678

日本向け表示形式

090-1234-5678

データベースには統一形式を保存し、画面では利用者に分かりやすい形式へ整形します。

保存値は機械が扱いやすい形式
表示値は人が読みやすい形式

ただし、入力値をどの形式で受け付け、どの国・地域として解釈するかは、画面上で明示する必要があります。

たとえば、日本国内専用なら次のように表示できます。

電話番号(日本国内)
例:090-1234-5678

国際対応するなら、国選択UIを設ける方法もあります。

国・地域:日本(+81)
電話番号:90-1234-5678

データベース設計で考えること

電話番号を保存するなら、最低限次の項目を検討します。

phone_e164
phone_verified_at

Laravelのマイグレーション例:

Schema::table('users', function (Blueprint $table) {
    $table->string('phone_e164', 16)
        ->nullable()
        ->unique();

    $table->timestamp('phone_verified_at')
        ->nullable();
});

E.164の最大15桁に加えて、先頭の+を含めるため、VARCHAR(16)という考え方です。

ただし、unique()を付けるかは要件次第です。

  • 家族で同じ電話番号を使う可能性
  • 法人の代表番号を複数ユーザーが使う可能性
  • 電話番号が再割り当てされる可能性
  • 退会後の番号をどう扱うか
  • ソフトデリートと一意制約の関係

「電話番号は必ず一人につき一つ」と決めつけず、サービス要件を確認する必要があります。


ログへ電話番号を出さない

電話番号を扱う場合、アプリケーションログへの出力にも注意が必要です。

避けたい例:

Log::debug('SMS送信先: '.$phoneE164);

ログは、開発者、運用担当者、監視サービスなど、データベースとは別の経路で参照される可能性があります。

必要がなければ電話番号そのものをログへ出さず、内部IDや処理結果を記録します。

Log::info('SMS verification code requested', [
    'user_id' => $user->id,
    'result' => 'accepted',
]);

障害調査のために一部を表示する場合も、マスキングを検討します。

+8190******78

個人開発では「そもそも持たない」選択肢がある

今回制作している映画レビューアプリのLaravel移植版では、電話番号を保存しない予定です。

電話番号が本当に必要かを考えると、次のような判断になります。

電話連絡を行わない
SMS認証を使わない
予約確認をメールで行える
本人確認に電話番号が不要

この場合、電話番号入力欄そのものを設けない方が、設計としてシンプルです。

電話番号を持たなければ、次の処理も不要になります。

  • 電話番号のバリデーション
  • E.164への変換
  • 暗号化やアクセス制御の検討
  • 漏えい時の影響評価
  • 退会時の削除処理
  • 電話番号変更機能
  • SMS送信APIとの連携
  • SMS送信費用
  • ログやバックアップでの管理

「取得できるから取得する」のではなく、サービスに必要な情報だけを扱うことも重要な設計判断です。


今回の学び

最初は、Laravelで電話番号の正規表現を書く学習でした。

そこから調べていくと、次の論点につながりました。

正規表現
↓
入力値の正規化
↓
保存形式
↓
E.164
↓
SMS API連携
↓
本人確認
↓
二段階認証
↓
個人情報を持つ必要性

入力フォームを作るときは、「どうバリデーションするか」だけでなく、次の順番で考えることが大切だと分かりました。

  1. その情報は本当に必要か
  2. どの入力形式を受け付けるか
  3. どの段階で正規化するか
  4. どの形式で保存するか
  5. 外部APIへどの形式で渡すか
  6. 本人所有をどう確認するか
  7. ログ・バックアップ・退会時にどう扱うか

まとめ

電話番号の正規表現は、電話番号を扱う設計の一部分にすぎません。

バリデーション
= 入力条件を満たすか確認する

正規化
= 同じ意味の値を一定の形式へ揃える

E.164
= 国番号を含む国際的な電話番号形式

SMSコード認証
= 本人がその番号を利用できるか確認する

そして、個人開発では次の判断も重要です。

必要がない個人情報は、最初から取得・保存しない

電話番号のバリデーションをきっかけに、データ設計、外部API連携、認証、個人情報の扱いまで考えるきっかけになりました。


補足:この記事で示したコードについて

この記事のコードは、学習用の例です。

実際のサービスで電話番号やSMS認証を扱う場合は、次の点を要件に合わせて検討する必要があります。

  • 対応する国・地域
  • 固定電話と携帯電話の扱い
  • SMS受信可能番号に限定するか
  • 電話番号用ライブラリの導入
  • SMS事業者の仕様
  • レート制限
  • 認証コードの有効期限
  • 不正利用対策
  • 個人情報保護方針
  • 保存期間と削除方針
  • 法令・契約・事業者ガイドライン

参考資料

1
2
0

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
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?