チュートリアル: INVO

この第2のチュートリアルでは、より完全なアプリケーションを例にして説明し、Phalconを使用した開発について理解を深めます。 INVOは、私達が制作したサンプルアプリケーションの1つです。 INVOは小さなWebサイトで、ユーザーは請求書を作成したり、顧客や製品を管理したりといったタスクを行うことができます。 コードは Github からcloneすることができます。

INVOはクライアントサイドフレームワークである Bootstrap を使用して作られています。 アプリケーションは実際の請求書を生成しませんが、フレームワークの働きを理解するサンプルにはなります。

プロジェクトの構成

ドキュメントルートでプロジェクトをcloneすると、次のような構造が表示されます:

invo/
    app/
        config/
        controllers/
        forms/
        library/
        logs/
        models/
        plugins/
        views/
    cache/
        volt/
    docs/
    public/
        css/
        fonts/
        js/
    schemas/

ご存知のように、Phalconはアプリケーション開発において特定のファイル構造を強制しません。 このプロジェクトには、単純なMVC構造とパブリックなドキュメントルートがあります。

ブラウザで http://localhost/invo にアクセスしてアプリケーションを開くと、以下のように表示されるでしょう:

アプリケーションは2つの部分に分かれています: フロント/バックエンド。 フロントエンドは公開されている部分で、訪問者はINVOの概要を知ったり、連絡先情報をリクエストする事ができます。 バックエンドは管理用の領域で、登録ユーザーが製品や顧客情報の管理ができます。

ルーティング

INVOはRouterコンポーネントに組み込みの標準的なルートを使用します。 これらのルートは、 /:controller/:action/:params というパターンにマッチします。 これは、URIの最初の部分がコントローラー、2番めの部分がアクション、残りがパラメーターになる、ということを意味しています。

/session/register というルートでは、SessionController コントローラの registerAction アクションが実行されます。

設定

INVOにはアプリケーション内で、一般的なパラメーターをセットする設定ファイルがあります。 このファイルは app/config/config.ini にあり、アプリケーションのブートストラップ (public/index.php) の最初の数行で読み込まれています:

<?php

use Phalcon\Config\Adapter\Ini as ConfigIni;

// ...

// 設定の読み込み
$config = new ConfigIni(
    APP_PATH . 'app/config/config.ini'
);

Phalcon Config (Phalcon\Config) を使うと、オブジェクト指向のやり方でファイルの操作を可能にします。 この例では、設定にiniファイルを使用していますが、Phalconは他のファイルタイプに対してもアダプターを持っています。 構成ファイルには、次の設定が含まれています:

[database]
host     = localhost
username = root
password = secret
name     = invo

[application]
controllersDir = app/controllers/
modelsDir      = app/models/
viewsDir       = app/views/
pluginsDir     = app/plugins/
formsDir       = app/forms/
libraryDir     = app/library/
baseUri        = /invo/

Phalconには、あらかじめ定義された設定規則はありません。 セクションは必要に応じてオプションを整理するのに役立ちます。 このファイルには、2つのセクションが使用されます: applicationdatabase

オートローダー

ブートストラップ(public/index.php)に記述されている2番目の部分はオートローダーです:

<?php

/**
 * オートローダーの設定
 */
require APP_PATH . 'app/config/loader.php';

オートローダーは、アプリケーションが最終的に必要とするクラスを探すディレクトリーのセットを登録します。

<?php

$loader = new Phalcon\Loader();

// 設定ファイルからディレクトリの設定を取得して登録
$loader->registerDirs(
    [
        APP_PATH . $config->application->controllersDir,
        APP_PATH . $config->application->pluginsDir,
        APP_PATH . $config->application->libraryDir,
        APP_PATH . $config->application->modelsDir,
        APP_PATH . $config->application->formsDir,
    ]
);

$loader->register();

上記のコードは、設定ファイルで定義されたディレクトリを登録していることに注意してください。 viewsDirにはHTMLファイルとPHPファイルが含まれますが、クラスは含まれていないためviewsDirディレクトリだけは登録しません。 また、APP_PATHという定数を使っていることに注意してください。 この定数はブートストラップ(public/index.php)で定義されているもので、プロジェクトのルートパスを参照することができます。

<?php

// ...

define(
    'APP_PATH',
    realpath('..') . '/'
);

サービスの登録

ブートストラップに必要な別のファイルは (app/config/services.php)です。 このファイルを使用すると、INVOが使用するサービスを整理できます。

<?php

/**
 * アプリケーションのサービスを登録
 */
require APP_PATH . 'app/config/services.php';

サービスの登録には、必要なコンポーネントを遅延ロードするために、クロージャで実装されています。

<?php

use Phalcon\Mvc\Url as UrlProvider;

// ...

/**
 * URLコンポーネントはアプリケーションにおける全てのURLを生成するに使われる
 */
$di->set(
    'url',
    function () use ($config) {
        $url = new UrlProvider();

        $url->setBaseUri(
            $config->application->baseUri
        );

        return $url;
    }
);

後でこのファイルについてより詳しく説明します。

リクエストの処理

ファイル(public/index.php)の最後では、リクエストは最終的にPhalcon\Mvc\Applicationで処理されています。このクラスは、アプリケーションの実行に必要な全ての初期化と処理の実行を行います:

<?php

use Phalcon\Mvc\Application;

// ...

$application = new Application($di);

$response = $application->handle();

$response->send();

依存性の注入

