初心者用CakePHPでブログサイト③ログインユーザーと記事を紐づける

初心者用CakePHPでブログサイト③ログインユーザーと記事を紐づける

595 回閲覧されました

みなさんこんにちは、jonioです。

今回は管理画面を作成してCRUDができるようにしてログイン機能を作成して誰が記事を投稿したかを分かるようにします。

1回目の記事をまだ読んでない方は下記の記事から読んで下さい。

まずは管理画面を作成します。

管理画面用のコントローラーの作成

管理画面用のコントローラーの作成する為に下記のコマンドを叩きます。

bin/cake bake controller posts --prefix admin 

「–prefix admin」と書くことで「CakePHPのプロジェクト > src > Controller > Admin」の下の階層に「PostsController.php」が作成されます。

Admin」は「–prefix admin」の「admin」のことです。

次は管理画面用のビューを作成します。

管理画面用のビューの作成

下記のコマンドを叩きます。

bin/cake bake template posts --prefix admin

posts –prefix admin」と書くことで「CakePHPのプロジェクト > templates > Admin > Posts」の下の階層に「edit.php」・「add.php」・「view.php」・「index.php」が作成されます。(背景色で対応させて下さい)

今の状態ではまだコントローラーが認識されていないので認識をさせます。

コントローラーの認識

「CakePHPのプロジェクト > config > routes.php」のコードを下記にします。

<?php
/**
 * Routes configuration.
 *
 * In this file, you set up routes to your controllers and their actions.
 * Routes are very important mechanism that allows you to freely connect
 * different URLs to chosen controllers and their actions (functions).
 *
 * It's loaded within the context of `Application::routes()` method which
 * receives a `RouteBuilder` instance `$routes` as method argument.
 *
 * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
 * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
 *
 * Licensed under The MIT License
 * For full copyright and license information, please see the LICENSE.txt
 * Redistributions of files must retain the above copyright notice.
 *
 * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
 * @link          https://cakephp.org CakePHP(tm) Project
 * @license       https://opensource.org/licenses/mit-license.php MIT License
 */

use Cake\Routing\Route\DashedRoute;
use Cake\Routing\RouteBuilder;
use Cake\Routing\Router;                    //この行を追加

/*
 * The default class to use for all routes
 *
 * The following route classes are supplied with CakePHP and are appropriate
 * to set as the default:
 *
 * - Route
 * - InflectedRoute
 * - DashedRoute
 *
 * If no call is made to `Router::defaultRouteClass()`, the class used is
 * `Route` (`Cake\Routing\Route\Route`)
 *
 * Note that `Route` does not do any inflections on URLs which will result in
 * inconsistently cased URLs when used with `:plugin`, `:controller` and
 * `:action` markers.
 */
/** @var \Cake\Routing\RouteBuilder $routes */
$routes->setRouteClass(DashedRoute::class);

