Laravel4.1、認証機能のサンプル

タグ: Laravel4  

Laravelユーザーも増えてきましたので、認証機能を外部パッケージを使わずにバージョン4.1で組んでみると、こうなりますよというサンプルです。でも、もうほぼ実用です。コードを読んで学ぶタイプの方向けです。

実装する機能は、以下の通りです。

  • ユーザー登録(メールで確認)
  • ロールとURIによるアクセスコントロール
  • パスワードリマインダー(パスワードリセット)
  • Remember Me(オートログイン)
  • ユーザー資格停止(サスペンド)
  • モデルでアクセス、ユーザーのソフトデリート採用

Remember Me機能はLaravelの機能をそのまま使用しています。現状はセキュリティーの面も改善され、そのまま使用することができます。パスワードリマインダー(リセット)の機能も、Laravelの機能をそのまま使用しています。

さすがにコードを全部ルート定義に書くと、かえって読みづらいので、コントローラーにまとめて書いていますが、ひと目で読めるように「ファット・コントローラー」にしています。

テーブルの準備

usersテーブルのマイグレーション中、upメソッドは以下のようになります。

Schema::create( 'users', function( Blueprint $table )
{
    $table->increments( 'id' );
    $table->string( 'username', 64 );
    $table->string( 'email', 320 );
    $table->string( 'password', 60 );
    $table->smallInteger( 'active' )->default( 0 );
    $table->smallInteger( 'suspended' )->default( 0 );
    $table->smallInteger( 'role' )->default( 0 );
    $table->string( 'remember_token' )->nullable();
    $table->timestamps();
    $table->softDeletes();
}

メールでの認証に利用する、認証コードを保持するテーブル(confirms)を別に作成します。ユーザーと1:1のリレーションをもたせます。

<?php

use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateConfirms extends Migration
{

    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create( 'confirms', function( Blueprint $table )
        {
            $table->increments( 'id' );
            $table->unsignedInteger( 'user_id' );
            $table->string( 'key' );
            $table->timestamps();

            $table->foreign( 'user_id' )
                ->references( 'id' )->on( 'users' )
                ->onDelete( 'cascade' )
                ->onUpdate( 'cascade' );
        } );
    }
    ...

テーブル自身は単純ですが、外部キーで紐つけています。これで対応するユーザーが削除された場合は、自動的に削除するようにしています。

その他、パスワードリセッター用の準備は、ドキュメントにしたがって用意してください。

モデルの準備

ユーザーです。

<?php

use Illuminate\Auth\UserInterface;
use Illuminate\Auth\Reminders\RemindableInterface;

class User extends Eloquent implements UserInterface, RemindableInterface
{
    protected $guarded = [ 'id' ];
    protected $hidden = array( 'password' );
    protected $softDelete = true;

    public function setPasswordAttribute( $value )
    {
        $this->attributes['password'] = Hash::make( $value );
    }

    public function confirm()
    {
        return $this->hasOne( 'Confirm' );
    }

    public function getAuthIdentifier()
    {
        return $this->getKey();
    }

    public function getAuthPassword()
    {
        return $this->password;
    }

    public function getRememberToken()
    {
        return $this->remember_token;
    }

    public function setRememberToken( $value )
    {
        $this->remember_token = $value;
    }

    public function getRememberTokenName()
    {
        return 'remember_token';
    }

    public function getReminderEmail()
    {
        return $this->email;
    }

}

パスワードをミューティターを利用し、セット時に自動的にハッシュにしている他は、特別な部分はありません。

続いて、認証キーの管理テーブルのモデルです。

<?php

class Confirm extends Eloquent
{
    protected $fillable = [ 'key' ];

    public function user() {
        return $this->belongsTo('User');
    }

}

こちらも特別な部分はありません。

ルートとフィルター

ルート定義です。

<?php

// パターン

Route::when( '*', 'csrf', [ 'post' ] );
Route::when( 'admin/*', 'admin' );
Route::when( 'dashboard/*', 'auth' );
Route::pattern('id', '[0-9]+');

// 各ルート

Route::get( '/', [ 'as' => 'home', 'uses' => 'FixPageController@home' ] );
Route::get( 'register',
            ['as' => 'register-form', 'uses' => 'AuthController@showRegisterForm' ] );
Route::post( 'register',
             ['as' => 'handle-register', 'uses' => 'AuthController@handleRegister' ] );
Route::get( 'confirm',
            ['as' => 'confirm-form', 'uses' => 'AuthController@showConfirmForm' ] );
Route::post( 'confirm',
             ['as' => 'handle-confirm', 'uses' => 'AuthController@handleConfirm' ] );
Route::get( 'login', ['as' => 'login-form', 'uses' => 'AuthController@showLoginForm' ] );
Route::post( 'login', ['as' => 'handle-login', 'uses' => 'AuthController@handleLogin' ] );
Route::get( 'logout', ['as' => 'logout', 'uses' => 'AuthController@handleLogout' ] );

Route::get( 'remainder',
            ['as' => 'reminder-form', 'uses' => 'RemindersController@showReminderForm' ] );
Route::post( 'remainder',
             ['as' => 'handle-reminder', 'uses' => 'RemindersController@handleReminder' ] );
Route::get( 'reset/{token}',
            ['as' => 'reset-form', 'uses' => 'RemindersController@showResetForm' ] );
Route::post( 'reset/{token}',
             ['as' => 'handle-reset', 'uses' => 'RemindersController@handleReset' ] );

routes.phpです。特別な部分はありません。初めの4パターンで定義している内容は、以下の通りです。

  1. 全POSTルートにCSRFフィルターをかける
  2. 'admin/'で始まるURIには'admin'フィルターをかける
  3. 'dashboard/'で始まるURIには、'auth'フィルターをかける
  4. ルート指定時の'id'プレースフォルダーに、整数の束縛を付ける

フィルターは、以下の通りです。

<?php

App::before( function($request)
{

} );
App::after( function($request, $response)
{

} );
Route::filter( 'auth', function()
{
    if( Auth::guest() )
    {
        return Redirect::guest( 'login' )
                ->withMessage( 'ログインしてください。' );
    }
} );
Route::filter( 'admin', function() 
{
    if( Auth::guest() || Auth::user()->role < 100 )
    {
        return Redirect::guest( 'login' )
                ->withMessage( '管理者の権限がありません。' );
    }
} );
Route::filter( 'csrf', function()
{
    if( Session::token() != Input::get( '_token' ) )
    {
        throw new Illuminate\Session\TokenMismatchException;
    }
} );

ユーザーのロールは、通常のユーザーが'0'、管理者は'100'としています。

ビュー

省略します。

ユーザー登録では、パスワードの確認を省きました。最近は省くほうが主流です。ですから登録してもらうのは、ユーザー名、パスワード、メールアドレスです。

認証は、メールアドレスとパスワードで行います。

ユーザー登録の確認と、パスワードリセットの確認は、メール中からのリンクから飛ばされてくる前提です。今回実装したユーザー登録では、認証コードはクエリー文字列として送り、Laravelの機能をそのまま利用した、パスワードリセッターではURIに含まれています。(仕様)

ユーザー登録の確認のメールのみ、紹介します。

<!DOCTYPE html>
<html lang="en-US">
    <head>
        <meta charset="utf-8">
    </head>
    <body>
        <h2>ユーザー登録の確認</h2>
        <div>
            <p>{{ $username }}様、</p>
            <p>XXXXに登録いただき、ありがとうございます。</p>
            <p>登録したアカウントを有効にするためには、次のリンクをクリックしてください。</p>
            <p>{{ URL::to( 'confirm?key='.$key ) }}</p>
            <p>リンク先で、再度メールアドレスを指定いただきますと、登録確認完了です。</p>
            <p>なお、このリンクは24時間有効です。それ以降は、お手数ですが再度登録し直してください。</p>
            <p>もし、XXXXXに登録していただいた覚えがない場合は、リンクをクリックしないよう、お願いいたします。24時間後、自動的に登録された情報(メールアドレス)は、当サイトから削除されますので、ご安心ください。</p>
            <p>以上、よろしくお願いいたします。</p>
            <p>XXXXXX管理者:川瀬 裕久(master@xxxxxxx.com)</p>
        </div>
    </body>
</html>

コントローラー

リマインダーのため、コマンドで作成する、RemindersControllerは省略します。

AuthContoroller.phpです。

<?php

use Carbon\Carbon;

class AuthController extends BaseController
{

    public function showRegisterForm()
    {
        return View::make( 'auth.register' );
    }

    public function handleRegister()
    {
        $rules = [
            'username' => [ 'required' ],
            'email'    => [ 'required', 'email', 'unique:users' ],
            'password' => [ 'required', 'min:6' ],
        ];

        $inputs = Input::only( ['username', 'email', 'password' ] );

        $val = Validator::make( $inputs, $rules );

        if( $val->fails() )
        {
            return Redirect::route( 'register-form' )
                    ->withErrors( $val )
                    ->withInput();
        }

        DB::beginTransaction();

        try
        {
            $user = User::create( $inputs );

            $confirmKey = Str::random( 16 );

            $user->confirm()->create( ['key' => $confirmKey ] );
        }
        catch( Exception $e )
        {
            DB::rollBack();

            return App::abort( '500', 'データベースが不調です。確認キーの生成に失敗しました。' );
        }

        DB::commit();

        Mail::queue( 'emails.auth.confirm',
                     [ 'username' => $inputs['username'], 'key' => $confirmKey ],
                     function($message) use($inputs)
        {
            $message->subject( 'xxxxxx登録確認' );
            $message->to( $inputs['email'] );
        } );

        return Redirect::route( 'home' )
                ->withMessage( '登録確認メールを送信しました。24時間以内に、中の確認リンクをクリックしてください。' );
    }

    public function showConfirmForm()
    {
        return View::make( 'auth.confirm' );
    }

    public function handleConfirm()
    {
        $rules = [
            'email' => [ 'required', 'email', 'exists:users' ],
            'key'   => [ 'required', 'size:16' ],
        ];

        $inputs = Input::only( [ 'email', 'key' ] );

        $val = Validator::make( $inputs, $rules );

        if( $val->fails() )
        {
            return Redirect::route( 'confirm-form' )
                    ->withInput()
                    ->withErrors( $val );
        }

        $user = User::whereEmail( $inputs['email'] )->first();

        if( $user->active == 1 )
        {
            return Redirect::home()
                    ->withMessage( '既に有効になっています。ログインしてご利用ください。' );
        }

        $expired_at = with( new Carbon( $user->created_at ) )->addHours( 24 );

        if( $expired_at->lt( Carbon::now() ) )
        {
            DB::transaction( function() use($user)
            {
                $user->forceDelete();  // 外部キーの指定でconfirmも同時削除
            } );

            return Redirect::route( 'register-form' )
                    ->withWarning( '仮登録から24時間過ぎています。恐れ入りますが、再度ユーザー登録し直してください。' );
        }

        $confirm = $user->confirm()->first();

        if( $confirm->key != $inputs['key'] )
        {
            return Redirect::route( 'confirm-form' )
                    ->withInput()
                    ->withWarning( '入力されたキーが一致しません。' );
        }

        DB::beginTransaction();

        try
        {
            $user->active = 1;
            $user->save();

            $confirm->delete();
        }
        catch( Exception $e )
        {
            DB::rollBack();

            return App::abort( '500', 'データベースが不調です。本登録に失敗しました。' );
        }

        DB::commit();

        return Redirect::home()
                ->withMessage( '登録を確認しました。ログインいただけます。' );
    }

    public function showLoginForm()
    {
        return View::make( 'auth.login' );
    }

    public function handleLogin()
    {
        $rules = [
            'email'    => [ 'required', 'email' ],
            'password' => [ 'required' ],
        ];

        $inputs = Input::only( ['email', 'password' ] );

        $val = Validator::make( $inputs, $rules );

        if( $val->fails() )
        {
            return Redirect::route( 'login-form' )
                    ->withInput()
                    ->withErrors( $val );
        }

        if( !Auth::attempt( $inputs, (Input::get( 'remember', '0' ) == '1' ) ) )
        {
            return Redirect::route( 'login-form' )
                    ->withInput()
                    ->withWarning( 'メールアドレス/パスワードが一致しません。' );
        }

        if( Auth::user()->active == 0 )
        {
            Auth::logout();

            return Redirect::home()
                    ->withWarning( 'ユーザー登録が終了していません。登録確認メール中のリンクをクリックし、確認作業を初めてください。' );
        }

        if( Auth::user()->suspended == 1 )
        {
            Auth::logout();

            return Redirect::home()
                    ->withDanger( 'このユーザーの利用資格は停止されています。' );
        }



        return Redirect::intended( Auth::user()->role == 100 ? route( 'admin-panel' ) : '/'  )
                ->withMessage( 'ログインしました。' );
    }

    public function handleLogout()
    {
        Auth::logout();

        return Redirect::home()
                ->withMessage( 'ログアウトしました。' );
    }

}

太っていますが、それでも二百行足らずです。

リダイレクト時にwithで$message、$warning、$dangerに文字列をセッションにフラッシュとして保存すると、リダイレクト先でメッセージとして表示する仕組みにしています。Redirect::back()は、大抵の場合問題にならないと思いますが、できるだけ避け、Redirect::route()でルートに付けた名前で指定しています。

これ以外を説明するのはちょっと億劫ですので、コメント入りバージョンをご覧ください。

<?php

use Carbon\Carbon;

class AuthController extends BaseController
{

    // 今回のコードでは、フォームの出力は
    // show....Formというアクション名を付けています。
    public function showRegisterForm()
    {
        return View::make( 'auth.register' );
    }

    // フォームの処理はhandle...という名前を付けています。
    public function handleRegister()
    {
        $rules = [
            'username' => [ 'required' ], // 日本語の場合、漢字一文字があるため
            'email'    => [ 'required', 'email', 'unique:users' ],
            'password' => [ 'required', 'min:6' ],  // 長さはリマインダーと合わせてある
        ];

        $inputs = Input::only( ['username', 'email', 'password' ] );

        $val = Validator::make( $inputs, $rules );

        if( $val->fails() )
        {
            return Redirect::route( 'register-form' )
                    ->withErrors( $val )
                    ->withInput();
        }

        // トランザクションの開始、usersテーブルと、紐ついたconfirmsテーブルを
        // 同時に更新するため。
        // また、エラー発生時に500にするため、DB::transaction()を使用していない。
        DB::beginTransaction();

        try
        {
            $user = User::create( $inputs );

            // Str::random()の実装は調べていない。
            // 乱数生成のアルゴリズムに問題がないか、要チェック
            // 実用にするなら、16ではなく、もっと増やしたほうが良い
            $confirmKey = Str::random( 16 );

            $user->confirm()->create( ['key' => $confirmKey ] );
        }
        catch( Exception $e )
        {
            DB::rollBack();

            return App::abort( '500', 'データベースが不調です。確認キーの生成に失敗しました。' );
        }

        DB::commit();

        // メールは基本queue()で送信する。デフォルトのsyncドライバーだと、
        // send()コマンドとほぼ同じ。(結果が取れないのが弱点か。)
        // 本格的にキューが使用したくなったら、設定ファイルを変更して
        // 対応できる。
        Mail::queue( 'emails.auth.confirm',
                     [ 'username' => $inputs['username'], 'key' => $confirmKey ],
                     function($message) use($inputs)
        {
            $message->subject( 'xxxxxxx登録確認' );
            $message->to( $inputs['email'] );
        } );

        // 'home'と名前を付けたルートへリダイレクトする。
        // 今回は'/'へリダイレクト。
        return Redirect::home()
                ->withMessage( '登録確認メールを送信しました。24時間以内に、中の確認リンクをクリックしてください。' );
    }

    public function showConfirmForm()
    {
        return View::make( 'auth.confirm' );
    }

    public function handleConfirm()
    {
        // メールアドレスは、usersテーブルに登録されていることをバリデートする。
        $rules = [
            'email' => [ 'required', 'email', 'exists:users' ],
            'key'   => [ 'required', 'size:16' ],
        ];

        $inputs = Input::only( ['email', 'key' ] );

        $val = Validator::make( $inputs, $rules );

        if( $val->fails() )
        {
            return Redirect::route( 'confirm-form' )
                    ->withInput()
                    ->withErrors( $val );
        }

        // バリデーションで、メールアドレスは存在していることを確認済み
        // そのため、必ずヒットするので、クエリー後のチェックは省略している。
        // ただ、バッチ処理の削除とバッティングする可能性があるので、
        // より良い方法としては、共有でレコードロックをかけ、
        // バッチ処理は専有でテーブルロックをかける方が良いだろう。
        $user = User::whereEmail( $inputs['email'] )->first();

        // 確認済みのチェック
        if( $user->active == 1 )
        {
            return Redirect::home()
                    ->withMessage( '既に有効になっています。ログインしてご利用ください。' );
        }

        // 認証キーの有効期限チェック。本来はconfirmsテーブルのレコードで調べるが、
        // ユーザーと同時に作成されるので、ユーザー作成日付を代用している。
        // (アクセスが増えるので、遅らせたい。)
        // Carbonを利用し、24時間後のインスタンスを生成している。
        // withはLaravelのヘルパー、生成したインスタンスにメソッドチェーンを
        // 繋げたい場合に利用する。
        // テーブルのタイムスタンプの値は、直接Carbonに渡して、その日時の
        // インスタンスが生成できます。
        $expired_at = with( new Carbon( $user->created_at ) )->addHours( 24 );

        //  $expired_at < Carbon::now() をチェック
        if( $expired_at->lt( Carbon::now() ) )
        {
            // 本当は失敗時は500にしたほうが良い :D
            // 今回は手抜きでtransaction()を使用。
            DB::transaction( function() use($user)
            {
                // 外部キーで指定しているため、
                // 対応するconfirmsのレコードも同時削除
                $user->forceDelete();  
            } );

            return Redirect::route( 'register-form' )
                    ->withWarning( '仮登録から24時間過ぎています。恐れ入りますが、再度ユーザー登録し直してください。' );
        }

        // 関連付けから、対応するconfirmsレコードを取得
        // 事前にEagerロードする手もあるが、ユーザーが有効でない場合、
        // そのクエリーが一回分無駄になるため、ここまで遅らせた。
        $confirm = $user->confirm()->first();

        if( $confirm->key != $inputs['key'] )
        {
            return Redirect::route( 'confirm-form' )
                    ->withInput()
                    ->withWarning( '入力されたキーが一致しません。' );
        }

        // ユーザーを有効(active=1)にし、同時に
        // 対応するconfirmsレコードを削除
        DB::beginTransaction();

        try
        {
            $user->active = 1;
            $user->save();

            $confirm->delete();
        }
        catch( Exception $e )
        {
            DB::rollBack();

            return App::abort( '500', 'データベースが不調です。本登録に失敗しました。' );
        }

        DB::commit();

        return Redirect::home()
                ->withMessage( '登録を確認しました。ログインいただけます。' );
    }

    public function showLoginForm()
    {
        return View::make( 'auth.login' );
    }

    public function handleLogin()
    {
        // 登録時にパスワード長はエラーメッセージで表示されるが、
        // ヒントになるため、ここでは積極的に出さない。
        $rules = [
            'email'    => [ 'required', 'email' ],
            'password' => [ 'required' ], 
        ];

        $inputs = Input::only( ['email', 'password' ] );

        $val = Validator::make( $inputs, $rules );

        if( $val->fails() )
        {
            return Redirect::route( 'login-form' )
                    ->withInput()
                    ->withErrors( $val );
        }

        // 未有効(active=0)と、アカウント停止(suspended=1)を
        // この認証の時点で弾くこともできるが、エラーメッセージを分けて表示するため、
        // メールアドレスとパスワードだけで認証する。
        // ログインページのremembeチェックボックスにチェックが付けられたら(1)、
        // Remember Me(オートログイン)を有効にする
        if( !Auth::attempt( $inputs, (Input::get( 'remember', '0' ) == '1' ) ) )
        {
            return Redirect::route( 'login-form' )
                    ->withInput()
                    ->withWarning( 'メールアドレス/パスワードが一致しません。' );
        }

        // ユーザー登録の確認が済んでいるかチェック
        if( Auth::user()->active == 0 )
        {
            Auth::logout();

            return Redirect::home()
                    ->withWarning( 'ユーザー登録が終了していません。登録確認メール中のリンクをクリックし、確認作業を初めてください。' );
        }

        // ユーザーの利用資格が停止されていないかチェック
        if( Auth::user()->suspended == 1 )
        {
            Auth::logout();

            return Redirect::home()
                    ->withDanger( 'このユーザーの利用資格は停止されています。' );
        }

        // 認証成功!
        // ログインページを表示されたきっかけが、'auth'フィルターに
        // 引っかかった場合のときは、そのURIにログイン後にアクセス可能であれば、
        // もとのアクセスURIへリダイレクトされる。それ以外の場合、
        // 通常ユーザー(roll=0)はルート(/)、管理者(roll=100)は
        // 管理者パネルへリダイレクトする。
        return Redirect::intended( Auth::user()->role == 100 ? route( 'admin-panel' ) : '/'  )
                ->withMessage( 'ログインしました。' );
    }

    public function handleLogout()
    {
        Auth::logout();

        return Redirect::home()
                ->withMessage( 'ログアウトしました。' );
    }

}

コマンド

登録したが、24時間経っても確認作業を行わないユーザーを削除するバッチ処理が必要となります。通常はcronなどで、外部から実行することになるでしょう。

コマンドのベースとして、利用しているクラスです。

<?php

use Illuminate\Console\Command;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Formatter\OutputFormatterStyle;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

/**
 * Handle exception and internationalize messages
 * for Laravel & Symfony Command class.
 */
class BaseCommand extends Command
{

    /**
     * Override run method to internationalize error message.
     *
     * @param \Symfony\Component\Console\Input\InputInterface $input
     * @param \Symfony\Component\Console\Output\OutputInterface $output
     * @return int Return code
     */
    public function run( InputInterface $input, OutputInterface $output )
    {
        // Set extra colors
        // The most problem is $output->getFormatter() don't work.
        // So create new formatter to add extra color.
        $formatter = new OutputFormatter( $output->isDecorated() );
        $formatter->setStyle( 'red', new OutputFormatterStyle( 'red', 'black' ) );
        $formatter->setStyle( 'green', new OutputFormatterStyle( 'green', 'black' ) );
        $formatter->setStyle( 'yellow', new OutputFormatterStyle( 'yellow', 'black' ) );
        $formatter->setStyle( 'blue', new OutputFormatterStyle( 'blue', 'black' ) );
        $formatter->setStyle( 'magenta', new OutputFormatterStyle( 'magenta', 'black' ) );
        $formatter->setStyle( 'yellow-blue', new OutputFormatterStyle( 'yellow', 'blue' ) );
        $output->setFormatter( $formatter );

        try
        {
            $result = parent::run( $input, $output );
        }
        catch( \RuntimeException $e )
        {
            // All error messages were hard coded in
            // Symfony/Component/Console/Input/Input.php
            if( $e->getMessage() == 'Not enough arguments.' )
            {
                $this->error( '引数が足りません。' );
            }
            elseif( $e->getMessage() == 'Too many arguments.' )
            {
                $this->error( '引数が多すぎます。' );
            }
            elseif( preg_match( '/The "(.+)" option does not exist./', $e->getMessage(),
                                $matches ) )
            {
                $this->error( $matches[1].'オプションが足りません。' );
            }
            else
            {
                $this->error( $e->getMessage() );
            }
            $result = 1;
        }

        return $result;
    }

}

今回作成したコマンドはこちらです。

<?php

use Symfony\Component\Console\Input\InputOption;

/**
 * Simple deploy Artisan command.
 *
 * You can change command by config file setting.
 */
class BatchCommand extends BaseCommand
{
    /**
     * The dummy console command name.
     * This will be replaced by config setting.
     *
     * @var string
     */
    protected $name = 'batch';

    /**
     * The dummy console command description.
     * This will be replaced by language file setting.
     *
     * @var string
     */
    protected $description = 'バッチ処理実行ツール';

    /**
     * Execute the console command.
     *
     * Return value will be execute code of this command.
     * So don't return ture/false. It must be integer.
     *
     * @return integer Return Code. 0: Terminated successfully.
     */
    public function fire()
    {
        $args = array_merge( $this->option(), $this->argument() );

        if( $args['sweep-confirm'] )
        {
            $affected = User::whereActive( '0' )
                ->where( DB::raw( 'created_at + INTERVAL 24 HOUR' ), '<',
                                                 DB::raw( 'now()' ) )
                ->forceDelete();

            $this->line( 'ユーザーレコードを<green>'.$affected.'件</green>削除しました。' );
        }


        if( $args['purge-cache'] )
        {
            Cache::flush();

            $this->line( '全キャッシュをパージしました。' );
        }

        return 0;
    }

    /**
     * Get the console command arguments.
     *
     * @return array
     */
    protected function getArguments()
    {
        return [ ];
    }

    /**
     * Get the console command options.
     *
     * @return array
     */
    protected function getOptions()
    {
        return [
            [
                'purge-cache',
                'p',
                InputOption::VALUE_NONE,
                '全キャッシュを削除する。',
                null
            ],
            [
                'sweep-confirm',
                's',
                InputOption::VALUE_NONE,
                '期限切れ登録確認キーレコードを削除する。',
                null
            ],
        ];
    }

}

-sで24時間経過しているが、有効になっていないユーザーをまとめて削除しています。ついでに、-pオプションで、全キャッシュをパージする機能も付けています。

コマンドを有効にするには、登録する必要があります。

厳密に言えば、ユーザー削除の場合、トランザクションを利用する必要があります。更に、削除前にユーザーテーブルをロックしておく必要もあるでしょう。それでもユーザー側で、(最悪であるが最善でもある、処理の初めから終わりまで)ロックをかけているわけではないため、1,コントローラーで24時間経ったレコード発見、2.バッチで24時間経ったレコード削除、3.コントローラー側が削除しようとしたけど、見つからなかった、という事態が起こり得ます。(レコードロックのことをすっかり忘れてしまいました。:D)

システムの運用も考慮すれば、バッチ中はシステムをメンテモードにし、バッチ処理を行い、終了後に再開するというのが、安全です。レコード更新の遅れも考え、メンテモードにし、若干時間を置き、削除処理し、すぐ再開である程度リスクは回避できそうです。

もしくは、確率的に多少の不具合が発生する可能性を受け入れてしまえば、コードはシンプルになります。ここらへんは実装の手間(コスト)と安全性を天秤にかけ、決定する部分です。今回のコードは、ある程度のリスクは受け入れ、簡単にコードできる範囲に収めてあります。

または、Laravelにはプライオリティーでコントロールでき、複数のキューを取り扱う機能がついていますので、これを利用しDBアクセスをキュー管理するかでしょう。(多分、テーブルロックよりはオーバーヘッドはかかりますが、キューの順番が入れ替わらず、かつプライオリティーが正しく動作していれば、安全な方法です。)

まあ、そこら辺はLaravelに限定した話でもないですし、よく考えましょうということで、おしまいにしましょう。

コマンド実行はもちろん、'php artisan batch オプション'となります。コマンドの名前はbatchですが、適当なものに直しましょう。

その他

そのうち、メールアドレス変更時の処理も追加しておきます。登録時の確認キーとメールの処理を流用すれば、簡単に実現できるでしょう。(ただ、この場合はユーザレコードの作成日時ではなく、確認キーのレコードの作成日時を初めからチェックする必要があります。)