上記コード例の1行目を見てください。 Application クラスのコンストラクタは、$di 変数を引数として受け取っています。 この変数の目的は何でしょう? Phalconは高度に分割されたフレームワークなので、全てが協調して動作するための接着剤の役割を果たすコンポーネントが必要です。 そのコンポーネントは、Phalcon\Di です。 これはサービスコンテナで、依存性の注入(Dependency Injection)や、アプリケーションに必要なコンポーネントの初期化も実行します。

コンテナにサービスを登録するには、様々な方法があります。 INVOでは、ほとんどのサービスは無名関数/クロージャーを使って登録されています。 このおかげで、オブジェクトは必要になるまでインスタンス化されないので、アプリケーションに必要なリソースが節約できます。

例えば、以下の抜粋では、セッションサービスが登録されています。 この無名関数は、アプリケーションがセッションデータにアクセスする必要がある場合にのみ呼び出されます:

<?php

use Phalcon\Session\Adapter\Files as Session;

// ...

// コンポーネントがsessionサービスを最初に要求した時にセッションを開始する
$di->set(
    'session',
    function () {
        $session = new Session();

        $session->start();

        return $session;
    }
);

これでアダプタを変更して、初期化処理を追加する等が自由に行えるようになりました。 サービスは “session” という名前で登録されていることに注意してください。 これは、フレームワークがサービスコンテナ内のアクティブなサービスを識別できるようにする規約です。

リクエストは多数のサービスを利用する可能性があり、それらを1つずつ登録するのは面倒な作業です。 そのため、Phalconは Phalcon\Di\FactoryDefault という Phalcon\Di の別バージョンを用意しています。

<?php

use Phalcon\Di\FactoryDefault;

// ...

// FactoryDefault Dependency Injectorは、フルスタックフレームワークを提供するのに
// 最適なサービスを、自動的に登録します
$di = new FactoryDefault();

FactoryDefault はフレームワークが標準的に提供しているコンポーネントサービスのほぼ全てを登録します。 サービスの定義をオーバーライドする必要がある場合は、上記のようにsessionurlを再設定することができます。 以上が、$di 変数が存在する理由です。

アプリケーションへのログイン

ログイン機能によって、私たちはバックエンドコントローラの作業に取りかかることができます。 バックエンドとフロントエンドのコントローラーの分割は、あくまで論理上のものです。 全てのコントローラーは、同じディレクトリ (app/controllers/) に含まれています。

システムに入るには、有効なユーザー名とパスワードが必要です。 ユーザー情報はデータベースinvoのテーブルusersに格納されます。

セッションを開始する前に、アプリケーションがデータベースに接続できるよう設定する必要があります。 接続情報を持った db という名前のサービスが、サービスコンテナ内で用意されます。 オートローダーと同様、サービスを設定するための情報は設定ファイルから取得します:

<?php

use Phalcon\Db\Adapter\Pdo\Mysql as DbAdapter;

// ...

// 設定ファイルに定義されたパラメーターに基いてデータベース接続が作成される
$di->set(
    'db',
    function () use ($config) {
        return new DbAdapter(
            [
                'host'     => $config->database->host,
                'username' => $config->database->username,
                'password' => $config->database->password,
                'dbname'   => $config->database->name,
            ]
        );
    }
);

ここでは、MySQLアダプタのインスタンスを返します。 必要に応じて、ロガーやプロファイラの追加、アダプタの変更などの追加の設定を行うことができます。

次の簡単なフォーム (app/views/session/index.volt) はログイン情報を要求します。 サンプルをより簡潔にするために、いくつかのHTMLコードを削除しました。

{{ form('session/start') }}
    <fieldset>
        <div>
            <label for='email'>
                Username/Email
            </label>

            <div>
                {{ text_field('email') }}
            </div>
        </div>

        <div>
            <label for='password'>
                Password
            </label>

            <div>
                {{ password_field('password') }}
            </div>
        </div>

        <div>
            {{ submit_button('Login') }}
        </div>
    </fieldset>
{{ endForm() }}

以前のチュートリアルでは生のPHPを使用する代わりに、Voltを使ってチュートリアルを始めました。 これは、Jinja_に触発された組み込みのテンプレートエンジンで、テンプレートを作成するためのよりシンプルで使いやすい構文を提供します。 Voltに精通するのに時間はかかりません。

SessionController::startAction関数 (app/controllers/SessionController.php) が、フォームに入力されたデータのバリデーションを行います。これには、データベース内の有効なユーザーかの確認も含まれます:

<?php

class SessionController extends ControllerBase
{
    // ...

    private function _registerSession($user)
    {
        $this->session->set(
            'auth',
            [
                'id'   => $user->id,
                'name' => $user->name,
            ]
        );
    }

    /**
     * このアクションはユーザーを認証しアプリケーションにログインさせる
     */
    public function startAction()
    {
        if ($this->request->isPost()) {
            // POSTで送信された変数を受け取る
            $email    = $this->request->getPost('email');
            $password = $this->request->getPost('password');

            // データベースからユーザーを検索
            $user = Users::findFirst(
                [
                    "(email = :email: OR username = :email:) AND password = :password: AND active = 'Y'",
                    'bind' => [
                        'email'    => $email,
                        'password' => sha1($password),
                    ]
                ]
            );

            if ($user !== false) {
                $this->_registerSession($user);

                $this->flash->success(
                    'Welcome ' . $user->name
                );

                // ユーザーが有効なら、'invoices' コントローラーに転送する
                return $this->dispatcher->forward(
                    [
                        'controller' => 'invoices',
                        'action'     => 'index',
                    ]
                );
            }

            $this->flash->error(
                'Wrong email/password'
            );
        }

        // ログインフォームへ再度転送
        return $this->dispatcher->forward(
            [
                'controller' => 'session',
                'action'     => 'index',
            ]
        );
    }
}