$routes->scope('/', function (RouteBuilder $builder) {
    /*
     * Here, we are connecting '/' (base path) to a controller called 'Pages',
     * its action called 'display', and we pass a param to select the view file
     * to use (in this case, templates/Pages/home.php)...
     */
    $builder->connect('/', ['controller' => 'Pages', 'action' => 'display', 'home']);

    /*
     * ...and connect the rest of 'Pages' controller's URLs.
     */
    $builder->connect('/pages/*', ['controller' => 'Pages', 'action' => 'display']);


    //ここから追加
    $builder->prefix('admin', function($routes) {
      $routes->fallbacks('DashedRoute');
    });
    //ここまで追加


//以下の行は変更がない為省略

これで「http://localhost:8888/cakephp/CakeBlog1/admin/posts/index」にアクセスすると下記の表示になります。

画面右上の「NEW POST」から記事の作成ができます。

画面の「Actions」の項目の「View」・「Edit」・「Delete」の項目も動作しますので確認してください。

私はこの画面をchromeで確認しましたが「NEW POST」から記事を作成しようとしたら「Missing or invalid CSRF cookie.」とエラーが表示されました。

chromeの設定からcookieを削除すればエラーがなくなりますので万が一同じエラーが出たら試してみて下さい。

管理画面・トップページのURLを変更

まずは管理画面のURLを変更します。

「CakePHPのプロジェクト > config > routes.php」のコードを下記に修正します。(変更がある部分のみ掲載します)

$builder->prefix('admin', function($routes) {
  $routes->connect('/', ['controller' => 'Posts', 'action' => 'index']);   //この行を追加

  $routes->fallbacks('DashedRoute');
});

2行目の「Posts」はコントローラー名で「index」はメソッド名です、さっき作成した奴です。

これで「http://localhost:8888/cakephp/CakeBlog1/admin」にアクセスすると管理画面になります。

次はトップページのURLを変更します。

今の状態だと記事一覧を表示するURLは「http://localhost:8888/cakephp/CakeBlog1/posts/index」でしたが「http://localhost:8888/cakephp/CakeBlog1/」(トップページのURL)に変更します。

これもroutes.phpを修正すればいいです、下記に変更します。

$routes->scope('/', function (RouteBuilder $builder) {
    /*
     * Here, we are connecting '/' (base path) to a controller called 'Pages',
     * its action called 'display', and we pass a param to select the view file
     * to use (in this case, templates/Pages/home.php)...
     */
    $builder->connect('/', ['controller' => 'Pages', 'action' => 'display', 'home']);   //この行は削除  
    $builder->connect('/', ['controller' => 'Posts', 'action' => 'index']); ;      //この行に変更

    /*
     * ...and connect the rest of 'Pages' controller's URLs.
     */
    $builder->connect('/pages/*', ['controller' => 'Pages', 'action' => 'display']);

これで「http://localhost:8888/cakephp/CakeBlog1/」にアクセスすると記事一覧が表示されます。

ログインした人のみ閲覧できるページの作成

今の状態は誰でも管理画面にアクセスできてCRUD(記事の投稿・編集・削除)を実行できますがログインに通った人だけができるようにします。

CakPHPのチュートリアルを使っています。

ユーザー登録用のテーブルを作成

まず認証用のプラグインをインストールする為に下記のコマンドを叩きます。

composer require cakephp/authentication:^2.0

そしてユーザーを登録するテーブルを作成しますがまずはマイグレーションファイルの作成です。

下記のコマンドを叩きます。

bin/cake bake migration CreateUsers

マイグレーションファイルに下記の記述をします。

<?php
declare(strict_types=1);

use Migrations\AbstractMigration;

class CreateUsers extends AbstractMigration
{
    /**
     * Change Method.
     *
     * More information on this method is available here:
     * https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
     * @return void
     */
    public function change(): void
    {
        $table = $this->table('users');
        
        
        //ここから追加
        $table->addColumn('username', 'string', [
                    'default' => null,
                    'limit' => 50,
                    'null' => false
                ])
                ->addColumn('password', 'string', [
                    'default' => null,
                    'limit' => 255,
                    'null' => false
                ])
                ->addColumn('created', 'datetime')
                ->addColumn('modified', 'datetime')
                ->create();
        //ここまで追加
        
        
    }
}

ユーザー名は「username」カラムとしパスワードは「password」カラムとします。

そしてusersテーブルに挿入するデータの作成をします。

「CakePHPのプロジェクト > config > Seeds」の下の階層に「UsersSeed.php」を作成して中身に下記の記述をします。

<?php
declare(strict_types=1);

use Migrations\AbstractSeed;
use Authentication\PasswordHasher\DefaultPasswordHasher;         //この行を追加

/**
 * Users seed.
 */
class UsersSeed extends AbstractSeed
{
    /**
     * Run Method.
     *
     * Write your database seeder using this method.
     *
     * More information on writing seeds is available here:
     * https://book.cakephp.org/phinx/0/en/seeding.html
     *
     * @return void
     */
    public function run(): void
    {
        $data = [
            [
                'username' => 'admin',
                'password' => $this->_setPassword('admin'),
                'created' => '2020-05-02 10:00:00',
                'modified' => '2020-05-02 10:00:00',
            ],            
            [
                'username' => 'yamada',
                'password' => $this->_setPassword('yamada'),
                'created' => '2020-05-02 10:00:00',
                'modified' => '2020-05-02 10:00:00',
            ],            
        ];

        $table = $this->table('users');
        $table->insert($data)->save();
    }


    //ここから追加
    protected function _setPassword(string $password) : ?string
    {
        if (strlen($password) > 0) {
            return (new DefaultPasswordHasher())->hash($password);
        }
    }
    //ここまで追加


}

PostsSeed.phpに記述した時に5行目・45行目〜50行目はなかったのですが今回は必要なので記述しています。

ログインに使うパスワードをハッシュ化(ランダムな文字列にして元々のパスワードが何か分からなくする)するのに必要です。

実際のパスワードのハッシュ化は27行目・33行目のvalueで行っています。

それではusersテーブルを作成してSeederのデータをテーブルに挿入する為に下記のコマンドを叩きます。

bin/cake migrations migrate
bin/cake migrations seed --seed UsersSeed

これでusersテーブルにSeederのデータが入った状態になりました。

次はモデルを使ってビューにユーザー一覧を表示します。

ユーザー用のモデルの作成

モデルを作成する為に下記のコマンドを叩きます。

bin/cake bake model users

「CakePHPのプロジェクト > src > Model > Entity > User.php」にパスワードのハッシュ化をする為の記述をします。

<?php
declare(strict_types=1);

namespace App\Model\Entity;

use Authentication\PasswordHasher\DefaultPasswordHasher;          //この行を追加
use Cake\ORM\Entity;

/**
 * User Entity
 *
 * @property int $id
 * @property string $username
 * @property string $password
 * @property \Cake\I18n\FrozenTime $created
 * @property \Cake\I18n\FrozenTime $modified
 */
class User extends Entity
{
    /**
     * Fields that can be mass assigned using newEntity() or patchEntity().
     *
     * Note that when '*' is set to true, this allows all unspecified fields to
     * be mass assigned. For security purposes, it is advised to set '*' to false
     * (or remove it), and explicitly make individual fields accessible as needed.
     *
     * @var array<string, bool>
     */
    protected $_accessible = [
        'username' => true,
        'password' => true,
        'created' => true,
        'modified' => true,
    ];

    /**
     * Fields that are excluded from JSON versions of the entity.
     *
     * @var array<string>
     */
    protected $_hidden = [
        'password',
    ];


    //ここから追加
    protected function _setPassword(string $password) : ?string
    {
        if (strlen($password) > 0) {
            return (new DefaultPasswordHasher())->hash($password);
        }
    }
    //ここまで追加

    
}

ユーザー一覧の表示

次はユーザーを表示するのに使うコントローラーを作成する為に下記のコマンドを叩きます。

bin/cake bake controller users --prefix admin

そしてビューを作成する為に下記のコマンドを叩きます。

bin/cake bake template users --prefix admin

そしてビューに追記します。

「CakePHPのプロジェクト > templates > Admin > Users > index.php」を下記に修正します。

<?php
/**
 * @var \App\View\AppView $this
 * @var iterable<\App\Model\Entity\User> $users
 */
?>
<div class="users index content">
    <?= $this->Html->link(__('New User'), ['action' => 'add'], ['class' => 'button float-right']) ?>
    <h3><?= __('Users') ?></h3>
    <div class="table-responsive">
        <table>
            <thead>
                <tr>
                    <th><?= $this->Paginator->sort('id') ?></th>
                    <th><?= $this->Paginator->sort('username') ?></th>
                    <th><?= $this->Paginator->sort('password') ?></th>      //この行を追加
                    <th><?= $this->Paginator->sort('created') ?></th>
                    <th><?= $this->Paginator->sort('modified') ?></th>
                    <th class="actions"><?= __('Actions') ?></th>
                </tr>
            </thead>
            <tbody>
                <?php foreach ($users as $user): ?>
                <tr>
                    <td><?= $this->Number->format($user->id) ?></td>
                    <td><?= h($user->username) ?></td>
                    <td><?= h($user->password) ?></td>         //この行を追加
                    <td><?= h($user->created) ?></td>
                    <td><?= h($user->modified) ?></td>
                    <td class="actions">
                        <?= $this->Html->link(__('View'), ['action' => 'view', $user->id]) ?>
                        <?= $this->Html->link(__('Edit'), ['action' => 'edit', $user->id]) ?>
                        <?= $this->Form->postLink(__('Delete'), ['action' => 'delete', $user->id], ['confirm' => __('Are you sure you want to delete # {0}?', $user->id)]) ?>
                    </td>
                </tr>
                <?php endforeach; ?>
            </tbody>
        </table>
    </div>
    <div class="paginator">
        <ul class="pagination">
            <?= $this->Paginator->first('<< ' . __('first')) ?>
            <?= $this->Paginator->prev('< ' . __('previous')) ?>
            <?= $this->Paginator->numbers() ?>
            <?= $this->Paginator->next(__('next') . ' >') ?>
            <?= $this->Paginator->last(__('last') . ' >>') ?>
        </ul>
        <p><?= $this->Paginator->counter(__('Page {{page}} of {{pages}}, showing {{current}} record(s) out of {{count}} total')) ?></p>
    </div>
</div>

これで「http://localhost:8888/cakephp/CakeBlog1/admin/users」にアクセスするとユーザー一覧が表示されパスワードがランダムな文字列になっているのが確認できます(下記赤枠)。

ログイン機能の作成

「CakePHPのプロジェクト > src > Application.php」に追記します。

<?php
declare(strict_types=1);

/**
 * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
 * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
 *
 * Licensed under The MIT License
 * For full copyright and license information, please see the LICENSE.txt
 * Redistributions of files must retain the above copyright notice.
 *
 * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
 * @link      https://cakephp.org CakePHP(tm) Project
 * @since     3.3.0
 * @license   https://opensource.org/licenses/mit-license.php MIT License
 */
namespace App;

use Cake\Core\Configure;
use Cake\Core\Exception\MissingPluginException;
use Cake\Error\Middleware\ErrorHandlerMiddleware;
use Cake\Http\BaseApplication;
use Cake\Http\Middleware\BodyParserMiddleware;
use Cake\Http\Middleware\CsrfProtectionMiddleware;
use Cake\Http\MiddlewareQueue;
use Cake\Routing\Middleware\AssetMiddleware;
use Cake\Routing\Middleware\RoutingMiddleware;


//ここから追加
use Authentication\AuthenticationService;
use Authentication\AuthenticationServiceInterface;
use Authentication\AuthenticationServiceProviderInterface;
use Authentication\Middleware\AuthenticationMiddleware;
use Cake\Routing\Router;
use Psr\Http\Message\ServerRequestInterface;
//ここまで追加


/**
 * Application setup class.
 *
 * This defines the bootstrapping logic and middleware layers you
 * want to use in your application.
 */
class Application extends BaseApplication implements AuthenticationServiceProviderInterface     //この行の「BaseApplication」のあとに「implements AuthenticationServiceProviderInterface」を追加している
{
    /**
     * Load all the application configuration and bootstrap logic.
     *
     * @return void
     */
    public function bootstrap(): void
    {
        // Call parent to load bootstrap from files.
        parent::bootstrap();

        if (PHP_SAPI === 'cli') {
            $this->bootstrapCli();
        }

        /*
         * Only try to load DebugKit in development mode
         * Debug Kit should not be installed on a production system
         */
        if (Configure::read('debug')) {
            $this->addPlugin('DebugKit');
        }

        // Load more plugins here
    }

    /**
     * Setup the middleware queue your application will use.
     *
     * @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to setup.
     * @return \Cake\Http\MiddlewareQueue The updated middleware queue.
     */
    public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue
    {
        $middlewareQueue
            // Catch any exceptions in the lower layers,
            // and make an error page/response
            ->add(new ErrorHandlerMiddleware(Configure::read('Error')))

            // Handle plugin/theme assets like CakePHP normally does.
            ->add(new AssetMiddleware([
                'cacheTime' => Configure::read('Asset.cacheTime'),
            ]))

            // Add routing middleware.
            // If you have a large number of routes connected, turning on routes
            // caching in production could improve performance. For that when
            // creating the middleware instance specify the cache config name by
            // using it's second constructor argument:
            // `new RoutingMiddleware($this, '_cake_routes_')`
            ->add(new RoutingMiddleware($this))

            // RoutingMiddleware の後に認証を追加
            ->add(new AuthenticationMiddleware($this))              //この行を追加

            // Parse various types of encoded request bodies so that they are
            // available as array through $request->getData()
            // https://book.cakephp.org/4/en/controllers/middleware.html#body-parser-middleware
            ->add(new BodyParserMiddleware())

            // Cross Site Request Forgery (CSRF) Protection Middleware
            // https://book.cakephp.org/4/en/controllers/middleware.html#cross-site-request-forgery-csrf-middleware
            ->add(new CsrfProtectionMiddleware([
                'httponly' => true,
            ]));

        return $middlewareQueue;
    }


    //ここから追加
    public function getAuthenticationService(ServerRequestInterface $request): AuthenticationServiceInterface
    {
        $authenticationService = new AuthenticationService([
            'unauthenticatedRedirect' => Router::url('/admin/users/login'),   //ログイン必須のページでログインしていない場合のリダイレクト先
            'queryParam' => 'redirect',
        ]);

        // identifiers を読み込み、username と password のフィールドを確認します
        $authenticationService->loadIdentifier('Authentication.Password', [
            'fields' => [
                'username' => 'username',
                'password' => 'password',
            ]
        ]);

        //  authenticatorsをロードしたら, 最初にセッションが必要です
        $authenticationService->loadAuthenticator('Authentication.Session');
        // 入力した username と password をチェックする為のフォームデータを設定します
        $authenticationService->loadAuthenticator('Authentication.Form', [
            'fields' => [
                'username' => 'username',
                'password' => 'password',
            ],
            'loginUrl' => Router::url('/admin/users/login'),
        ]);

        return $authenticationService;
    }
    //ここまで追加


    /**
     * Bootrapping for CLI application.
     *
     * That is when running commands.
     *
     * @return void
     */
    protected function bootstrapCli(): void
    {
        try {
            $this->addPlugin('Bake');
        } catch (MissingPluginException $e) {
            // Do not halt if the plugin is missing
        }

        $this->addPlugin('Migrations');

        // Load more plugins here
    }
}

「CakePHPのプロジェクト > src > Controller > Admin」の下の階層に「AdminController.php」を作成して中身を下記にします。

<?php
declare(strict_types=1);

/**
 * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
 * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
 *
 * Licensed under The MIT License
 * For full copyright and license information, please see the LICENSE.txt
 * Redistributions of files must retain the above copyright notice.
 *
 * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
 * @link      https://cakephp.org CakePHP(tm) Project
 * @since     0.2.9
 * @license   https://opensource.org/licenses/mit-license.php MIT License
 */
namespace App\Controller\Admin;

use Cake\Controller\Controller;

/**
 * Application Controller
 *
 * Add your application-wide methods in the class below, your controllers
 * will inherit them.
 *
 * @link https://book.cakephp.org/4/en/controllers.html#the-app-controller
 */
class AdminController extends Controller
{
    /**
     * Initialization hook method.
     *
     * Use this method to add common initialization code like loading components.
     *
     * e.g. `$this->loadComponent('FormProtection');`
     *
     * @return void
     */
    public function initialize(): void
    {
        parent::initialize();

        $this->loadComponent('RequestHandler');
        $this->loadComponent('Flash');
        // 認証結果を確認し、サイトのロックを行うために次の行を追加します
        $this->loadComponent('Authentication.Authentication');

        /*
         * Enable the following component for recommended CakePHP form protection settings.
         * see https://book.cakephp.org/4/en/controllers/components/form-protection.html
         */
        //$this->loadComponent('FormProtection');
    }
}

次はAdminコントローラーと同じ階層にあるPostsコントローラーを修正します。

<?php
declare(strict_types=1);

namespace App\Controller\Admin;

use App\Controller\Admin\AdminController;            //この行を修正

/**
 * Posts Controller
 *
 * @property \App\Model\Table\PostsTable $Posts
 * @method \App\Model\Entity\Post[]|\Cake\Datasource\ResultSetInterface paginate($object = null, array $settings = [])
 */
class PostsController extends AdminController          //AppControllerをAdminControllerに変更


//下の行は変更がない為省略

またAdminコントローラーと同じ階層にあるUsersコントローラーも修正します。

<?php
declare(strict_types=1);

namespace App\Controller\Admin;

use App\Controller\Admin\AdminController;                    //この行を修正

/**
 * Users Controller
 *
 * @property \App\Model\Table\UsersTable $Users
 * @method \App\Model\Entity\User[]|\Cake\Datasource\ResultSetInterface paginate($object = null, array $settings = [])
 */
class UsersController extends AdminController         //AppControllerをAdminControllerに変更


//下の行は変更がない為省略

UsersController.phpに追記します。

<?php
declare(strict_types=1);

namespace App\Controller\Admin;

use App\Controller\Admin\AdminController;                  

/**
 * Users Controller
 *
 * @property \App\Model\Table\UsersTable $Users
 * @method \App\Model\Entity\User[]|\Cake\Datasource\ResultSetInterface paginate($object = null, array $settings = [])
 */
class UsersController extends AdminController             
{


    //ここから追加
    public function beforeFilter(\Cake\Event\EventInterface $event)
    {
        parent::beforeFilter($event);
  
        $this->Authentication->addUnauthenticatedActions(['login']);
    }
    //ここまで追加


    /**
     * Index method
     *
     * @return \Cake\Http\Response|null|void Renders view
     */
    public function index()
    {
        $users = $this->paginate($this->Users);

        $this->set(compact('users'));
    }

    /**
     * View method
     *
     * @param string|null $id User id.
     * @return \Cake\Http\Response|null|void Renders view
     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
     */
    public function view($id = null)
    {
        $user = $this->Users->get($id, [
            'contain' => [],
        ]);

        $this->set(compact('user'));
    }

    /**
     * Add method
     *
     * @return \Cake\Http\Response|null|void Redirects on successful add, renders view otherwise.
     */
    public function add()
    {
        $user = $this->Users->newEmptyEntity();
        if ($this->request->is('post')) {
            $user = $this->Users->patchEntity($user, $this->request->getData());
            if ($this->Users->save($user)) {
                $this->Flash->success(__('The user has been saved.'));

                return $this->redirect(['action' => 'index']);
            }
            $this->Flash->error(__('The user could not be saved. Please, try again.'));
        }
        $this->set(compact('user'));
    }

    /**
     * Edit method
     *
     * @param string|null $id User id.
     * @return \Cake\Http\Response|null|void Redirects on successful edit, renders view otherwise.
     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
     */
    public function edit($id = null)
    {
        $user = $this->Users->get($id, [
            'contain' => [],
        ]);
        if ($this->request->is(['patch', 'post', 'put'])) {
            $user = $this->Users->patchEntity($user, $this->request->getData());
            if ($this->Users->save($user)) {
                $this->Flash->success(__('The user has been saved.'));

                return $this->redirect(['action' => 'index']);
            }
            $this->Flash->error(__('The user could not be saved. Please, try again.'));
        }
        $this->set(compact('user'));
    }

    /**
     * Delete method
     *
     * @param string|null $id User id.
     * @return \Cake\Http\Response|null|void Redirects to index.
     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
     */
    public function delete($id = null)
    {
        $this->request->allowMethod(['post', 'delete']);
        $user = $this->Users->get($id);
        if ($this->Users->delete($user)) {
            $this->Flash->success(__('The user has been deleted.'));
        } else {
            $this->Flash->error(__('The user could not be deleted. Please, try again.'));
        }

        return $this->redirect(['action' => 'index']);
    }


    //ここから追加
    public function login()
    {
        $this->request->allowMethod(['get', 'post']);
        $result = $this->Authentication->getResult();
        // POST, GET を問わず、ユーザーがログインしている場合はリダイレクトします
        if ($result->isValid()) {
            return $this->redirect('/admin');
        }
        // ユーザーが submit 後、認証失敗した場合は、エラーを表示します
        if ($this->request->is('post') && !$result->isValid()) {
            $this->Flash->error('ユーザー名かパスワードが正しくありません。');
        }
    }
    //ここまで追加

    
}

19行目〜24行目で認証を必要としないアクションの設定ができます。

今はUsersコントローラーにbeforeFilterメソッドを記述しているのでUsersコントローラーに対して認証を必要としないアクションの設定ができます。

他のコントローラーにも認証を必要としないアクションの設定をしたいならその度にbeforeFilterメソッドの記述をします。

これでログイン機能はできたので次はログイン用のビューを作成します。

ログイン用のビュー

「CakePHPのプロジェクト > templates > Admin > Users」の下の階層にlogin.phpを作成します。

login.phpの中身を下記にします。

<div class="users form">
    <?= $this->Flash->render() ?>
    <?= $this->Form->create() ?>
    <fieldset>
        <?= $this->Form->control('username', ['required' => true]) ?>
        <?= $this->Form->control('password', ['required' => true]) ?>
    </fieldset>
    <?= $this->Form->submit('ログイン'); ?>
    <?= $this->Form->end() ?>
</div>

これでログインが必須な「http://localhost:8888/cakephp/CakeBlog1/admin/」にログインするとログインのページにリダイレクトします。

これでログイン機能ができましたが今の状態はログアウトの機能がないので作成します。

ログアウト機能

UsersController.phpに追記します。

<?php
declare(strict_types=1);

namespace App\Controller\Admin;

use App\Controller\Admin\AdminController;                  

/**
 * Users Controller
 *
 * @property \App\Model\Table\UsersTable $Users
 * @method \App\Model\Entity\User[]|\Cake\Datasource\ResultSetInterface paginate($object = null, array $settings = [])
 */
class UsersController extends AdminController             
{
    public function beforeFilter(\Cake\Event\EventInterface $event)
    {
        parent::beforeFilter($event);
        // 認証を必要としないログインアクションを構成し、
        // 無限リダイレクトループの問題を防ぎます
        $this->Authentication->addUnauthenticatedActions(['login']);
    }

    /**
     * Index method
     *
     * @return \Cake\Http\Response|null|void Renders view
     */
    public function index()
    {
        $users = $this->paginate($this->Users);

        $this->set(compact('users'));
    }

    /**
     * View method
     *
     * @param string|null $id User id.
     * @return \Cake\Http\Response|null|void Renders view
     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
     */
    public function view($id = null)
    {
        $user = $this->Users->get($id, [
            'contain' => [],
        ]);

        $this->set(compact('user'));
    }

    /**
     * Add method
     *
     * @return \Cake\Http\Response|null|void Redirects on successful add, renders view otherwise.
     */
    public function add()
    {
        $user = $this->Users->newEmptyEntity();
        if ($this->request->is('post')) {
            $user = $this->Users->patchEntity($user, $this->request->getData());
            if ($this->Users->save($user)) {
                $this->Flash->success(__('The user has been saved.'));

                return $this->redirect(['action' => 'index']);
            }
            $this->Flash->error(__('The user could not be saved. Please, try again.'));
        }
        $this->set(compact('user'));
    }

    /**
     * Edit method
     *
     * @param string|null $id User id.
     * @return \Cake\Http\Response|null|void Redirects on successful edit, renders view otherwise.
     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
     */
    public function edit($id = null)
    {
        $user = $this->Users->get($id, [
            'contain' => [],
        ]);
        if ($this->request->is(['patch', 'post', 'put'])) {
            $user = $this->Users->patchEntity($user, $this->request->getData());
            if ($this->Users->save($user)) {
                $this->Flash->success(__('The user has been saved.'));

                return $this->redirect(['action' => 'index']);
            }
            $this->Flash->error(__('The user could not be saved. Please, try again.'));
        }
        $this->set(compact('user'));
    }

    /**
     * Delete method
     *
     * @param string|null $id User id.
     * @return \Cake\Http\Response|null|void Redirects to index.
     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
     */
    public function delete($id = null)
    {
        $this->request->allowMethod(['post', 'delete']);
        $user = $this->Users->get($id);
        if ($this->Users->delete($user)) {
            $this->Flash->success(__('The user has been deleted.'));
        } else {
            $this->Flash->error(__('The user could not be deleted. Please, try again.'));
        }

        return $this->redirect(['action' => 'index']);
    }

    public function login()
    {
        $this->request->allowMethod(['get', 'post']);
        $result = $this->Authentication->getResult();
        // POST, GET を問わず、ユーザーがログインしている場合はリダイレクトします
        if ($result->isValid()) {
            return $this->redirect('/admin');
        }
        // ユーザーが submit 後、認証失敗した場合は、エラーを表示します
        if ($this->request->is('post') && !$result->isValid()) {
            $this->Flash->error('ユーザー名かパスワードが正しくありません。');
        }
    }


    //ここから追加
    public function logout()
    {
        $result = $this->Authentication->getResult();
        // POST, GET を問わず、ユーザーがログインしている場合はリダイレクトします
        if ($result && $result->isValid()) {
            $this->Authentication->logout();
            return $this->redirect(['controller' => 'Users', 'action' => 'login']);
        }
    }
    //ここまで追加


}

ログアウト機能の記述はそのまま使いますがログアウトに遷移するボタンがないので作成します。

ログアウトのボタンは管理画面の全てのページにあった方がいいので管理画面専用のレイアウト作ってそこに記述します。

「CakePHPのプロジェクト > templates > Admin」の下の階層に「layoutフォルダ」を作成してその下の階層に「default.php」を作成して中身に下記の記述をします。

<?php
/**
 * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
 * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
 *
 * Licensed under The MIT License
 * For full copyright and license information, please see the LICENSE.txt
 * Redistributions of files must retain the above copyright notice.
 *
 * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
 * @link          https://cakephp.org CakePHP(tm) Project
 * @since         0.10.0
 * @license       https://opensource.org/licenses/mit-license.php MIT License
 * @var \App\View\AppView $this
 */

$cakeDescription = 'CakePHP: the rapid development php framework';
?>
<!DOCTYPE html>
<html>
<head>
    <?= $this->Html->charset() ?>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>管理画面</title>
    <?= $this->Html->meta('icon') ?>

    <link href="https://fonts.googleapis.com/css?family=Raleway:400,700" rel="stylesheet">

    <?= $this->Html->css(['normalize.min', 'milligram.min', 'cake']) ?>

    <?= $this->fetch('meta') ?>
    <?= $this->fetch('css') ?>
    <?= $this->fetch('script') ?>
</head>
<body>
    <nav class="top-nav">
        <div class="top-nav-title">
            <a href="/cakephp/CakeBlog1/admin">ControlPanel</a>
        </div>
        <div class="top-nav-links">
            <a href="/cakephp/CakeBlog1/admin/posts">投稿</a>
            <a href="/cakephp/CakeBlog1/admin/users">ユーザー</a>
            <a href="/cakephp/CakeBlog1/admin/users/logout">ログアウト</a>
        </div>
    </nav>
    <main class="main">
        <div class="container">
            <?= $this->Flash->render() ?>
            <?= $this->fetch('content') ?>
        </div>
    </main>
    <footer>
    </footer>
</body>
</html>

これで「http://localhost:8888/cakephp/CakeBlog1/admin」にアクセスすると下記の画面になり「ログアウト」ができるボタンが表示されるのを確認できます。

多言語化

今回の内容とは関係ないですがおまけで付けます。

現在のサイトは日本語ですが言語の切り替えができるようにします。

切り替えができるページの確認

「cakePHPのプロジェクト > templates > Posts > view.php」を開きます。

<?php
/**
 * @var \App\View\AppView $this
 * @var \App\Model\Entity\Post $post
 */
?>
<div class="row">
    <aside class="column">
        <div class="side-nav">
            <h4 class="heading"><?= __('Actions') ?></h4>
            <?= $this->Html->link(__('Edit Post'), ['action' => 'edit', $post->id], ['class' => 'side-nav-item']) ?>
            <?= $this->Form->postLink(__('Delete Post'), ['action' => 'delete', $post->id], ['confirm' => __('Are you sure you want to delete # {0}?', $post->id), 'class' => 'side-nav-item']) ?>
            <?= $this->Html->link(__('List Posts'), ['action' => 'index'], ['class' => 'side-nav-item']) ?>
            <?= $this->Html->link(__('New Post'), ['action' => 'add'], ['class' => 'side-nav-item']) ?>
        </div>
    </aside>
    <div class="column-responsive column-80">
        <div class="posts view content">
            <h3><?= h($post->title) ?></h3>
            <table>
                <tr>
                    <th><?= __('Title') ?></th>
                    <td><?= h($post->title) ?></td>
                </tr>
                <tr>
                    <th><?= __('Id') ?></th>
                    <td><?= $this->Number->format($post->id) ?></td>
                </tr>
                <tr>
                    <th><?= __('Created') ?></th>
                    <td><?= h($post->created) ?></td>
                </tr>
                <tr>
                    <th><?= __('Modified') ?></th>
                    <td><?= h($post->modified) ?></td>
                </tr>
                <tr>
                    <th><?= __('Published') ?></th>
                    <td><?= $post->published ? __('Yes') : __('No'); ?></td>
                </tr>
            </table>
            <div class="text">
                <strong><?= __('Description') ?></strong>
                <blockquote>
                    <?= $this->Text->autoParagraph(h($post->description)); ?>
                </blockquote>
            </div>
            <div class="text">
                <strong><?= __('Body') ?></strong>
                <blockquote>
                    <?= $this->Text->autoParagraph(h($post->body)); ?>
                </blockquote>
            </div>
        </div>
    </div>
</div>

「__(‘ ‘)」となっている部分が全て多言語化できます。

多言語化の設定

下記のコマンドを叩きます。

bin/cake i18n

すると質問が複数出てくるので答えます。

[E]xtract POT file from sources
[I]nitialize a language from POT file
[H]elp
[Q]uit
What would you like to do? (E/I/H/Q) 

これは「E」にします。

Current paths: None
What is the path you would like to extract?
[Q]uit [D]one

「Enter」を押します。

Current paths: /Applications/MAMP/htdocs/cakephp/CakeBlog2/src/
What is the path you would like to extract?
[Q]uit [D]one

「Enter」を押します。

Current paths: /Applications/MAMP/htdocs/cakephp/CakeBlog2/src/, /Applications/MAMP/htdocs/cakephp/CakeBlog2/templates/
What is the path you would like to extract?
[Q]uit [D]one

「Enter」を押します。

Would you like to extract the messages from the CakePHP core? (y/n) 

「Enter」を押します。

What is the path you would like to output?
[Q]uit

「Enter」を押します。

What would you like to do? (E/I/H/Q) 

「Q」を押します。

そして「cakePHPのプロジェクト > resources > locales」の下の階層に「ja_JP」フォルダを作成します。

同じ階層にある「default.pot」をコピーしてja_JPフォルダの下の階層において「default.po」にリネームします。

default.poを開きます。

# LANGUAGE translation of CakePHP Application
# Copyright YEAR NAME <EMAIL@ADDRESS>
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"POT-Creation-Date: 2024-02-03 19:49+0900\n"
"PO-Revision-Date: YYYY-mm-DD HH:MM+ZZZZ\n"
"Last-Translator: NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <EMAIL@ADDRESS>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n"

#: ./src/Controller/Admin/PostsController.php:55
#: ./src/Controller/Admin/PostsController.php:79
#: ./src/Controller/PostsController.php:58
#: ./src/Controller/PostsController.php:82
msgid "The post has been saved."
msgstr ""

22行目の「msgstr ” “」の「” “」に訳にあたる文字列を入れることで言語の切り替えができます。

試しに163行目の「” “」の中に適当な文字列を入れてみます。

#: ./templates/Admin/Posts/view.php:27
msgid "Title"
msgstr "Titleをタイトルに変更"

変更するページのURLは「http://localhost:8888/cakephp/CakeBlog1/admin」で上記のコードより「Titleカラム」が変更になるはずですがページの確認をしても変更されていません。

これはページのキャッシュが残っているからなのでキャッシュファイルを削除します。

キャッシュファイルは「cakePHPのプロジェクト > tmp > cache > persistent」の下層にある名前に「translations」が付いているやつです、これを全て削除します。

これで「Titleカラム」が切り替わっているのが確認できます。

他の項目も変更します、変更した部分のみを下記に記載します。

#: ./templates/Admin/Posts/view.php:12
msgid "Delete Post"
msgstr "投稿の削除"

#: ./templates/Admin/Posts/view.php:27
msgid "Title"
msgstr "タイトル"

#: ./templates/Admin/Posts/view.php:31
#: ./templates/Admin/Users/view.php:26
msgid "Id"
msgstr "ID"

#: ./templates/Admin/Posts/view.php:35
#: ./templates/Admin/Users/view.php:30
msgid "Created"
msgstr "登録日"

#: ./templates/Admin/Posts/view.php:39
#: ./templates/Admin/Users/view.php:34
msgid "Modified"
msgstr "編集日"

#: ./templates/Admin/Posts/view.php:43
msgid "Published"
msgstr "公開"

#: ./templates/Admin/Posts/view.php:44
msgid "Yes"
msgstr "はい"

#: ./templates/Admin/Posts/view.php:44
msgid "No"
msgstr "いいえ"

#: ./templates/Admin/Posts/view.php:48
msgid "Description"
msgstr "概要"

これで変更が適用されます。

今の状態は誰が記事を投稿したか分からない状態ですが今回は1対多アソシエーションを使って誰が記事を投稿したか分かるようにします。

また多対多アソシエーションを使って記事とタグを紐付けします。

アソシエーションはモデルとモデルを繋ぐ機能のことです。

まずは1対多アソシエーションから説明します。

ユーザーと記事を紐づける

一人のユーザーに記事を結びつけるのでUserモデルとPostモデルを繋ぎます。

ユーザーに記事が結びつくのでUserモデルが親にあたります。

マイグレーションファイルの編集

postsテーブルを作成した時に使ったマイグレーションファイルを編集します。

<?php
declare(strict_types=1);

use Migrations\AbstractMigration;

class CreatePosts extends AbstractMigration
{
    /**
     * Change Method.
     *
     * More information on this method is available here:
     * https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
     * @return void
     */
    public function change(): void
    {
        $table = $this->table('posts');
        $table->addColumn('title', 'string', [
            'limit' => 150,
            'null' => false
        ])
              ->addColumn('description', 'text', [
                  'limit' => 255
              ])
              ->addColumn('body', 'text')
              ->addColumn('published', 'boolean', [
                  'default' => false
              ])
              
              
              //ここから追加
              ->addColumn('user_id', 'integer', [
                  'default' => 1,
                  'limit' => 11,
                  'null' => false
              ])
              //ここまで追加
              
              
              ->addColumn('created', 'datetime')
              ->addColumn('modified', 'datetime')
              ->create();
    }
}

32行目の「user_id」ですがアソシエーションをする時は子にあたるテーブルに「親モデル名_id」カラムを作らないといけないルールがあります。

Seederの編集

PostsSeed.phpを編集します。

<?php
declare(strict_types=1);

use Migrations\AbstractSeed;

/**
 * Posts seed.
 */
class PostsSeed extends AbstractSeed
{
    /**
     * Run Method.
     *
     * Write your database seeder using this method.
     *
     * More information on writing seeds is available here:
     * https://book.cakephp.org/phinx/0/en/seeding.html
     *
     * @return void
     */
    public function run(): void
    {
        $data = [
            // 各レコードにuser_idを追加
            [
                'title' => '最初の投稿',
                'description' => '最初の投稿の概要',
                'body' => '最初の投稿の内容',
                'published' => 1,
                'user_id' => 1,                           //この行を追加
                'created' => '2020-05-02 10:00:00',
                'modified' => '2020-05-02 10:00:00',
            ],
            [
                'title' => '2番目の投稿',
                'description' => '2番目の投稿の概要',
                'body' => '2番目の投稿の内容',
                'published' => 1,
                'user_id' => 1,                           //この行を追加
                'created' => '2020-05-02 10:00:00',
                'modified' => '2020-05-02 10:00:00',
            ],
        ];

        $table = $this->table('posts');
        $table->insert($data)->save();
    }
}

コマンド

下記のコマンドを叩きます。

rm database/product.sqlite
bin/cake migrations migrate
bin/cake migrations seed

これでpostsテーブルが更新されました。

モデルの編集

Entityフォルダの下の階層にあるPost.phpを編集します。

<?php
declare(strict_types=1);

namespace App\Model\Entity;

use Cake\ORM\Entity;

/**
 * Post Entity
 *
 * @property int $id
 * @property string $title
 * @property string $description
 * @property string $body
 * @property bool $published
 * @property \Cake\I18n\FrozenTime $created
 * @property \Cake\I18n\FrozenTime $modified
 */
class Post extends Entity
{
    /**
     * Fields that can be mass assigned using newEntity() or patchEntity().
     *
     * Note that when '*' is set to true, this allows all unspecified fields to
     * be mass assigned. For security purposes, it is advised to set '*' to false
     * (or remove it), and explicitly make individual fields accessible as needed.
     *
     * @var array<string, bool>
     */
    protected $_accessible = [
        'title' => true,
        'description' => true,
        'body' => true,
        'published' => true,
        'user_id' => true,                //この行を追加
        'created' => true,
        'modified' => true,
    ];
}

Tableフォルダの下の階層にあるPostsTable.phpを編集します、ここでアソシエーションの設定をします。

<?php
declare(strict_types=1);

namespace App\Model\Table;

use Cake\ORM\Query;
use Cake\ORM\RulesChecker;
use Cake\ORM\Table;
use Cake\Validation\Validator;

/**
 * Posts Model
 *
 * @method \App\Model\Entity\Post newEmptyEntity()
 * @method \App\Model\Entity\Post newEntity(array $data, array $options = [])
 * @method \App\Model\Entity\Post[] newEntities(array $data, array $options = [])
 * @method \App\Model\Entity\Post get($primaryKey, $options = [])
 * @method \App\Model\Entity\Post findOrCreate($search, ?callable $callback = null, $options = [])
 * @method \App\Model\Entity\Post patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = [])
 * @method \App\Model\Entity\Post[] patchEntities(iterable $entities, array $data, array $options = [])
 * @method \App\Model\Entity\Post|false save(\Cake\Datasource\EntityInterface $entity, $options = [])
 * @method \App\Model\Entity\Post saveOrFail(\Cake\Datasource\EntityInterface $entity, $options = [])
 * @method \App\Model\Entity\Post[]|\Cake\Datasource\ResultSetInterface|false saveMany(iterable $entities, $options = [])
 * @method \App\Model\Entity\Post[]|\Cake\Datasource\ResultSetInterface saveManyOrFail(iterable $entities, $options = [])
 * @method \App\Model\Entity\Post[]|\Cake\Datasource\ResultSetInterface|false deleteMany(iterable $entities, $options = [])
 * @method \App\Model\Entity\Post[]|\Cake\Datasource\ResultSetInterface deleteManyOrFail(iterable $entities, $options = [])
 *
 * @mixin \Cake\ORM\Behavior\TimestampBehavior
 */
class PostsTable extends Table
{
    /**
     * Initialize method
     *
     * @param array $config The configuration for the Table.
     * @return void
     */
    public function initialize(array $config): void
    {
        parent::initialize($config);

        $this->setTable('posts');
        $this->setDisplayField('title');
        $this->setPrimaryKey('id');

        $this->addBehavior('Timestamp');

        $this->belongsTo('Users');           //この行を追加
    }

    /**
     * Default validation rules.
     *
     * @param \Cake\Validation\Validator $validator Validator instance.
     * @return \Cake\Validation\Validator
     */
    public function validationDefault(Validator $validator): Validator
    {
        $validator
            ->scalar('title')
            ->maxLength('title', 150)
            ->requirePresence('title', 'create')
            ->notEmptyString('title');

        $validator
            ->scalar('description')
            ->requirePresence('description', 'create')
            ->allowEmptyString('description');

        $validator
            ->scalar('body')
            ->requirePresence('body', 'create')
            ->allowEmptyString('body');

        $validator
            ->boolean('published')
            ->notEmptyString('published');


        //ここから追加
        $validator
            ->boolean('user_id')
            ->notEmptyString('user_id');
        //ここまで追加
        

        return $validator;
    }
}

48行目がアソシエーションをする為の記述です。

またTableフォルダの下の階層にあるUsersTable.phpも編集します。

<?php
declare(strict_types=1);

namespace App\Model\Table;

use Cake\ORM\Query;
use Cake\ORM\RulesChecker;
use Cake\ORM\Table;
use Cake\Validation\Validator;

/**
 * Users Model
 *
 * @method \App\Model\Entity\User newEmptyEntity()
 * @method \App\Model\Entity\User newEntity(array $data, array $options = [])
 * @method \App\Model\Entity\User[] newEntities(array $data, array $options = [])
 * @method \App\Model\Entity\User get($primaryKey, $options = [])
 * @method \App\Model\Entity\User findOrCreate($search, ?callable $callback = null, $options = [])
 * @method \App\Model\Entity\User patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = [])
 * @method \App\Model\Entity\User[] patchEntities(iterable $entities, array $data, array $options = [])
 * @method \App\Model\Entity\User|false save(\Cake\Datasource\EntityInterface $entity, $options = [])
 * @method \App\Model\Entity\User saveOrFail(\Cake\Datasource\EntityInterface $entity, $options = [])
 * @method \App\Model\Entity\User[]|\Cake\Datasource\ResultSetInterface|false saveMany(iterable $entities, $options = [])
 * @method \App\Model\Entity\User[]|\Cake\Datasource\ResultSetInterface saveManyOrFail(iterable $entities, $options = [])
 * @method \App\Model\Entity\User[]|\Cake\Datasource\ResultSetInterface|false deleteMany(iterable $entities, $options = [])
 * @method \App\Model\Entity\User[]|\Cake\Datasource\ResultSetInterface deleteManyOrFail(iterable $entities, $options = [])
 *
 * @mixin \Cake\ORM\Behavior\TimestampBehavior
 */
class UsersTable extends Table
{
    /**
     * Initialize method
     *
     * @param array $config The configuration for the Table.
     * @return void
     */
    public function initialize(array $config): void
    {
        parent::initialize($config);

        $this->setTable('users');
        $this->setDisplayField('username');
        $this->setPrimaryKey('id');

        $this->addBehavior('Timestamp');

        $this->hasMany('Posts');              //この行を追加
    }

    /**
     * Default validation rules.
     *
     * @param \Cake\Validation\Validator $validator Validator instance.
     * @return \Cake\Validation\Validator
     */
    public function validationDefault(Validator $validator): Validator
    {
        $validator
            ->scalar('username')
            ->maxLength('username', 50)
            ->requirePresence('username', 'create')
            ->notEmptyString('username');

        $validator
            ->scalar('password')
            ->maxLength('password', 255)
            ->requirePresence('password', 'create')
            ->notEmptyString('password');

        return $validator;
    }

    /**
     * Returns a rules checker object that will be used for validating
     * application integrity.
     *
     * @param \Cake\ORM\RulesChecker $rules The rules object to be modified.
     * @return \Cake\ORM\RulesChecker
     */
    public function buildRules(RulesChecker $rules): RulesChecker
    {
        $rules->add($rules->isUnique(['username']), ['errorField' => 'username']);

        return $rules;
    }
}

48行目がアソシエーションをする為の設定です。

次は投稿者を表示します。

コントローラーの編集

「cakePHPのプロジェクト > src > Controller」の下の階層にあるPostsController.phpのviewメソッドを修正します。

public function view($id = null)
{
  $post = $this->Posts->get($id, [
    'contain' => 'Users'
  ]);

  $this->set(compact('post'));
}

今PostモデルとUserモデルのアソシエーションをしていますがpostsテーブルに関連するusersテーブルの情報を使う為に4行目があります。

getメソッドの第二引数の連想配列の部分はkeyにcontainを指定してvalueにモデル名s(今はUsers)をしています。

ビューの編集

templatesフォルダの直下にあるPostsフォルダの下の階層にあるview.phpを修正します。

<div class="content">
    <p><?= $post->created->i18nFormat('YYYY年MM月dd日 HH:mm') ?></p>
    <h1><?= h($post->title) ?></h1>
    <?= $this->Text->autoParagraph(h($post->body)) ?>
    <p><small>投稿者 : <?= h($post->user->username) ?></small></p>
    <hr>
    <?= $this->Html->link('一覧へ戻る', [
        'action' => 'index'
    ], ['class' => 'button']) ?>
</div>

5行目にアソシエーションを使っています。

これで「http://localhost:8888/cakephp/CakeBlog1/Posts/view/2」にアクセスすると表示が下記になります。

次は記事一覧のページにもユーザー名を表示します。

PostsController.phpのindexメソッドを修正します。

public function index()
{
  $posts = $this->paginate($this->Posts->find()->contain(['Users']));           //この行を修正

  $this->set(compact('posts'));
}

「->contain([‘Users’])」を追加しました。

そして「cakePHPのプロジェクト > templates > Posts」の下の階層にあるindex.phpを修正します。

<div class="content">
    <?php foreach($posts as $post): ?>
        <h3><?= h($post->title) ?></h3>
        <p><?= $post->created->i18nFormat('YYYY年MM月dd日 HH:mm') ?></p>
        <p><?= h($post->description) ?></p>
        <p><small>投稿者 : <?= h($post->user->username) ?></small></p>        //この行を追加

これで「http://localhost:8888/cakephp/CakeBlog1/Posts/」にアクセスすると下記の表示になります。

次は管理画面でもユーザー名を表示させます。

Adminフォルダの下の階層にあるPostsController.phpのindexアクションを修正します、ページネーションを一緒に追加します。

public function index()
{
  $posts = $this->paginate($this->Posts->find()->contain(['Users']));       //この行を修正

  $this->set(compact('posts'));
}

次はtemplates > Admin > Postsフォルダの下の階層のindex.phpを修正します。

<?php
/**
 * @var \App\View\AppView $this
 * @var iterable<\App\Model\Entity\Post> $posts
 */
?>
<div class="posts index content">
    <?= $this->Html->link(__('New Post'), ['action' => 'add'], ['class' => 'button float-right']) ?>
    <h3><?= __('Posts') ?></h3>
    <div class="table-responsive">
        <table>
            <thead>
                <tr>
                    <th><?= $this->Paginator->sort('id') ?></th>
                    <th><?= $this->Paginator->sort('title') ?></th>
                    <th><?= $this->Paginator->sort('user_id') ?></th>          //この行を追加    
                    <th><?= $this->Paginator->sort('published') ?></th>
                    <th><?= $this->Paginator->sort('created') ?></th>
                    <th><?= $this->Paginator->sort('modified') ?></th>
                    <th class="actions"><?= __('Actions') ?></th>
                </tr>
            </thead>
            <tbody>
                <?php foreach ($posts as $post): ?>
                <tr>
                    <td><?= $this->Number->format($post->id) ?></td>            
                    <td><?= h($post->title) ?></td>
                    <td><?= h($post->user->username) ?></td>                  //この行を追加
                    <td><?= h($post->published) ?></td>

これで「http://localhost:8888/cakephp/CakeBlog1/admin/posts」にアクセスするとユーザー名が表示されます。

次は記事の詳細画面です。

さっきのPostsController.phpのviewメソッドを修正します。

public function view($id = null)
{
  $post = $this->Posts->get($id, [
    'contain' => ['Users'],              //この行を修正
  ]);

  $this->set(compact('post'));
}

そして「Admin > Posts > view.php」を修正します。


  <tr>
    <th><?= __('Modified') ?></th>
    <td><?= h($post->modified) ?></td>
  </tr>
  <tr>
    <th><?= __('Published') ?></th>
    <td><?= $post->published ? __('Yes') : __('No'); ?></td>
  </tr>
  
  
  //ここから追加
  <tr>
    <th><?= __('User') ?></th>
    <td><?= h($post->user->username) ?></td>
  </tr>
  //ここまで追加
  
  
</table>

これで「http://localhost:8888/cakephp/CakeBlog1/admin/posts/view/1」にアクセスすると下記の表示になります。

次は記事の編集画面でユーザー名を編集できるのをselectタグでします。

さっきのPostsController.phpのeditメソッドを修正します。

<?php
declare(strict_types=1);

namespace App\Controller\Admin;

use App\Controller\Admin\AdminController;

/**
 * Posts Controller
 *
 * @property \App\Model\Table\PostsTable $Posts
 * @method \App\Model\Entity\Post[]|\Cake\Datasource\ResultSetInterface paginate($object = null, array $settings = [])
 */
class PostsController extends AdminController
{
    public $Users = null;             //この行を追加
    
    public $paginate = [
        'limit' => 3,
        'order' => [
            'Posts.created' => 'desc'
        ],              
    ];


    //ここから追加
    public function initialize(): void{
        parent::initialize();

        $this->loadModel('Users');
    }
    //ここまで追加






    /**
     * Edit method
     *
     * @param string|null $id Post id.
     * @return \Cake\Http\Response|null|void Redirects on successful edit, renders view otherwise.
     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
     */
    public function edit($id = null)
    {
        $post = $this->Posts->get($id, [
            'contain' => [],
        ]);
        if ($this->request->is(['patch', 'post', 'put'])) {
            $post = $this->Posts->patchEntity($post, $this->request->getData());
            if ($this->Posts->save($post)) {
                $this->Flash->success(__('The post has been saved.'));

                return $this->redirect(['action' => 'index']);
            }
            $this->Flash->error(__('The post could not be saved. Please, try again.'));
        }

        $users = $this->Users->find('list');            //この行を追加

        $this->set(compact('post', 'users'));           //この行を編集
    }

このコントローラーでは60行目でUsersを使っていますが同じ名称以外のモデル(今回はUser)を使う場合は29行目の記述をします、引数は「モデル名s」です。

26行目〜30行目のintializeメソッドの中に記述します、これはコントローラーが呼び出された時に最初に実行するメソッドです。

27行目は必ず記述します。

次に「Admin > Posts」の下の階層にあるedit.phpを修正します。

<?php
  echo $this->Form->control('title');
  echo $this->Form->control('description');
  echo $this->Form->control('body');
  echo $this->Form->control('published');
  echo $this->Form->control('user_id');             //この行を追加
?>

そして「http://localhost:8888/cakephp/CakeBlog1/admin/posts/edit/1」にアクセスすると下記のようにユーザーを選択できる形で編集ができるようになります。

次は多対多アソシエーションです。

タグと記事を紐づける

タグと記事ですがお互いに複数紐づきます、こんな時に多対多アソシエーションを使います。

Tag用のテーブルの作成

マイグレーションファイルを作成する為に下記のコマンドを叩きます。

bin/cake bake migration CreateTags

作成したマイグレーションファイルに下記の記述をします。

<?php
declare(strict_types=1);

use Migrations\AbstractMigration;

class CreateTags extends AbstractMigration
{
    /**
     * Change Method.
     *
     * More information on this method is available here:
     * https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
     * @return void
     */
    public function change(): void
    {
        $table = $this->table('tags');
        $table
              ->addColumn('title', 'text', [
                'limit' => 100,
                'null' => false
              ])
              ->addColumn('created', 'datetime')
              ->addColumn('modified', 'datetime')
              ->create();
        $table = $this->table('posts_tags');
        $table
              ->addColumn('post_id', 'integer', [
                'null' => false
              ])
              ->addColumn('tag_id', 'integer', [
                'null' => false
              ])
              ->create();
    }
}

多対多アソシエーションをする場合は中間テーブルというテーブルを作成しないといけません、26行目〜34行目に当たります。

今回はpostsテーブルとtagsテーブルを紐づけますが「テーブル名_id」カラムを作成しないといけません。

その為に28行目(post_id)と31行目(tag_id)があります。

記述が終わったら下記のコマンドを叩きます。

 bin/cake migrations migrate

これでpostsテーブルとposts_tagsテーブルが作成されます。

次はテーブルにデータを挿入する為のSeederを作成します。

Seederの作成

下記のコマンドを叩きます。

bin/cake bake seed Tags

そしてSeederファイルの中身を下記にします。

<?php
declare(strict_types=1);

use Migrations\AbstractSeed;

/**
 * Tags seed.
 */
class TagsSeed extends AbstractSeed
{
    /**
     * Run Method.
     *
     * Write your database seeder using this method.
     *
     * More information on writing seeds is available here:
     * https://book.cakephp.org/phinx/0/en/seeding.html
     *
     * @return void
     */
    public function run(): void
    {
        $data = [
            [
                'title' => 'タグ1',
                'created' => '2020-05-02 10:00:00',
                'modified' => '2020-05-02 10:00:00',
            ],
            [
                'title' => 'タグ2',
                'created' => '2020-05-03 10:00:00',
                'modified' => '2020-05-03 10:00:00',
            ],
            [
                'title' => 'タグ3',
                'created' => '2020-05-04 10:00:00',
                'modified' => '2020-05-04 10:00:00',
            ],
        ];

        $table = $this->table('tags');
        $table->insert($data)->save();

        $data = [
            [
                'post_id' => 1,
                'tag_id' => 1,
            ],
            [
                'post_id' => 1,
                'tag_id' => 2,
            ],
            [
                'post_id' => 2,
                'tag_id' => 3,
            ],
            [
                'post_id' => 3,
                'tag_id' =>1,
            ],
        ];

        $table = $this->table('posts_tags');
        $table->insert($data)->save();
    }
}

41行目〜64行目は中間テーブル用のデータです。

記述が終わったら下記のコマンドを叩きます。

bin/cake migrations seed --seed TagsSeed

これでSeederの情報がテーブルに追加されました。

次はモデルを作成します。

モデルの作成

下記のコマンドを叩きます。

CakeBlog3 % bin/cake bake model tags

そしてEntityフォルダの下の階層にあるPost.phpを修正します。

protected $_accessible = [
  'title' => true,
  'description' => true,
  'body' => true,
  'published' => true,
  'user_id' => true,
  'tags' => true,                //この行を追加
  'created' => true,
  'modified' => true,
];

そしてTableフォルダの下の階層にあるPostsTable.phpを修正します。

public function initialize(array $config): void
{
  parent::initialize($config);

  $this->setTable('posts');
  $this->setDisplayField('title');
  $this->setPrimaryKey('id');

  $this->addBehavior('Timestamp');

  $this->belongsTo('Users');
  
  
  //ここから追加
  $this->belongsToMany('Tags', [
    'foreignKey' => 'post_id',         
    'targetForeignKey' => 'tag_id',    
    'joinTable' => 'posts_tags'      
  ]);
  //ここまで追加
  
  
}

15行目〜19行目ですが第2引数に連想配列の形でキーの指定をします。

16行目は自分のキーで17行目は相手のキーで18行目は中間テーブル名です。

これでモデルの設定が終わったので次はコントローラーの作成です。

コントローラーとビューファイルの作成

「src > Controller」の下の階層にTagsController.phpを作成して中身を下記にします。

<?php
declare(strict_types=1);

namespace App\Controller;

/**
 * Posts Controller
 *
 * @method \App\Model\Entity\Post[]|\Cake\Datasource\ResultSetInterface paginate($object = null, array $settings = [])
 */
class TagsController extends AppController
{
    /**
     * Index method
     *
     * @return \Cake\Http\Response|null|void Renders view
     */
    public function index()
    {
        $this->paginate = [
            'limit' => 30,
            'order' => [
                'created' => 'desc'
            ],
        ];

        $tags = $this->paginate($this->Tags->find());

        $this->set(compact('tags'));
    }

    /**
     * View method
     *
     * @param string|null $id Post id.
     * @return \Cake\Http\Response|null|void Renders view
     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
     */
    public function view($id = null)
    {
        $post = $this->Posts->get($id, [
            'contain' => 'Users'
        ]);

        $this->set(compact('post'));
    }

    /**
     * Add method
     *
     * @return \Cake\Http\Response|null|void Redirects on successful add, renders view otherwise.
     */
    public function add()
    {
        $post = $this->Posts->newEmptyEntity();
        if ($this->request->is('post')) {
            $post = $this->Posts->patchEntity($post, $this->request->getData());
            if ($this->Posts->save($post)) {
                $this->Flash->success(__('The post has been saved.'));

                return $this->redirect(['action' => 'index']);
            }
            $this->Flash->error(__('The post could not be saved. Please, try again.'));
            $this->log(print_r($article->errors(),true),LOG_DEBUG);
        }
        $this->set(compact('post'));
    }

    /**
     * Edit method
     *
     * @param string|null $id Post id.
     * @return \Cake\Http\Response|null|void Redirects on successful edit, renders view otherwise.
     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
     */
    public function edit($id = null)
    {
        $post = $this->Posts->get($id, [
            'contain' => [],
        ]);
        if ($this->request->is(['patch', 'post', 'put'])) {
            $post = $this->Posts->patchEntity($post, $this->request->getData());
            if ($this->Posts->save($post)) {
                $this->Flash->success(__('The post has been saved.'));

                return $this->redirect(['action' => 'index']);
            }
            $this->Flash->error(__('The post could not be saved. Please, try again.'));
        }
        $this->set(compact('post'));
    }

    /**
     * Delete method
     *
     * @param string|null $id Post id.
     * @return \Cake\Http\Response|null|void Redirects to index.
     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
     */
    public function delete($id = null)
    {
        $this->request->allowMethod(['post', 'delete']);
        $post = $this->Posts->get($id);
        if ($this->Posts->delete($post)) {
            $this->Flash->success(__('The post has been deleted.'));
        } else {
            $this->Flash->error(__('The post could not be deleted. Please, try again.'));
        }

        return $this->redirect(['action' => 'index']);
    }
}

templatesフォルダの下の階層にTagsフォルダを作成してPostsフォルダの中にあるindex.phpとview.phpをコピーしてTagsフォルダの中に設置します。

Tagsフォルダの中にあるindex.phpを下記に変更します。

<div class="content">
    <ul>
        <?php foreach($tags as $tag) :?>
            <li>
                <time><?= $tag->created->i18nFormat('YYYY年MM月dd日 HH:mm') ?></time>
                <?= $this->Html->link($tag->title, ['controller' => 'tags', 'action' => 'view', $tag->id]) ?>
            </li>
        <?php endforeach ?>
    </ul>

    <?php if($this->Paginator->total() > 1) :?>
        <div class="paginator">
            <ul class="pagination">
                <?= $this->Paginator->first('<< 最初') ?>
                <?= $this->Paginator->prev('< 前へ') ?>
                <?= $this->Paginator->numbers() ?>
                <?= $this->Paginator->next('次へ >') ?>
                <?= $this->Paginator->last('最後 >>') ?>
            </ul>
        </div>
    <?php endif ?>
</div>

そして「http://localhost:8888/cakephp/CakeBlog1/tags」にアクセスすると下記の表示になります。

次は詳細ページを作成します、タグに関連した記事の内容を表示します。

TagsController.phpのviewメソッドを下記にします。

public function view($id = null)
{
  $tag = $this->Tags->get($id, [
    'contain' => 'Posts'
  ]);

  $this->paginate = [
    'limit' => 10,
    'order' => [
      'Posts.created' => 'desc'
    ],
    'contain' => ['Users', 'Tags']
  ];

  $posts = $this->Posts->find()->matching('Tags', function(Query $q) use($id){
    return $q->where(['Tags.id' => $id]);
  });
  
  $posts = $this->paginate($posts);           

  $this->set(compact('tag', 'posts'));
}

15行目〜17行目で記事の絞り込みをしています。

15行目の「matching(‘Tags〜」の「Tags」が絞り込みに使うモデル名で「function」の中が検索条件です。

15行目の「use($id)」でPostsテーブルのidカラムを「matching」の中で使うことができるようにして16行目の「’Tags.id’ => $id」でPostsテーブルのidカラムとTagsテーブルのidカラムが一致するデータを取得しています。

ただ、TagsController.phpではPostモデルが使えないのでTagsController.phpでPostモデルを使うことができるようにします。

TagsController.phpに追記します。

<?php
declare(strict_types=1);

namespace App\Controller;

use Cake\ORM\Query;              //この行を追加

/**
 * Posts Controller
 *
 * @method \App\Model\Entity\Post[]|\Cake\Datasource\ResultSetInterface paginate($object = null, array $settings = [])
 */
class TagsController extends AppController
{


    //ここから追加
    public $Posts = null;

    public function initialize(): void{
        parent::initialize();

        $this->loadModel('Posts');
    }
    //ここまで追加
    
    

18行目〜24行目でPostモデルが使えるようになります。

「’contain’ => [‘Users’, ‘Tags’]」に「Users」があるのでUserモデルを使っていますが「$this->set(compact(‘tag’, ‘posts’));」の所でUserモデルを使っていないので「$this->loadModel(‘Users’)」は必要ありません。

そしてTagsフォルダの下の階層にあるview.phpを下記に変更します。

<div class="content">
    <h1>「<?= h($tag->title) ?>」の投稿一覧</h1>
    <?php foreach($posts as $post) :?>
        <h3><?= h($post->title) ?></h3>
        <p><?= $post->created->i18nFormat('YYYY年MM月dd日 HH:mm') ?></p>
        <p><?= h($post->description) ?></p>
        <p><small>投稿者 : <?= h($post->user->username) ?></small></p>
        <?= $this->Html->link('記事を読む', ['controller' => 'Posts', 'action' => 'view', $post->id], ['class' => 'button']) ?>
        <hr>
    <?php endforeach ?>
    <?= $this->Html->link('一覧へ戻る', ['action' => 'index'], ['class' => 'button']) ?>
</div>

そしてタグの詳細を見ると下記の警告が表示されます。

警告内容は下記になります。

Deprecated: Passing query options as paginator settings is deprecated. Use a custom finder through `finder` config instead. Extra keys found are: contain /Applications/MAMP/htdocs/cakephp/CakeBlog3/vendor/cakephp/cakephp/src/Datasource/Paging/NumericPaginator.php, line: 189 You can disable all deprecation warnings by setting `Error.errorLevel` to `E_ALL & ~E_USER_DEPRECATED`. Adding `vendor/cakephp/cakephp/src/Datasource/Paging/NumericPaginator.php` to `Error.ignoredDeprecationPaths` in your `config/app.php` config will mute deprecations from that file only. in /Applications/MAMP/htdocs/cakephp/CakeBlog3/vendor/cakephp/cakephp/src/Core/functions.php on line 318

ネットで調べても解決方法を見つけることができなかったのですが ChatGTPに質問してとりあえず対処に近い解決方法を見つけました。

「CakePHPのプロジェクト > config > app.php」の「Error」の項目に追記すればいいです。

'Error' => [
  'errorLevel' => E_ALL,
  'exceptionRenderer' => ExceptionRenderer::class,
  'skipLog' => [],
  'log' => true,
  'trace' => true,
  
  
  //ここから追加
  'ignoredDeprecationPaths' => [
      'vendor/cakephp/cakephp/src/Datasource/Paging/NumericPaginator.php'
  ],
  //ここまで追加
  
  
]

これで警告はなくなります。

おすすめ参考書

CakePHP3はまだまだ仕事で使われます。

下記の参考書がおすすめです。