はじめに
CakePHP3がリリースされてから、夢のように年月が経ってしまいました。そのCakePHP3も来年にはサポート終了となり、現行バージョンはCakePHP4、すでにCakePHP5の開発も始まっています。そして、残念ながら、CakePHP2は今年の6月にサポート終了となっています。
しかし、CakePHP2で作られたアプリケーションをCakePHP3以降にアップグレードするのは困難です。特にbaserCMSの場合には、CMSという性質上、仮にbaserCMS自体をアップグレードできたとしても、今度は利用者の方がアプリケーション、作成したテーマ、プラグインなどをアップグレードする必要があります。
もっと簡単に、新機能だけをつまみ食いするような形で段階的なアップグレードを行えないかずっと悩んでいたのですが、よい考えは浮かびませんでした。
そんな中、3年ほど前に仕事の都合でLaravelという別のフレームワークを触る機会がありました。
そして私は気付きました。気付いてしまったのです。
CakePHP2はCakePHP3以降にアップグレードするよりもLaravelに移行する方が遥かに簡単だということに。
本稿ではbaserCMSを実際にLaravelで動作させてみることで、CakePHP2からLaravelへの移行がいかに簡単かをご紹介したいと思います。
必須要件
- PHP >= 7.3
- Composer >= 2.0
最新のLaravel 8.xをインストールするためにPHPのバージョンは7.3以上である必要があります。PHPのバージョンが7.3未満でも7.2.5以上であれば代わりに6.xをインストールすることもできますが、保守期間が8.xよりも少し短くなっています。
また、LaravelではComposerを使用しますのでComposerも必要です。
インストール
まずはbaserCMSをインストールガイドにしたがってインストールしましょう。テーマは既定のBcSampleを選択してください。インストールが完了したら、簡単な動作確認をしておきましょう。
Laravel化
baserCMSをLaravelで動作させるまでにかかる時間は5分くらいだと思います。
オートロード設定
では、早速Laravel化していきましょう。
Laravelではオートロードの設定が必要です。baserCMSのROOTにcomposer.jsonがありますので、それを開いて設定を追加します。
{
...
"autoload": {
"psr-4": {
"Baser\\": "lib/Baser",
"App\\": "app"
}
},
...
}
設定を追加したら、dump-autoloadを実行します。
composer dump-autoload
関連パッケージ取得
まず、ComposerでLaravelのフレームワーク本体を取得します。
composer require "laravel/framework:^8.0"
次にOstoandelプラグインを取得します。CakePHP2をLaravelで動作させるためのプラグインとして開発しました。まだ、リリース前なのでdev-masterを指定してください。
composer require "ostoandel/ostoandel:dev-master"
最後にLaravelのアプリケーションを取得します。コマンドが少し違うので注意してください。
composer create-project --no-install --no-scripts "laravel/laravel:^8.0"
プラグイン読込
app/Config/bootstarp.phpでOstoandelプラグインを読み込んでください。pathは必須です。
CakePlugin::load('Ostoandel', ['path' => VENDORS . '/ostoandel/ostoandel/plugin/']);
Laravel化コマンド実行
Ostoandelプラグインを読み込んだらLaravel化コマンドを実行します。ROOT以下のファイルを書き換えますので、必要に応じてバックアップを取ってください。
cake Ostoandel.laravelize
retry関数
baserCMSの場合にはもうひとつだけ追加の手順があります。lib/Baser/basics.phpを開いてretry関数が定義されないようにしてください。Laravelにも同名の関数があり、名前が衝突してしまうためです。
if (!function_exists('retry')) {
function retry($times, callable $callback, $interval = 0)
{
...
}
}
さて、これでbaserCMSがLaravelで動作するようになったはずです。さっそくトップページを開き直してみましょう。
baserCMSのトップページが開けば成功です。おつかれさまでした。エラーになってしまった場合には手順を見直してみてください。
といっても、見た目の変化がないのであまり実感がないかもしれませんね。では、存在しないページを開いてみましょう。
ね? ちゃんとLaravelのエラー画面が表示されたでしょう?
互換性のない機能
Laravelへの移行後もまったく変わらず動作するかというと、一部、互換性のない機能があります。
config関数
CakePHPでは名前を指定して設定ファイルを取り込むための関数です。
Laravelでは設定値を読み書きするための関数になっており、Ostoandelではこちらが使用されます。
もし、アプリケーション中でconfig関数を使用している場合は、代わりにincludeなどを使用するようにしてください。
cache関数
CakePHPではファイルを指定してキャッシュの読み書きをするための関数です。
Laravelではキーを指定してキャッシュの読み書きをする関数になっていて、ふるまいが異なっています。
CakePHPではすでに廃止予定としてマークされているため使用されていない場合が多いと思いますが、もし使用している場合には、代わりにCacheファサードなどを使用するようにしてください。
env関数
CakePHPとLaravel、いずれにおいても$_SERVER、$_ENVまたは環境変数から値を取得する関数なので、ある程度の互換性がありますが、一部のふるまいが異なっています。
CakePHPでは以下のキーについては他のキーを参照するなどして別の値を返すことがあります。
- HTTPS
- SCRIPT_NAME
- REMOTE_ADDR
- DOCUMENT_ROOT
- PHP_SELF
- CGI_MODE
- HTTP_BASE
HTTPSについてはoff以外の値の場合には真を、offの場合には偽を返します。
その他のキーについての詳細はenv関数の実装を参照してください。
一方、Laravelでは一部の値について型の変換や空文字への変換が行われます。
- true または (true)
- false または (false)
- empty または (empty)
- null または (null)
また、Laravelではキーが存在しない場合に返す既定の値を第二引数で指定することができます。
複数のデータ元に同名のキーが存在する場合の優先順位についてもCakePHPとLaravelでは異なっています。CakePHPでは$_SERVER、$_ENV、環境変数の順番でキーを探しますが、Laravelでは$_ENV、$_SERVER、環境変数の順番でキーを探します。
以上のようにCakePHPとLaravelではenv関数のふるまいに若干の違いがありますが、中でも重要なのはHTTPSの扱いの違いです。CakePHPでは特別な変換が行われて真偽を返しますが、Laravelでは変換対象にはなりませんのでonやoffを文字列で返します。
OstoandelではLaravelのenv関数を使用しますが、HTTPSについては影響が大きいため、優先順位の差を利用して、$_SERVER['HTTPS']の値に応じて$_ENV['HTTPS']に真偽値を設定するようにしています。結果としてenv('HTTPS')は真偽を返すようになりますし、何らかの理由で$_SERVER['HTTPS']を直接参照している処理があったとしても、これまで通りに動作すると思います。
コントローラーでの型宣言
CakePHP2ではControllerクラスで定義されていないpublicメソッドで、かつメソッド名がアンダースコアで始まらないメソッドは、既定のルーティングではアクションとして扱われます。
実はこの仕様は脆弱性に繋がる可能性もあります。
baserCMSではsendMailというメソッドがBcAppControllerに存在しますが、このメソッドはControllerクラスでは定義されておらず、publicで、メソッド名がアンダースコアで始まっていないため、本来であればこれもアクションとして呼び出すことが可能です。そのため、baserCMSではバックトレースを確認することで安全を担保しているようです。
class BcAppController exends Controller
{
...
public function sendMail($to, $title = '', $body = '', $options = [])
{
$dbg = debug_backtrace();
if (!empty($dbg[1]['function']) && $dbg[1]['function'] === 'invokeArgs') {
$this->notFound();
}
...
}
...
}
もし、バックトレースの確認がなければ、悪意のある第三者がメールを送信できてしまっていたでしょう。
こうしたアクションではないpublicな共通メソッドが、ひょっとするとアプリケーションによっては型宣言のために守られているということもあるかもしれません。仮に以下のような共通メソッドがあったとします。
App::uses('CakeEmail', 'Network/Email');
class AppController exends BcAppController
{
...
public function sendCakeEmail(CakeEmail $email)
{
...
}
...
}
CakeEmailの型宣言があるため、仮に既定のルーティングを使用していたとしても、引数としてCakeEmailのインスタンスを渡すことまでは不可能ですので、このsendCakeEmailメソッドはアクションとして呼び出すことは通常はできません。
しかし、Laravel化した場合には、あとで紹介するサービスコンテナーによる依存性の注入の機能により、CakeEmailは自動的にインスタンス化されます。結果として、こうしたメソッドが脆弱性に繋がってしまう可能性があります。
コントローラーに共通メソッドを用意する場合には、protectedで宣言するか、メソッド名をアンダースコアで始めるようにしてください。
何らかのインターフェイスを実装する必要があるなど、どうしてもpublicで、かつアンダースコアから始まらないメソッドを用意する必要がある場合は、methodsプロパティ―から手動で除外する方法もあります。
class AppController extends BcAppController
{
...
public function __construct($request = null, $response = null)
{
parent::__construct($request, $response);
$this->methods = array_diff($this->methods, ['sendCakeEmail']);
}
public function sendCakeEmail(CakeEmail $email)
{
...
}
...
}
運用中のアプリケーションをLaravel化させる場合には、アプリケーションにこうしたメソッドが存在しないかどうか、事前に確認した上でLaravel化するようにしてください。
Laravelの機能つまみ食い
以下の手順はすべて任意です。すべてに目を通す必要はありません。目ぼしい機能だけつまみ食いしてください。
なお、以下の節はあくまでbaserCMSの本体をLaravelに移行する想定で記載していますので、本体のコードを直接修正してしまっていますが、実際にお使いのアプリケーションをLaravel化する場合には、baserCMSのコアは修正すると以降のアップグレードを受けるのが難しくなってしまいますので、なるべく触らないようにしましょう。
エラー処理
インストールの手順で存在しないページの表示が変わってしまっていたと思います。これはCakePHPのエラー処理はもはや実行されていないためです。
でも、元通りにbaserCMSのエラー画面を表示したいかもしれませんね。
Laravelではエラー処理はapp/Exceptions/Handler.phpで行われていますので、OstoandelのLaravelExceptionRendererを使用するように修正します。
namespace App\Exceptions;
...
use Ostoandel\Exceptions\LaravelExceptionRenderer;
class Handler extends ExceptionHandler
{
...
public function render($request, $exception)
{
try {
return (new LaravelExceptionRenderer($exception))->render();
} catch (\Throwable $e) {
return parent::render($request, $exception);
}
}
}
renderメソッドをオーバーライドしてOstoandel版のExceptionRendererを使用するように変更しました。
これで元のエラー画面が表示されるようになるはずです。
デバッグバー
Laravelでの開発を行いやすくするためにデバッグバーを導入してみましょう。
Composerからデバッグバーをインストールします。
composer require --dev "barryvdh/laravel-debugbar"
インストール後、config/app.phpにサービスプロバイダーを追加します。
return [
...
'providers' => [
...
Barryvdh\Debugbar\ServiceProvider::class,
],
...
];
ページを再読込すれば画面の左下にLaravelのロゴが現われるはずです。
ただ、このままだとちょっと問題もあります。あとで紹介するBladeViewを使用するとデバッグバーがエラーで表示されなくなってしまうのです。
あとでプルリクエストを出してみようと思っていますが、ここではデバッグバー側のコードを直接修正してしまいます。
namespace Barryvdh\Debugbar\DataCollector;
...
class ViewCollector extends TwigCollector
{
...
public function addView($view)
{
...
}
...
}
ViewCollectorクラスのaddViewメソッドの引数から型宣言をなくしました。インターフェイスで宣言してくれていたらよかったのですが、クラスで宣言されているためBladeViewを渡せませんでした。
でも、これでBladeViewを使用してもデバッグバーを使用できるはずです。
データベース
デバッグバーを見てみると、現状ではQueriesタブに何も表示されません。baserCMSのクエリーはCakePHPが実行されているため、Laravelはクエリーが実行されていることに気付いていないのです。
これはあまりよい状態ではありません。
現時点ではすべてのクエリーをCakePHPが実行している都合、問題はデバッグバーでクエリーを確認できないことくらいですが、たとえばLaravelのEloquentモデルを部分的に導入したいと考えたとしましょう。
そうなれば、CakePHPとLaravelがそれぞれクエリーを実行することになります。
当然、トランザクションも別になってしまいますので、一貫性のある処理を書くことができなくなってしまいます。
この状態を脱するためにDboSourceにLaravelのConnectionを使用してクエリーを実行させることもできます。
まずはLaravel側のデータベースの設定を行います。.envを開いて以下の設定を環境に合わせて変更しましょう。
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=root
DB_PASSWORD=
次にconfig/database.phpを開いて適切なprefixを設定してください。また、strictについてはfalseにする必要があります。これはCakePHPが緩いクエリーを実行する場合があり、厳格なモードだとエラーになってしまうためです。
return [
...
'connections' => [
...
'mysql' => [
...
'prefix' => 'mysite_',
...
'strict' => false,
...
],
...
],
...
];
続いてCakePHP側の修正を行います。
baserCMSでは、データベース接続に標準のDboSourceを拡張したBcMysqlクラスを使用しているようですので、こちらを以下のように改造します。
use Ostoandel\Traits\LaravelDatabase;
class BcMysql extends Mysql
{
use LaravelDatabase;
...
}
ページを更新してみましょう。
デバッグバーのQueriesタブにbaserCMSが実行したクエリーが表示されるようになったはずです。
おなじみのapp/Config/database.phpの設定はdatasourceの指定を除いて不要になりました。
class DATABASE_CONFIG {
public $default = [
'datasource' => 'Database/BcMysql',
];
public $test = [
'datasource' => 'Database/BcMysql',
];
}
なお、CakePHP側のdefaultの設定はLaravel側のdefaultの設定に読み替えられます。Laravel側のdefaultをmysqlから変更したい場合には、.envでDB_CONNECTIONにpgsqlなどを指定します。
また、testの設定はPHPUnitを実行する場合には必要になるので、mysqlの設定を参考に追加するといいでしょう。
return [
...
'connections' => [
...
'test' => [
...
],
...
],
...
];
なお、データベースの接続をLaravelに任せるようにすると、副作用としてcakeコマンドが動かなくなってしまいますが、安心してください。Ostoandelではcakeコマンドはartisanコマンドのサブコマンドとして実行できるようになっています。
php artisan cake bake
ストレージ
CakePHPでは一時ファイルなどはapp/tmpに置かれましたが、Laravelではstorageに置かれます。
置き場所をstorageに統一したい場合には、app/Providers/AppServiceProvider.phpで関連する定数を定義します。おすすめの設定は以下の通りです。
namespace App\Providers;
...
class AppServiceProvider extends ServiceProvider
{
...
public function register()
{
defined('TMP') || define('TMP', storage_path('framework/'));
defined('CACHE') || define('CACHE', storage_path('framework/cache/'));
defined('LOGS') || define('LOGS', storage_path('logs/'));
}
...
}
定数のほとんどはCakeServiceProviderのbootメソッド中で定義されますので、AppServiceProviderのregisgterメソッドで先に定義することができます。
ルーティング
Laravelの書き方でルーティングを定義することもできます。routes/web.phpを以下のように変更してみます。
Route::prefix('admin')->namespace('\Baser\Controller')->group(function() {
Route::any('favorites/ajax_add', 'FavoritesController@admin_ajax_add');
Route::any('favorites/ajax_delete', 'FavoritesController@admin_ajax_delete');
Route::any('favorites/ajax_edit/{id}', 'FavoritesController@admin_ajax_add');
Route::any('favorites/update_sort', 'FavoritesController@admin_update_sort');
});
Route::fallbackToCake();
Laravelではコントローラーやアクションにワイルドカードを使用することはできません。必要なルーティングは明示的に定義する必要があります。
また、コントローラーには任意の名前空間を使用することができます。利用者のアプリケーションと名前空間を分けるために例ではBaser\Controllerを指定してみました。まだFavoritesControllerはグローバル名前空間に定義されているので修正を行いましょう。
FavoritesControllerで名前空間を使用するように変更します。
namespace Baser\Controller;
use AppController;
...
class FavoritesController extends AppController
{
...
public $name = 'Favorites';
...
}
注意点として、名前空間を使用した場合にはnameプロパティ―を指定する必要があります。CakePHPはコントローラーの名前からモデルやビューを推測しますが、名前が指定されていない場合はクラス名を使用します。名前空間を使用するとクラス名も変わるため、モデルやビューを正しく取得できなくなってしまいます。ただ、baserCMSでは最初から指定されているみたいですね。
もう一つの注意点として、CakePHPのルーティングにフォールバックしない場合、コントローラーのコンストラクターにはCakeRequestとCakeResponseは渡されません。
これはLaravelのルーティングの仕様によるものなのですが、baserCMSのBcAppControllerのコンストラクターは以下のようなコードになっているため、CakeRequestが渡されないと動作しませんでした。
class BcAppController extends Controller
{
...
public function __construct($request = null, $response = null)
{
parent::__construct($request, $response);
...
$isRequestView = $request->is('requestview');
...
}
...
}
このままだと動作しないので、コンストラクターの代わりにconstructClassesをオーバーライドするように変更します。
class BcAppController extends Controller
{
...
public function constructClasses() {
$request = $this->request;
...
$isRequestView = $request->is('requestview');
...
parent::constructClasses();
}
}
constructClassesはコンストラクターが呼ばれた直後に呼び出されるので、この変更による影響はないはずです。
それから、BcRedirectMainSiteFilterクラスも修正する必要があります。このクラスはコントローラーの存在確認のためにCakePHPのコントローラーを探してしまうため、コントローラーを名前空間に入れるとエラーになってしまうようです。Laravelのルーティングでコントローラーが取得できている場合にはチェックしないように修正します。
class BcRedirectMainSiteFilter extends DispatcherFilter
{
...
protected function _existController($request)
{
if (request()->route()->controller) {
return true;
}
...
}
}
さて、お気に入りの登録や削除を行ってみましょう。ちゃんと動くはずです。
Bladeテンプレート
CakePHP2からCakePHP3以降へのアップグレードを試みたことのある方は、ビューをアップグレードすることさえ難しいことに気付いたはずです。
ビューをアップグレードするには、その依存関係にあるヘルパー、リクエスト、レスポンス、イベントマネージャー等をアップグレードする必要があります。それらにも依存関係があり、そうやって修正が次々に別の層に波及して、結局アプリケーション全体を修正するはめになってしまうのです。
一方、Laravelに移行するのであれば、一部のビューだけ部分的に導入することさえ可能です。実際にbaserCMSの一部のビューにLaravelのBladeテンプレートを使用してみましょう。
まず、BcAppViewクラスを継承したBladeViewクラスを作成します。Ostoandelも標準でBladeViewを用意してあったのですが、baserCMSは独自の仕様でビューを探すため、そのままでは使用できませんでした。なお、BladeViewクラスではLaravelのViewインターフェイスを実装する必要があります。LaravelBladeトレイトを使用するのが簡単です。
use Ostoandel\Traits\LaravelBlade;
App::uses('BcAppView', 'View');
class BladeView extends BcAppView implements \Illuminate\Contracts\View\View
{
use LaravelBlade;
}
次に、PagesControllerのdisplayアクションを以下のように変更します。
class PagesController extends AppController
...
public function display()
{
$this->viewClass = 'Blade';
...
}
}
先ほど作成したBladeViewが使用するように変更しました。
トップページを開けば、デバッグバーのViewsタブにBladeViewが処理したテンプレートの一覧が表示されるようになるはずです。
BladeはPHPの構文はのまま解釈しますので、$thisとしてViewのインスタンスさえ渡すことができれば、既存の.ctpファイルを処理することができるのです。
早速Bladeの構文も使ってみましょう。app/webroot/theme/bc_sample/Pages/templates/default.phpを修正します。
@extends('/Layouts/default')
@section('content')
<?php
$this->layout = 'empty';
$this->BcPage->content();
?>
@endsection
CakePHPの代わりにBladeにレイアウトを描画させてみました。
なお、emptyレイアウトを指定しているのは、レイアウトが重複して描画されないようにするためです。レイアウトをfalseに設定するのも誤りではありませんが、View.beforeLayout/View.afterLayoutイベントが実行されなくなってしまうので、空のレイアウトを指定する方がよいと思います。
また、@extendsディレクティブの第一引数にはCakePHPが理解できるパスを渡してあげてください。LaravelのFileViewFinderの代わりにCakePHPのViewにファイルを探させているからです。
さて、トップページを開いてみましょう。ちゃんとページが表示されるはずです。
ところで、Bladeの@sectionディレクティブの中身を取得するには、対応する@yieldディレクティブが必要なはずですがレイアウト側は修正していません。なぜちゃんとコンテンツが表示されているのでしょう?
その答えは、BladeViewではfetchメソッドと@yieldディレクティブが等価になっているからです。仮に一部のビューをBladeに移行したとしてもレイアウトまで修正する必要はありません。段階的にビューをBladeに移行した後、最後にレイアウトを移行することもできます。
Eloquentモデル
CakePHPのモデルの代わりにEloquentモデルを使用することもできます。
artsianコマンドからモデルを作成します。対応するCakePHPのモデルと同じ名前にしてください。
php artisan make:model Content
app/Models/Content.phpが作成されるはずです。なお、Laravel 6.xをインストールした場合、Eloquentモデルのファイルはapp/Content.phpに作成されます。
さて、作成されたばかりのこのEloquentモデルをCakePHPのモデルと連携できるようにします。継承するクラスの名前空間をIlluminateからOstoandelに変更してください。
namespace App\Models;
...
use Ostoandel\Database\Eloquent\Model;
class Content extends Model
{
...
}
これでEloquentモデルがCakePHPのモデルと連携できるようになりました。早速どこかで使ってみましょう。
モデルの取得
ContentsControllerの_createAdminIndexConditionsByTableメソッドに以下のような処理があります。
$content = $this->Content->find('first', ['fields' => ['lft', 'rght'], 'conditions' => ['Content.id' => $data['Content']['folder_id']], 'recursive' => -1]);
$conditions['Content.rght <'] = $content['Content']['rght'];
$conditions['Content.lft >'] = $content['Content']['lft'];
コンテンツ一覧の表形式表示時に絞り込み検索で使われるクエリーのようです。recursiveに-1を指定しているので、Contentモデルだけを取得する単純なクエリーですね。
実行されるクエリーは以下の通りです。
SELECT `Content`.`lft`, `Content`.`rght` FROM `cms`.`mysite_contents` AS `Content` WHERE `Content`.`id` = 6 AND `Content`.`deleted` = '0' LIMIT 1
これをEloquentモデルを使用して書き換えると下のようになります。
$content = \App\Models\Content::where('id', $data['Content']['folder_id'])->first(['lft', 'rght']);
$conditions['Content.rght <'] = $content->rght;
$conditions['Content.lft >'] = $content->lft;
ちょっとすっきりしました。返値も配列からオブジェクトになったので、オブジェクト演算子も使用できるようになりました。
実行されたクエリーは以下の通りです。
select `lft`, `rght` from `mysite_contents` where `id` = '6' and `deleted` = 0 limit 1
ところで、idを指定しただけの単純な命令だったはずですが、実行されたクエリーを見てみると追加の条件としてdeletedが勝手に指定されているのがわかると思います。これはContentモデルがSoftDeleteビヘイビアーを使用しているためです。
でも、CakePHPのモデルはともかく、Eloquentモデルが実行したクエリーでもdeletedが指定されているのはちょっと不思議じゃないでしょうか? LaravelにもSoftDeletesトレイトというのが存在しますが、今回は使用していません。
実はEloquentモデルがクエリーを実行する際にModel.beforeFindイベントを発生させていて、それをSoftDeleteビヘイビアーが捕まえて、Eloquentモデルによって構築されたクエリーを書き換えているからなのです。
アソシエーションの取得
アソシエーションを取得したい場合はどうでしょう。
PagesControllerのadmin_editアクションに以下のようなクエリーがあります。
$this->Page->recursive = 2;
$this->request->data = $this->Page->read(null, $id);
CakePHP2でも仮にContainableBehaviorを使用していれば以下のように書けそうですね。
$this->Page->contain('Content.User');
$this->request->data = $this->Page->read(null, $id);
Eloquentモデルではクエリービルダーのwithメソッドを使用すると同じように書くことができます。
Contentモデルの時と同じ手順でPageのEloquentモデルを作成します。
php artisan make:model Page
また、UserモデルはLaravelが既定で提供しているモデルが存在するためmake:modelコマンドがエラーになりますので、既存のファイルの名前を変更するか、--forceオプションをつけて上書きしましょう。
php artisan make:model --force User
継承するクラスの名前空間をOstoandelに変更するのも忘れないでください。
Eloquentモデルを修正したら、admin_editアクションを以下のように修正します。
$this->request->data = \App\Models\Page::with('Content.User')->find($id)->toCakeArray();
Laravel側ではアソシエーションは定義する必要はありません。CakePHPのモデルの設定からアソシエーションを判断しています。
モデルの保存
UsersControllerのadmin_addメソッドに以下のような保存処理があります。
$this->User->create($this->request->data);
if ($this->User->save()) {
$this->request->data['User']['id'] = $this->User->id;
...
} else {
...
}
これをEloquentモデルを使用するように変更してみましょう。以下のように書き直します。
$user = new \App\Models\User();
$user->guard(['id']);
$user->fill($this->request->data['User']);
if ($user->save()) {
$this->request->data['User']['id'] = $user->id;
...
} else {
...
}
こんな感じになります。
さて、ユーザー登録を行ってみましょう。
データベースを覗いてみるとちゃんと保存されているようですが、パスワードがハッシュ化されていますね。いつの間にハッシュ化されたのでしょう?
実はOstoandelが提供するEloquentモデルのsaveメソッドはCakePHPのモデルのラッパーに過ぎません。
Model.beforeSaveやModel.afterSaveを捕まえて何かをするとうのはありそうなことなので、Eloquentモデルから保存を行った場合にも同じように動作するようにしておきました。
モデルの削除
モデルの削除も保存と同様にCakePHPのモデルのラッパーとしてふるまいます。
PagesControllerのadmin_deleteアクションでは、Pageモデルのdeleteを呼んでいます。
class PagesController extends AppController
{
...
public function admin_delete()
{
if (empty($this->request->data['entityId'])) {
return false;
}
if ($this->Page->delete($this->request->data['entityId'])) {
return true;
}
return false;
}
...
}
このメソッドはModelのdeleteをオーバライドしていて、データベースからレコードを削除するのに加えて、テンプレートも削除しますが、これをEloquentモデルを使用して書き換えてみます。
class PagesController extends AppController
{
...
public function admin_delete()
{
$page = \App\Models\Page::find($this->request->data('entityId'));
return !$page || $page->delete();
}
...
}
こんな感じです。
ただ、実際に画面からコンテンツをゴミ箱に入れ、ゴミ箱を空にしてみたところ、削除時にパスが見つからなくてテンプレートは消えませんでした。ゴミ箱に入れた時点でparent_idが消えてしまうのが原因みたいです。でも、ちゃんとPageモデルのdeleteメソッドは呼ばれているはずです。
サービスコンテナー
Laravelの主要機能であるサービスコンテナーを使用して、コントローラーに依存性の注入を行ってみましょう。CakePHPでも4.2から試験的に導入されている機能ですね。
もっとも、実はCakePHP2にも似たような機能ならすでにあります。
CakePHPでは使用するコントローラーで使用したいモデルはusesプロパティ―にクラス名を書くだけで、UserモデルやUserGroupモデルをコントローラーのプロパティ―として使用できるようになると思います。
class UsersController extends AppController
{
...
public $uses = ['User', 'UserGroup'];
...
public function admin_edit($id)
{
...
if (empty($this->request->data)) {
$this->request->data = $this->User->read(null, $id);
} else {
...
}
...
}
}
ところで、このモデルたちはどこでどのように作られたのでしょう。というか、何気なく使っていますが、本当にそれぞれUserクラス、UserGroupクラスのインスタンスなんでしょうか?
実際のところ、コントローラーは自分が使用するモデルたちがどこでどのように作られたのかなんて気にしません。それはClassRegistryの領分だからです。もちろん通常はUserモデルはUserクラスのインスタンスになるはずですが、すでにその派生クラスがClassRegistryに格納されていればそれが使用されます。ユニットテストを実行した場合には、ClassRegistryはテスト用のデータベースを参照する設定でモデルをインスタンス化してくれますよね。
LaravelのサービスコンテナーはそんなClassRegistryの上位互換です。Laravelのサービスコンテナーを使用すると、たとえばモデルのインスタンスをプロパティ―ではなく、メソッドの引数として取得することができます。
先ほどのアクションをLaravelのサービスコンテナーを使用して書き直すとこうなります。
class UsersController extends AppController
{
...
public function admin_edit(User $userRepository, $id)
{
...
if (empty($this->request->data)) {
$this->request->data = $userRepository->read(null, $id);
...
} else {
...
}
...
}
...
}
Userクラスのインスタンス化の方法はサービスプロバイダーで指定します。指定しなくても勝手にインスタンス化されますが、モデルを勝手にインスタンス化されるとユニットテストで困りますので、とりあえずClassRegistryでも使っておきましょう。
namespace App\Providers;
class AppServiceProvider extends \Illuminate\Support\ServiceProvider\ServiceProvider
{
...
public function register()
{
$this->app->singleton('User', function() {
return \ClassRegistry::init('User');
});
}
...
}
だいぶLaravelっぽいコードになってきましたね。
リクエスト
リクエストはCakePHPではCakeRequestを使用します。CakeRequestはルーティング、コントローラー、コンポーネント、ビュー、ヘルパーなど、アプリケーションの多くの層で使用されているため、簡単にLaravelに移行することは難しいでしょう。たとえば、フォームヘルパーに初期値を表示させるためにはCakeRequestのdataプロパティ―に値を設定する必要があります。
ただ、参照のみであればLaravelのRequestを使用することができます。
SiteConfigsControllerのadmin_check_sendmailアクションは下のような処理になっています。
class SiteConfigsController extends AppController
{
...
public function admin_check_sendmail()
{
if (empty($this->request->data['SiteConfig'])) {
$this->ajaxError(500, __d('baser', 'データが送信できませんでした。'));
}
$this->siteConfigs = $this->request->data['SiteConfig'];
...
}
...
}
LaravelのRequestを使用して書き直すと下のようになります。
use Illuminate\Http\Request;
...
class SiteConfigsController extends AppController
{
...
public function admin_check_sendmail(Request $request)
{
$data = data_get($request->post(), 'data.SiteConfig');
if (!$data) {
$this->ajaxError(500, __d('baser', 'データが送信できませんでした。'));
}
$this->siteConfigs = $data;
...
}
...
}
レスポンス
レスポンスにはCakePHPではCakeResponseを使用します。CakeResponseもCakeRequest同様に多くの層で利用されているため、やはりすぐにLaravelのResponseに置き換えるのは困難かもしれません。
部分的に導入することから始めましょう。たとえば、UsersControllerのログアウト処理は以下のようになっています。
class UsersController extends AppController
{
...
public function admin_logout()
{
...
$this->redirect($redirect);
}
...
}
ControllerのredirectメソッドはCakeResponseがレスポンスを送信し、既定ではexitまで呼び出しますが、それだとLaravelのミドルウェアのレスポンス処理の部分が動作しません。
このリダイレクトは以下のように書き直すことができます。
class UsersController extends AppController
{
...
public function admin_logout()
{
...
$this->autoRender = false;
return redirect($redirect);
}
...
}
代わりにLaravelのredirectヘルパーを使用しました。レスポンスを返却させるためにreturnも使用しています。また、テンプレートを描画されては困りますのでautoRenderはfalseに指定しています。
ただし、この方法だとController.beforeRedirectイベントが発生しません。アプリケーションがこのイベントを必要としている場合には動作しません。
代わりにLaravelResponseトレイトを使用する方法もあります。
use Ostoandel\Traits\LaravelResponse;
...
class AppController extends BcAppController
{
use LaravelResponse;
...
}
LaravelResponseトレイトは、exitを実行する代わりにHttpResponseExceptionを投げます。
こうしておくことで、Controller.beforeRedirectイベントは残しつつ、ミドルウェアもレスポンスを捕まえることができるようになるはずです。
ミドルウェア
ミドルウェアを使用することもできます。CakePHPでも3.4から導入されている仕組みです。
CakePHP2の概念ではディスパッチャーフィルターに該当します。
なお、Laravelではコントローラーでもミドルウェアの指定ができますが、Ostoandelではルーティングでのみサポートしています。コントローラーでもサポートは可能なのですが、getMiddlewareメソッドを定義するとCakePHPではアクションになってしまうのでやめておきました。
CSRF防御
VerifyCsrfTokenミドルウェアをSecurityComponentが提供するCSRF防御の代わりに使用してみましょう。
まずはVerifyCsrfTokenを有効化します。
namespace App\Http;
...
class Kernel extends HttpKernel
{
...
protected $middlewareGroups = [
'web' => [
...
\App\Http\Middleware\VerifyCsrfToken::class,
...
],
...
];
...
}
次にSecurityComponentのCSRF防御を無効にします。
class AppController extends BcAppController {
public function beforeFilter()
{
parent::beforeFilter();
$this->Security->csrfCheck = false;
}
}
さて、この状態でCSRF防御が有効になっているか確認してみましょう。
試しにログイン画面からログインを行ってみると419エラーが返されます。CakePHPのFormHelperが描画したフォームが、LaravelのCSRFトークンを送信できていないからですね。ちゃんと防御できているようです。
では、FormHelperがLaravelのCSRFトークンを送信できるようにしましょう。
まず、SecrityComponentが生成したCSRFトークンをBcAppControllerのbeforeRenderメソッド中でLaravelのものに置き換えます。
class BcAppController extends Controller
{
...
public function beforeRender()
{
$this->request->params['_Token']['key'] = $this->getToken();
parent::beforeRender();
...
}
...
protected function getToken()
{
return csrf_token();
}
...
}
次に、VerifyCsrfTokenミドルウェアを修正してFormHelperが送信するトークンを受け取れるようにします。
namespace App\Http\Middleware;
...
class VerifyCsrfToken extends Middleware
{
...
protected function getTokenFromRequest($request)
{
$token = $request->input('data._Token.key') ?? $request->input('_Token.key');
if ($token) {
$request->merge(['_token' => $token]);
}
return parent::getTokenFromRequest($request);
}
}
これでCSRF防御を突破できるようになったはずです。
なお、baserCMSではSecurityComponentのcsrfUseOnceオプションが有効になっており、リクエスト毎に毎回新しいトークンを取得し直すためにjquery.bcToken.jsという独自のライブラリーを使用しているようです。
ただ、LaravelにはcsrfUseOnceと同等のオプションはないため、この変更によりCSRFトークンはセッション毎の固定になります。トークンを取得し直しても同じ値が返されるだけになっていますが、安全上は問題ありません。OWSAPでもCSRFトークンはセッション毎に発行すればよいことになっています。
Cookie
CakePHPではCookieの読み書きはCookieComponentを使用して行います。Cookieの暗号化・複合化を行うのもCookieComponentの仕事でした。
LaravelではEncryptCookiesミドルウェアが暗号化・複合化を行います。CakePHPでも3.4以降はEncryptedCookieミドルウェアに置き換わっていますので、いずれの道に進むにしてもCookieComponentとはこの辺で別れを告げる必要がありそうです。
まずはEncryptCookiesミドルウェアを有効化します。
namespace App\Http;
...
class Kernel extends HttpKernel
{
...
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
...
],
...
];
...
}
次に、Cookieの暗号化を例外を設定します。CookieComponentを使用している箇所が残っている場合、二重に暗号化されてしまうためです。
namespace App\Http\Middleware;
...
class EncryptCookies extends Middleware
{
...
protected $except = [
'CakeCookie',
];
}
Cookieを書き込む場合はResponseクラスを使用できればよいのですが、すぐには難しいかもしれません。
代わりにCookieファサードのqueueメソッド使用して書き込むとよいでしょう。AddQueuedCookiesToResponseミドルウェアが後でResponseにCookieを追加してくれます。
実例を見てみましょう。UsersControllerでCookieComponentを使用している箇所があります。
class UsersController
{
...
public function admin_login()
{
...
$this->Cookie->destroy();
...
}
,..
public function setAuthCookie($data)
{
...
$this->Cookie->httpOnly = true;
$this->Cookie->write(Inflector::camelize(str_replace('.', '', BcAuthComponent::$sessionKey)), $cookie, true, '+2 weeks'); // 3つめの'true'で暗号化
}
...
public function admin_logout()
{
...
$this->Cookie->delete(Inflector::camelize(str_replace('.', '', BcAuthComponent::$sessionKey)));
...
}
}
Cookieファサードを使用して書き直すと下のようになります。
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cookie;
...
class UsersController
{
...
public function admin_login(Request $request)
{
...
$cookieKey = Inflector::camelize(str_replace('.', '', BcAuthComponent::$sessionKey));
Cookie::expire($cookieKey);
...
}
...
public function setAuthCookie($data)
{
...
$cookieKey = Inflector::camelize(str_replace('.', '', BcAuthComponent::$sessionKey));
Cookie::queue($cookieKey, json_encode($cookie), strtotime('+2 weeks') / 60);
}
...
public function admin_logout()
{
...
$cookieKey = Inflector::camelize(str_replace('.', '', BcAuthComponent::$sessionKey));
Cookie::expire($cookieKey);
...
}
...
}
LaravelではCookieを暗号化している場合、値に配列を使用することができないため、json_encodeを呼んでいます。
また、リダイレクト時にResponseにCookieが書き込まれるようにLaravelResponseトレイトも使用する必要があります。もし使用していなければレスポンスの節を参照して使用してください。
Cookieを読み出す場合にはRequestクラスのcookieメソッドを使用します。
上で書き込んだCookieはBcAuthConfigureComponentで使用されているようです。
$cookie = $Controller->Cookie->read($cookieKey);
CookieComponentの代わりにRequestクラスを使用するように書き直してみましょう。今回は書き込む時にjson_encodeを呼んでいたので、読み出す時にはjson_encodeを呼ぶ必要があります。
$cookie = json_decode(request()->cookie($cookieKey), true);
これでCookieComponentを使用せずに同じ動作をするようになるはずです。
セッション
セッションについてはLaravelとCakePHPがそれぞれ管理しています。
一元化するにはconfig/app.phpでOstoandel版のCakeSessionを使用するように設定します。
Ostoandel版のCakeSessionはLaravelのSessionファサードのラッパーになっています。
return [
...
'aliases' => [
...
'CakeSession' => Ostoandel\Fake\CakeSession::class,
...
],
];
ただ、baserCMSではこれだけでは動作しません。理由は以下の3点です。
- $_SESSIONを使用している箇所がある
- session_idなどPHPのセッション系関数を使用している箇所がある
- exitを使用している箇所がある
LaravelのセッションはPHPのセッションの仕組みを使わず、StartSessionミドルウェアによって独自に実装されています。PHPのセッションはsession_startによって開始され、プログラムの終了時に保存されますが、このふるまいはミドルウェアの概念とは相容れません。
StartSessionミドルウェアではリクエスト処理前にセッションを開始し、リクエスト処理後に保存します。PHPのセッションの仕組みは用しませんので、session_startは呼ばれず、$_SESSIONも定義されず、session_register_shutdownも登録しませんので、プログラム終了時にセッションが保存されることもありません。
したがって、$_SESSIONを使用していたり、session_idなどPHPのセッション系関数を使用していたり、exitを使用していたりすると、期待通りに動作しません。
Ostodandelでは、こうした場合のためにcakeセッションドライバーを用意しています。このドライバーはPHPのセッションの仕組みを利用します。つまり、$_SESSIONや、session_idなどのPHPのセッション系関数を使用することができ、またexitが呼ばれたタイミングでセッションを保存します。
cakeセッションドライバーを利用するには.envでSESSION_DRIVERにcakeを指定します。
SESSION_DRIVER=cake
SESSION_COOKIE=BASERCMS
SESSION_COOKIEにはBASERCMSを指定しました。EncryptCookiesミドルウェアを使用している場合、このCookieは暗号化から除外する必要があります。
namespace App\Http\Middleware;
...
class EncryptCookies extends Middleware
{
...
protected $except = [
'CakeCookie',
'BASERCMS',
];
}
baserCMSではミドルウェアが実行される前にすでにセッションを開始しているからです。
認証
CakePHPのAuthComponentの代わりにLaravelのAuthenticateミドルウェアを使用することもできます。Laravelのルーティングを使用しますので、先にルーティングの節もご一読ください。
さて、まずはログインとログアウトをLaravelに任せるところまでをやってみます。
既定ではデータベースのパスワードはBlowfishPasswordHasherでハッシュ化されている必要がありますが、baserCMSではSimplePasswordHasherでハッシュ化しているようなのでAppServiceProviderでEloquentUserProviderにCakeHasherを渡すようにします。
namespace App\Providers;
...
use Illuminate\Auth\EloquentUserProvider;
use Ostoandel\Hashing\CakeHasher;
...
class AppServiceProvider extends ServiceProvider
{
public function boot()
{
Auth::provider('eloquent', function($app, $config) {
return new EloquentUserProvider(new CakeHasher(), $config['model']);
});
...
}
}
次にEloquent版のUserモデルでAuthenticatableインターフェイスを実装します。対応するトレイトが存在するので、それを利用するのが簡単です。
namespace App\Models;
...
use Ostoandel\Database\Eloquent\Model;
class User extends Model implements \Illuminate\Contracts\Auth\Authenticatable
{
use \Illuminate\Auth\Authenticatable;
...
}
次にBcAuthComponentのlogin/logoutメソッドの代わりに、LaravelのAuthファサードのattempt/logoutメソッドを使用するようにUsersControllerを修正します。
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
class UsersController extends AppController
{
...
public function admin_login_exec()
{
...
$credentials = Arr::only($this->request->data['User'], ['name', 'password']);
if (Auth::attempt($credentials)) {
return true;
}
...
}
...
public function admin_login()
{
...
if ($this->request->data) {
$credentials = Arr::only($this->request->data['User'], ['name', 'password']);
Auth::attempt($credentials);
...
}
}
...
public function admin_logout()
{
...
Auth::logout();
$logoutRedirect = Router::normalize($this->BcAuth->logoutRedirect);
...
}
...
}
なお、BcAuthConfigureComponentでも呼ばれているようなので、そちらも修正する必要があります。
さて、これだけではCakePHP側に認証状態が伝わりませんので、Laravelのイベントを捕まえて、CakePHP側にも認証状態を伝えましょう。
app/Providers/EventServiceProvider.phpを開いてイベントリスナーを登録します。
namespace App\Providers;
...
...
class EventServiceProvider extends ServiceProvider
{
...
protected $listen = [
...
\Illuminate\Auth\Events\Login::class => [
\App\Listeners\LoginListener::class,
],
\Illuminate\Auth\Events\Logout::class => [
\App\Listeners\LogoutListener::class,
],
];
...
}
まだイベントリスナーのクラスが存在しませんので、artisanコマンドで作成しましょう。
php artisan event:generate
app/Listenersの下にイベントリスナークラスが作成されますので、まずはLoginListenerを修正します。
namespace App\Listeners;
...
use Illuminate\Auth\Events\Login;
use Illuminate\Support\Facades\Session;
...
class LoginListener
{
...
public function handle(Login $event)
{
Session::put(\BcAuthComponent::$sessionKey, $event->user->load(['UserGroup', 'Favorite'])->toArray());
}
}
baserCMSでは認証情報にUserGroupとFavoriteも必要なようでしたので、Loginイベント中でセットしました。
UserGroupとFavoriteのEloquentモデルが必要ですので、artisanコマンドで作成します。
php artisan make:model UserGroup
php artisan make:model Favorite
継承するクラスの名前空間をOstoandelに変更するのも忘れないでください。
次にLogoutListenerを修正します。
namespace Appf\Listeners;
...
use Illuminate\Auth\Events\Logout;
use Illuminate\Support\Facades\Session;
...
class LoginListener
{
...
public function handle(Logout $event)
{
Session::forget(\BcAuthComponent::$sessionKey);
}
}
これでAuthファサードを使用してログイン・ログアウトができるようになったはずです。
続いてAuthenticateミドルウェアを使用するように変更します。簡単そうなDashboardControllerを例にします。
namespace Baser\Controller;
use AppController;
use BcUtil;
use CakePlugin;
...
class DashboardController extends AppController
{
...
public $name = 'Dashboard';
...
public function beforeFilter()
{
parent::beforeFilter();
$this->BcAuth->allow();
}
...
}
Authenticateミドルウェアによって守られていることを確かめるために、この例ではbeforeFilterメソッドをオーバーライドしてBcAuthComponentによるアクセス制限をなくしました。
また、DashboardControllerを名前空間に入れたことにも注意してください。名前空間に入れるのは必須というわけではありませんが、入れた方が安全です。
理由としては、これまで見てきたミドルウェアは原則すべてのアクションに適用させるものでしたが、Authenticateミドルウェアの場合には、認証を必要とするアクションと必要しないアクションがあるはずです。そして、認証を必要とするアクションに対して、万が一ミドルウェアが適用されないとアプリケーションを危険にさらしてしまいます。
名前空間に入れてしまえば、CakePHPのルーターでは接続できなくなります。したがって、意図せず既定のルーティングにフォールバックしてしまって、ミドルウェアが適用されずにアクションが呼び出されてしまう危険性をなくせるのです。
さて、routes/web.phpを開いてAuthentcateミドルウェアをDashboardControllerに適用してみましょう。
Route::prefix('admin')->namespace('\Baser\Controller')->middleware('auth')->group(function() {
Route::any('/', 'DashboardController@admin_index');
});
Route::fallbackToCake();
最後に、app/Http/Middleware/Authenticate.phpを開いてリダイレクト先を修正します。
namespace App\Http\Middleware;
class Authenticate extends Middleware
{
...
protected function redirectTo($request)
{
if (! $request->expectsJson()) {
return \Router::url(['controller' => 'users', 'action' => 'login', 'admin' => true]);
}
}
}
では、ダッシュボードにアクセスしてみましょう。ログイン画面にリダイレクトされるはずです。
おわりに
いかがでしたか? CakePHP2をCakePHP3以降にアップグレードしようとすると壮大な計画を立てる必要があります。でも、Laravelに移行するなら5分後にはもう新機能を利用できるなんてワクワクしませんか?
もっとも、baserCMSはすでにCakePHP4へのアップグレードの準備を着々と進められているようで、Ostoandelがお役に立てる機会はないかもしれませんが、もしCakePHP2からCakePHP3以降へのアップグレードに苦労されている方がいらっしゃいましたら、ぜひ本稿を参考に開発環境などで試験的に導入いただいてフィードバックをいただければ嬉しいです。
でも、ひょっとしたら、baserCMSがLaravelで作られたCMSとして生まれ変わり、マスコットキャラクターのべっしー君も、ららべっしー君へと進化する日が来たりして。