簡単にするため、 データベースに保存するパスワードハッシュにsha1を使用していますが、このアルゴリズムは実際のアプリケーションでは推奨されません。代わりにbcryptを使ってください。

コントローラー内で $this->flash$this->request$this->session のようなpublic属性へのアクセスに注目してください。 これらは、サービスコンテナであらかじめ定義したサービスです (app/config/services.php)。 初めてアクセスされると、コントローラの一部として注入されます。 これらのサービスは共有されているため、これらのオブジェクトをどこから呼び出しても、常に同じインスタンスにアクセスすることになります。 例えば、ここでsessionサービスを呼び出して、ユーザーを識別する情報をauthという変数に保存しています:

<?php

$this->session->set(
    'auth',
    [
        'id'   => $user->id,
        'name' => $user->name,
    ]
);

このセクションのもう1つの重要な側面は、ユーザーが有効なものとして検証される方法です。まず、リクエストがPOSTメソッドを使用して行われたかどうかを検証します:

<?php

if ($this->request->isPost()) {
    // ...
}

次に、フォームからパラメータを受け取ります:

<?php

$email    = $this->request->getPost('email');
$password = $this->request->getPost('password');

ここで、同じユーザー名または電子メールとパスワードを持つユーザーが1人いるかどうかを確認する必要があります。

<?php

$user = Users::findFirst(
    [
        "(email = :email: OR username = :email:) AND password = :password: AND active = 'Y'",
        'bind' => [
            'email'    => $email,
            'password' => sha1($password),
        ]
    ]
);

'バインドパラメータ'を使う事で、プレースホルダ:email::password:を値が存在すべき場所に設置する事で、パラメータbindの値が'バインド'されます。 これにより、SQLインジェクションのリスクがなくても、これらのカラムの値が安全に置き換えられます。

ユーザーが有効な場合、セッションに登録し、ダッシュボードに転送します:

<?php

if ($user !== false) {
    $this->_registerSession($user);

    $this->flash->success(
        'Welcome ' . $user->name
    );

    return $this->dispatcher->forward(
        [
            'controller' => 'invoices',
            'action'     => 'index',
        ]
    );
}

ユーザーが存在しない場合は、フォームが表示されているアクションにユーザーを再度戻します:

<?php

return $this->dispatcher->forward(
    [
        'controller' => 'session',
        'action'     => 'index',
    ]
);

バックエンドのセキュリティ保護

バックエンドは登録されたユーザーだけがアクセスできるプライベートな領域です。 したがって、登録されたユーザーだけがそれらのコントローラーにアクセスできるようチェックする必要があります。 たとえば、ログインせずに products コントローラー (プライベート領域) にアクセスしようとすると、以下のように表示されるはずです:

コントローラー・アクションにアクセスしようとしたときにはいつでも、アプリケーションは現在のロール (セッションに含まれる) が、アクセス権を持っているか確認します。アクセス権がない場合は、上のようなメッセージを表示し、インデックスページに遷移させます。

次に、アプリケーションがこの動きをどのように実現しているか見ていきましょう。 最初に知るべきは、Dispatcher コンポーネントです。 これは、Routingコンポーネントによって発見されたルートの情報を受け取ります。 次に、適切なコントローラーを読み込んで、対応するアクションのメソッドを実行します。

通常、フレームワークはディスパッチャを自動的に作成します。 今回は、要求されたアクションを実行する前に、認証を行い、ユーザーがアクセスできるか否かチェックする必要があります。 これを実現するため、ブートストラップの中に関数を用意して、ディスパッチャを置き換えています:

<?php

use Phalcon\Mvc\Dispatcher;

// ...

/**
 * MVCディスパッチャー
 */
$di->set(
    'dispatcher',
    function () {
        // ...

        $dispatcher = new Dispatcher();

        return $dispatcher;
    }
);

これで、アプリケーションで使用されるディスパッチャを完全に制御できるようになりました。 フレーワークの多くのコンポーネントはイベントを発火するので、内部の処理の流れを変更することができます。 DIコンポーネントが接着剤として機能し、EventsManagerがコンポーネントが生み出すイベントをインターセプトし、イベントをリスナーに通知します。

イベント管理

EventsManagerによって、特定のタイプのイベントにリスナーを割り当てることができます。 今、私達が取り組んでいるイベントのタイプは 'dispatch' です。 以下のコードは、ディスパッチャによって生成される全てのイベントをフィルタリングしています:

<?php

use Phalcon\Mvc\Dispatcher;
use Phalcon\Events\Manager as EventsManager;

$di->set(
    'dispatcher',
    function () {
        // イベントマネージャを作成する
        $eventsManager = new EventsManager();

        // Securityプラグインを使用して、ディスパッチャが生成するイベントを監視する
        $eventsManager->attach(
            'dispatch:beforeExecuteRoute',
            new SecurityPlugin()
        );

        // NotFoundPluginを使用して例外や未発見の例外を処理する
        $eventsManager->attach(
            'dispatch:beforeException',
            new NotFoundPlugin()
        );

        $dispatcher = new Dispatcher();

        // イベントマネージャーをディスパッチャにアサインする
        $dispatcher->setEventsManager($eventsManager);

        return $dispatcher;
    }
);

beforeExecuteRouteというイベントが発生すると、次のプラグインが通知されます。

<?php

/**
 * ユーザーがSecurityPluginを使用して特定のアクションにアクセスすることを許可されているかどうかを確認します
 */
$eventsManager->attach(
    'dispatch:beforeExecuteRoute',
    new SecurityPlugin()
);

beforeExceptionがトリガされると、他のプラグインに通知されます:

<?php

/**
 * NotFoundPluginを使用して例外や未発見の例外を処理する
 */
$eventsManager->attach(
    'dispatch:beforeException',
    new NotFoundPlugin()
);

SecurityPluginは (app/plugins/SecurityPlugin.php) にあるクラスです。 このクラスはbeforeExecuteRouteメソッドを実装しています。 これは、ディスパッチャーが生成するイベントの1つと同じ名前です:

<?php

use Phalcon\Events\Event;
use Phalcon\Mvc\User\Plugin;
use Phalcon\Mvc\Dispatcher;

class SecurityPlugin extends Plugin
{
    // ...

    public function beforeExecuteRoute(Event $event, Dispatcher $dispatcher)
    {
        // ...
    }
}

フックイベントは常に2つの引数を取ります。第1引数はイベントが生成されたコンテキストの情報($event) で、第2引数はイベントを生成したオブジェクト自身 ($dispatcher) です。 プラグインがPhalcon\Mvc\User\Pluginを継承することは必須ではありませんが、継承することでアプリケーションのサービスに簡単にアクセスできるようになります。

では、ACLリストを使ってユーザーのアクセス権限を確認し、現在のセッションでのロールを検証しましょう。 ユーザーがアクセスできない場合は、前述のようにホーム画面にリダイレクトされます。

<?php

use Phalcon\Acl;
use Phalcon\Events\Event;
use Phalcon\Mvc\User\Plugin;
use Phalcon\Mvc\Dispatcher;

class SecurityPlugin extends Plugin
{
    // ...

    public function beforeExecuteRoute(Event $event, Dispatcher $dispatcher)
    {
        // ロールを定義するため、セッションに'auth'変数があるかチェックする
        $auth = $this->session->get('auth');

        if (!$auth) {
            $role = 'Guests';
        } else {
            $role = 'Users';
        }

        // ディスパッチャからアクティブなコントローラー名とアクション名を取得する
        $controller = $dispatcher->getControllerName();
        $action     = $dispatcher->getActionName();

        // ACLリストを取得
        $acl = $this->getAcl();

        // ロールがコントローラー (又はリソース) にアクセス可能かチェックする
        $allowed = $acl->isAllowed($role, $controller, $action);

        if (!$allowed) {
            // アクセス権が無い場合、indexコントローラーに転送する
            $this->flash->error(
                "You don't have access to this module"
            );

            $dispatcher->forward(
                [
                    'controller' => 'index',
                    'action'     => 'index',
                ]
            );

            // 'false'を返し、ディスパッチャーに現在の処理を停止させる
            return false;
        }
    }
}

ACLリストの提供

上の例では、$this->getAcl()メソッドでACLを取得しました。 このメソッドもプラグインに実装されています。 ここでは、アクセス制御リスト (ACL) をどのように作ったか、ステップバイステップで解説します:

<?php

use Phalcon\Acl;
use Phalcon\Acl\Role;
use Phalcon\Acl\Adapter\Memory as AclList;

// ACLオブジェクトを作る
$acl = new AclList();

// デフォルトの挙動はDENY(拒否)
$acl->setDefaultAction(
    Acl::DENY
);

// 2つのロールを登録する
// ユーザーは登録済みユーザー、ゲストは未登録ユーザー
$roles = [
    'users'  => new Role('Users'),
    'guests' => new Role('Guests'),
];

foreach ($roles as $role) {
    $acl->addRole($role);
}

ここで、各領域のリソースをそれぞれ定義します。 コントローラ名はリソース名で、アクション名はリソースに対する操作です。

<?php

use Phalcon\Acl\Resource;

// ...

// プライベートエリアのリソース (バックエンド)
$privateResources = [
    'companies'    => ['index', 'search', 'new', 'edit', 'save', 'create', 'delete'],
    'products'     => ['index', 'search', 'new', 'edit', 'save', 'create', 'delete'],
    'producttypes' => ['index', 'search', 'new', 'edit', 'save', 'create', 'delete'],
    'invoices'     => ['index', 'profile'],
];

foreach ($privateResources as $resourceName => $actions) {
    $acl->addResource(
        new Resource($resourceName),
        $actions
    );
}

// 公開エリアのリソース (フロントエンド)
$publicResources = [
    'index'    => ['index'],
    'about'    => ['index'],
    'register' => ['index'],
    'errors'   => ['show404', 'show500'],
    'session'  => ['index', 'register', 'start', 'end'],
    'contact'  => ['index', 'send'],
];

foreach ($publicResources as $resourceName => $actions) {
    $acl->addResource(
        new Resource($resourceName),
        $actions
    );
}

いま、ACLは既存のコントローラーと関連するアクションの情報を知っている状態になっています。 Usersロールはバックエンドとフロントエンド双方の全てのリソースにアクセスできます。 Guestsロールは公開エリアにだけアクセスできます:

<?php

// 公開エリアのアクセス権をユーザーとゲストの双方に与える
foreach ($roles as $role) {
    foreach ($publicResources as $resource => $actions) {
        $acl->allow(
            $role->getName(),
            $resource,
            '*'
        );
    }
}

// ユーザーにだけ、プライベートエリアへのアクセス権を与える
foreach ($privateResources as $resource => $actions) {
    foreach ($actions as $action) {
        $acl->allow(
            'Users',
            $resource,
            $action
        );
    }
}

CRUDを使用した作業

バックエンドは一般的に、ユーザーがデータを操作できるようにフォームを提供します。 INVOの説明を続けます。私たちは今、CRUDの作成に取り組んでいます。これはPhalconにとって、フォーム、バリデーション、ページネーターなどを利用する事で、簡単に実装できるとても一般的な事柄です。

INVO (企業、製品、製品の種類) のデータを操作するほとんどのオプションは、基本的で一般的なCRUD (Create, Read, Update, Delete) を使用して構築されます。 各CRUDには、次のファイルが含まれています:

invo/
    app/
        controllers/
            ProductsController.php
        models/
            Products.php
        forms/
            ProductsForm.php
        views/
            products/
                edit.volt
                index.volt
                new.volt
                search.volt

各コントローラーは、次のようなアクションを持っています:

<?php

class ProductsController extends ControllerBase
{
    /**
     * 開始アクション。'search' ビューを表示
     */
    public function indexAction()
    {
        // ...
    }

    /**
     * 'index'から送信された検索条件に基づいて'search'を実行
     * 結果のページネーターを返す
     */
    public function searchAction()
    {
        // ...
    }

    /**
     * 'new' productを作成するビューを表示
     */
    public function newAction()
    {
        // ...
    }

    /**
     * 既存のproductを 'edit' するビューを表示
     */
    public function editAction()
    {
        // ...
    }

    /**
     * 'new' アクションで入力されたデータに基づいてproductを作成
     */
    public function createAction()
    {
        // ...
    }

    /**
     * 'edit' アクションで入力されたデータに基づいてproductを更新
     */
    public function saveAction()
    {
        // ...
    }

    /**
     * 既存のproductを削除
     */
    public function deleteAction($id)
    {
        // ...
    }
}

検索フォーム

すべてのCRUDは検索フォームから始まります。 このフォームは、テーブル (products) にある各フィールドを表示し、任意のフィールドの検索条件をユーザーが作成できるようにします。 productsテーブルはproducts_typesテーブルとのリレーションを持っています。 今回はフィールドでの検索を簡単に実装するために、テーブルのレコードを事前に取得しておきます:

<?php

/**
 * 開始アクション。'search' ビューを表示
 */
public function indexAction()
{
    $this->persistent->searchParams = null;

    $this->view->form = new ProductsForm();
}

ProductsFormフォーム (app/forms/ProductsForm.php) のインスタンスがビューに渡されます。 このフォームは、ユーザーに表示されるフィールドを定義します:

<?php

use Phalcon\Forms\Form;
use Phalcon\Forms\Element\Text;
use Phalcon\Forms\Element\Hidden;
use Phalcon\Forms\Element\Select;
use Phalcon\Validation\Validator\Email;
use Phalcon\Validation\Validator\PresenceOf;
use Phalcon\Validation\Validator\Numericality;

class ProductsForm extends Form
{
    /**
     * productsフォームの初期化
     */
    public function initialize($entity = null, $options = [])
    {
        if (!isset($options['edit'])) {
            $element = new Text('id');
            $element->setLabel('Id');
            $this->add($element);
        } else {
            $this->add(new Hidden('id'));
        }

        $name = new Text('name');
        $name->setLabel('Name');
        $name->setFilters(
            [
                'striptags',
                'string',
            ]
        );
        $name->addValidators(
            [
                new PresenceOf(
                    [
                        'message' => 'Name is required',
                    ]
                )
            ]
        );
        $this->add($name);

        $type = new Select(
            'profilesId',
            ProductTypes::find(),
            [
                'using'      => [
                    'id',
                    'name',
                ],
                'useEmpty'   => true,
                'emptyText'  => '...',
                'emptyValue' => '',
            ]
        );

        $this->add($type);

        $price = new Text('price');
        $price->setLabel('Price');
        $price->setFilters(
            [
                'float',
            ]
        );
        $price->addValidators(
            [
                new PresenceOf(
                    [
                        'message' => 'Price is required',
                    ]
                ),
                new Numericality(
                    [
                        'message' => 'Price is required',
                    ]
                ),
            ]
        );
        $this->add($price);
    }
}

フォームはオブジェクト指向で設計されており、formsコンポーネントを基底としたエレメント提供します。 すべてのエレメントは、ほぼ同じ構造をしています:

<?php

// 要素を作成
$name = new Text('name');

// ラベルを設定
$name->setLabel('Name');

// 要素を検証する前にフィルタを適用
$name->setFilters(
    [
        'striptags',
        'string',
    ]
);

// バリデーションを適用
$name->addValidators(
    [
        new PresenceOf(
            [
                'message' => 'Name is required',
            ]
        )
    ]
);

// フォームに要素を追加
$this->add($name);

他の要素もフォームで使用されます:

<?php

// 非表示項目をフォームに追加
$this->add(
    new Hidden('id')
);

// ...

$productTypes = ProductTypes::find();

// フォームにHTMLのSELECT (リスト)を追加
// 'product_types' のデータで埋める
$type = new Select(
    'profilesId',
    $productTypes,
    [
        'using'      => [
            'id',
            'name',
        ],
        'useEmpty'   => true,
        'emptyText'  => '...',
        'emptyValue' => '',
    ]
);

ProductTypes::find()には、Phalcon\Tag::select()を使用してSELECTタグを埋めるために必要なデータが含まれています。 フォームがビューに渡されると、レンダリングしてユーザーに表示することができます:

{{ form('products/search') }}

    <h2>
        Search products
    </h2>

    <fieldset>

        {% for element in form %}
            <div class='control-group'>
                {{ element.label(['class': 'control-label']) }}

                <div class='controls'>
                    {{ element }}
                </div>
            </div>
        {% endfor %}

        <div class='control-group'>
            {{ submit_button('Search', 'class': 'btn btn-primary') }}
        </div>

    </fieldset>

{{ endForm() }}

次の HTML が生成されます:

<form action='/invo/products/search' method='post'>

    <h2>
        Search products
    </h2>

    <fieldset>

        <div class='control-group'>
            <label for='id' class='control-label'>Id</label>

            <div class='controls'>
                <input type='text' id='id' name='id' />
            </div>
        </div>

        <div class='control-group'>
            <label for='name' class='control-label'>Name</label>

            <div class='controls'>
                <input type='text' id='name' name='name' />
            </div>
        </div>

        <div class='control-group'>
            <label for='profilesId' class='control-label'>profilesId</label>

            <div class='controls'>
                <select id='profilesId' name='profilesId'>
                    <option value=''>...</option>
                    <option value='1'>Vegetables</option>
                    <option value='2'>Fruits</option>
                </select>
            </div>
        </div>

        <div class='control-group'>
            <label for='price' class='control-label'>Price</label>

            <div class='controls'>
                <input type='text' id='price' name='price' />
            </div>
        </div>

        <div class='control-group'>
            <input type='submit' value='Search' class='btn btn-primary' />
        </div>

    </fieldset>

</form>

フォームが送信されると、searchアクションは、ユーザーが入力したデータに基づいて検索を実行するコントローラーの中で実行されます。

検索の実行

searchアクションには2つの動作があります。 POSTでアクセスすると、フォームから送信されたデータに基づいて検索が実行されますが、GETでアクセスするとページネーション内のページに移動します。 HTTPメソッドを区別するために、Requestコンポーネントを使ってチェックします:

<?php

/**
 * 'index' から送信された検索条件に基づいて 'search' を実行
 * 結果のページネーターを返す
 */
public function searchAction()
{
    if ($this->request->isPost()) {
        // クエリ条件を作成する
    } else {
        // 既存の条件を使用してページ切り替え
    }

    // ...
}

Phalcon\Mvc\Model\Criteriaによって、フォームから送信されたデータ型と値に基づいてインテリジェントに検索条件を作成することができます:

<?php

$query = Criteria::fromInput(
    $this->di,
    'Products',
    $this->request->getPost()
);

このメソッドは、どの値が ''(空の文字列)およびnullであるかを検証し、それらを考慮して検索条件を作成します。

  • フィールドのデータ型がテキストまたは同様のもの(char、varchar、textなど)の場合、SQLのlike演算子を使用して結果をフィルタリングします。
  • データ型がテキストでない場合、演算子=が使用されます。

さらに、Criteriaは、テーブルのどのフィールドとも一致しないすべての$POST変数を無視します。 値は、パラメーターのバインド を使用して自動的にエスケープされます。

ここでは、生成されたパラメータをコントローラのセッションバッグに格納します:

<?php

$this->persistent->searchParams = $query->getParams();

セッションバッグはリクエスト間で値を維持する、セッションサービスを利用したコントローラの特殊な変数です。 アクセスがあると、この変数は各コントローラで独立したPhalcon\Session\Bagインスタンスをインジェクションします。

次に、生成されたパラメータに基づいてクエリを実行します:

<?php

$products = Products::find($parameters);

if (count($products) === 0) {
    $this->flash->notice(
        'The search did not found any products'
    );

    return $this->dispatcher->forward(
        [
            'controller' => 'products',
            'action'     => 'index',
        ]
    );
}

検索でproductが返されない場合は、ユーザーをindexアクションにもう一度転送します。 検索結果が返ってきたことにして、ページネーションを作成して簡単にナビゲートしましょう:

<?php

use Phalcon\Paginator\Adapter\Model as Paginator;

// ...

$paginator = new Paginator(
    [
        'data'  => $products,   // ページネーション用データ
        'limit' => 5,           // ページ内行数
        'page'  => $numberPage, // 現在のページ
    ]
);

// paginatorの現在のページを取得
$page = $paginator->getPaginate();

最後に、返されたページを渡して表示します:

<?php

$this->view->page = $page;

ビュー (app/views/products/search.volt) では、現在のページに対応する結果を取得し、取得した全ての行が表示されます。

{% for product in page.items %}
    {% if loop.first %}
        <table>
            <thead>
                <tr>
                    <th>Id</th>
                    <th>Product Type</th>
                    <th>Name</th>
                    <th>Price</th>
                    <th>Active</th>
                </tr>
            </thead>
            <tbody>
    {% endif %}

    <tr>
        <td>
            {{ product.id }}
        </td>

        <td>
            {{ product.getProductTypes().name }}
        </td>

        <td>
            {{ product.name }}
        </td>

        <td>
            {{ '%.2f'|format(product.price) }}
        </td>

        <td>
            {{ product.getActiveDetail() }}
        </td>

        <td width='7%'>
            {{ link_to('products/edit/' ~ product.id, 'Edit') }}
        </td>

        <td width='7%'>
            {{ link_to('products/delete/' ~ product.id, 'Delete') }}
        </td>
    </tr>

    {% if loop.last %}
            </tbody>
            <tbody>
                <tr>
                    <td colspan='7'>
                        <div>
                            {{ link_to('products/search', 'First') }}
                            {{ link_to('products/search?page=' ~ page.before, 'Previous') }}
                            {{ link_to('products/search?page=' ~ page.next, 'Next') }}
                            {{ link_to('products/search?page=' ~ page.last, 'Last') }}
                            <span class='help-inline'>{{ page.current }} of {{ page.total_pages }}</span>
                        </div>
                    </td>
                </tr>
            </tbody>
        </table>
    {% endif %}
{% else %}
    No products are recorded
{% endfor %}

上記の例には、細かい部分で価値あることがたくさんあります。 まず第一に、現在のページ内のアクティブなアイテムは、Voltのforを使用して取得されます。 VoltはPHPのforeachを使うための、より簡単な構文を提供します。

{% for product in page.items %}

PHPで同じ事は:

<?php foreach ($page->items as $product) { ?>

forブロック全体は以下を提供します:

{% for product in page.items %}
    {% if loop.first %}
        Executed before the first product in the loop
    {% endif %}

    Executed for every product of page.items

    {% if loop.last %}
        Executed after the last product is loop
    {% endif %}
{% else %}
    Executed if page.items does not have any products
{% endfor %}

今すぐビューに戻り、すべてのブロックが何をしているのかを調べることができます。 productのすべてのフィールドがそれに応じて表示されます:

<tr>
    <td>
        {{ product.id }}
    </td>

    <td>
        {{ product.productTypes.name }}
    </td>

    <td>
        {{ product.name }}
    </td>

    <td>
        {{ '%.2f'|format(product.price) }}
    </td>

    <td>
        {{ product.getActiveDetail() }}
    </td>

    <td width='7%'>
        {{ link_to('products/edit/' ~ product.id, 'Edit') }}
    </td>

    <td width='7%'>
        {{ link_to('products/delete/' ~ product.id, 'Delete') }}
    </td>
</tr>

product.idを使用する前に見たように、PHPの場合ではこうなります: $product->idproduct.nameの場合も同様です。 他のフィールドは異なる方法でレンダリングされます。たとえば、product.productTypes.nameに注目しましょう。 この部分を理解するには、Productsモデル (app/models/Products.php) を確認する必要があります。

<?php

use Phalcon\Mvc\Model;

/**
 * Products
 */
class Products extends Model
{
    // ...

    /**
     * Products初期処理
     */
    public function initialize()
    {
        $this->belongsTo(
            'product_types_id',
            'ProductTypes',
            'id',
            [
                'reusable' => true,
            ]
        );
    }

    // ...
}

モデルはinitialize()というメソッドを持つことができます。このメソッドはリクエストごとに1回呼び出され、ORMを使用してモデルを初期化します。 この場合、 'Products'は、このモデルが 'ProductTypes'と呼ばれる別のモデルと1対多の関係を持つことを定義することによって初期化されます。

<?php

$this->belongsTo(
    'product_types_id',
    'ProductTypes',
    'id',
    [
        'reusable' => true,
    ]
);

つまり、Productsの属性product_types_idは、ProductTypesモデルのid属性と、1対多の関係をもっています。 この関係を定義することによって、以下を使用してproductのタイプ名にアクセスできます:

<td>{{ product.productTypes.name }}</td>

フィールドpriceは、Voltのフィルタを使用してフォーマットされて出力されています。

<td>{{ '%.2f'|format(product.price) }}</td>

素のPHPでは、次のようになります:

<?php echo sprintf('%.2f', $product->price) ?>

productがアクティブかどうかを表示するには、モデルに実装されているヘルパーを使用します。

<td>{{ product.getActiveDetail() }}</td>

このメソッドはモデルに定義されています。

レコードの登録と更新

CRUDがレコードを作成し更新する方法を見てみましょう。 newおよびeditビューからユーザーが入力したデータはcreateおよびsaveアクションに送られ、それぞれproductsの作成および更新の処理を実行します。

作成の場合、送信されたデータを取得し、新しいProductsインスタンスに割り当てます:

<?php

/**
 * 'new' アクションで入力されたデータに基づいてproductを作成
 */
public function createAction()
{
    if (!$this->request->isPost()) {
        return $this->dispatcher->forward(
            [
                'controller' => 'products',
                'action'     => 'index',
            ]
        );
    }

    $form = new ProductsForm();

    $product = new Products();

    $product->id               = $this->request->getPost('id', 'int');
    $product->product_types_id = $this->request->getPost('product_types_id', 'int');
    $product->name             = $this->request->getPost('name', 'striptags');
    $product->price            = $this->request->getPost('price', 'double');
    $product->active           = $this->request->getPost('active');

    // ...
}

Productsフォームで定義したフィルタを覚えていますか? データは、オブジェクト$productに割り当てられる前にフィルタリングされます。 このフィルタリングはオプションです。 ORMはまた、入力データをエスケープし、列の種類に応じて追加の変換を実行します:

<?php

// ...

$name = new Text('name');

$name->setLabel('Name');

// nameをフィルタ
$name->setFilters(
    [
        'striptags',
        'string',
    ]
);

// nameのバリデーション
$name->addValidators(
    [
        new PresenceOf(
            [
                'message' => 'Name is required',
            ]
        )
    ]
);

$this->add($name);

保存すると、データがProductsFormフォーム (app/forms/ProductsForm.php)の形式で実装されたビジネスルールとバリデーションに沿っているかどうかがわかります。

<?php

// ...

$form = new ProductsForm();

$product = new Products();

// 入力内容をバリデーション
$data = $this->request->getPost();

if (!$form->isValid($data, $product)) {
    $messages = $form->getMessages();

    foreach ($messages as $message) {
        $this->flash->error($message);
    }

    return $this->dispatcher->forward(
        [
            'controller' => 'products',
            'action'     => 'new',
        ]
    );
}

最後に、フォームからバリデーションメッセージが返されない場合は、productインスタンスを保存できます:

<?php

// ...

if ($product->save() === false) {
    $messages = $product->getMessages();

    foreach ($messages as $message) {
        $this->flash->error($message);
    }

    return $this->dispatcher->forward(
        [
            'controller' => 'products',
            'action'     => 'new',
        ]
    );
}

$form->clear();

$this->flash->success(
    'Product was created successfully'
);

return $this->dispatcher->forward(
    [
        'controller' => 'products',
        'action'     => 'index',
    ]
);

さて、productを更新する場合は、まず編集されたレコードに現在あるデータをユーザーに表示する必要があります:

<?php

/**
 * IDに紐づいたproductを編集
 */
public function editAction($id)
{
    if (!$this->request->isPost()) {
        $product = Products::findFirstById($id);

        if (!$product) {
            $this->flash->error(
                'Product was not found'
            );

            return $this->dispatcher->forward(
                [
                    'controller' => 'products',
                    'action'     => 'index',
                ]
            );
        }

        $this->view->form = new ProductsForm(
            $product,
            [
                'edit' => true,
            ]
        );
    }
}

見つかったデータは、最初のパラメータとしてモデルを渡すことによってフォームにバインドされます。 これにより、ユーザーは任意の値を変更し、saveアクションを使用してデータベースを更新することができます:

<?php

/**
 * 'edit' アクションで入力されたデータに基づいてproductを更新
 */
public function saveAction()
{
    if (!$this->request->isPost()) {
        return $this->dispatcher->forward(
            [
                'controller' => 'products',
                'action'     => 'index',
            ]
        );
    }

    $id = $this->request->getPost('id', 'int');

    $product = Products::findFirstById($id);

    if (!$product) {
        $this->flash->error(
            'Product does not exist'
        );

        return $this->dispatcher->forward(
            [
                'controller' => 'products',
                'action'     => 'index',
            ]
        );
    }

    $form = new ProductsForm();

    $data = $this->request->getPost();

    if (!$form->isValid($data, $product)) {
        $messages = $form->getMessages();

        foreach ($messages as $message) {
            $this->flash->error($message);
        }

        return $this->dispatcher->forward(
            [
                'controller' => 'products',
                'action'     => 'new',
            ]
        );
    }

    if ($product->save() === false) {
        $messages = $product->getMessages();

        foreach ($messages as $message) {
            $this->flash->error($message);
        }

        return $this->dispatcher->forward(
            [
                'controller' => 'products',
                'action'     => 'new',
            ]
        );
    }

    $form->clear();

    $this->flash->success(
        'Product was updated successfully'
    );

    return $this->dispatcher->forward(
        [
            'controller' => 'products',
            'action'     => 'index',
        ]
    );
}

ユーザーコンポーネント

アプリケーションのすべてのUI要素とビジュアルスタイルは、主にBootstrapを使って実装されています。 アプリケーションの状態に応じてナビゲーションバーなどの一部の要素が変更されます。 たとえば、ユーザーがアプリケーションにログインしている場合、右上隅にあるLog in / Sign UpリンクはLog outに変わります。

アプリケーションのこの部分は、コンポーネントElements (app/library/Elements.php) で実装されています。

<?php

use Phalcon\Mvc\User\Component;

class Elements extends Component
{
    public function getMenu()
    {
        // ...
    }

    public function getTabs()
    {
        // ...
    }
}

このクラスはPhalcon\Mvc\User\Componentを拡張しています。 このクラスを使ってコンポーネントを拡張することは必須ではありませんが、アプリケーションのサービスへのアクセスをスムーズにするのに役立ちます。 ここでは、最初のユーザーコンポーネントをサービスコンテナに登録します:

<?php

// ユーザーコンポーネントを登録
$di->set(
    'elements',
    function () {
        return new Elements();
    }
);

ビュー内のコントローラ、プラグイン、コンポーネントとして、このコンポーネントは、コンテナに登録されているサービスにアクセスし、登録したサービスと同じ名前の属性にアクセスするだけでアクセスできます。

<div class='navbar navbar-fixed-top'>
    <div class='navbar-inner'>
        <div class='container'>
            <a class='btn btn-navbar' data-toggle='collapse' data-target='.nav-collapse'>
                <span class='icon-bar'></span>
                <span class='icon-bar'></span>
                <span class='icon-bar'></span>
            </a>

            <a class='brand' href='#'>INVO</a>

            {{ elements.getMenu() }}
        </div>
    </div>
</div>

<div class='container'>
    {{ content() }}

    <hr>

    <footer>
        <p>&copy; Company 2017</p>
    </footer>
</div>

重要な部分は次の箇所です:

{{ elements.getMenu() }}

タイトルの動的な変更

あるオプションと別のオプションを参照すると、現在作業している場所を示すタイトルが動的に変更されます。 これは、各コントローラーの初期化処理で実現されます:

<?php

class ProductsController extends ControllerBase
{
    public function initialize()
    {
        // ページタイトルを指定
        $this->tag->setTitle(
            'Manage your product types'
        );

        parent::initialize();
    }

    // ...
}

parent::initialize()メソッドも呼び出され、タイトルにデータを追加します:

<?php

use Phalcon\Mvc\Controller;

class ControllerBase extends Controller
{
    protected function initialize()
    {
        // タイトルの前にアプリケーション名を追加
        $this->tag->prependTitle('INVO | ');
    }

    // ...
}

最後に、メインビュー (app/views/index.volt) でタイトルを出力:

<!DOCTYPE html>
<html>
    <head>
        <?php echo $this->tag->getTitle(); ?>
    </head>

    <!-- ... -->
</html>