CakePHP3・4のネットでは見つからないちょっとした実装集
203 回閲覧されました
みなさんこんにちは、jonioです。
CakePHPに関して検索しても見つけることができなくて苦労して実装した実装集を掲載します。
情報が見つかり次第追記します。
目次
アソシエーション時に条件を満たしたら削除ボタンを表示
アイドル会社1とアイドル会社2があってそれぞれの会社に部署1と部署2があり部署1と部署2にはそれぞれ従業員が3人いたとします。
会社のカラムは下記になります。
部署のカラムは下記になります。
従業員のカラムは下記になります。
会社はcompaniesテーブル、部署はdepartmentsテーブル、従業員はusersテーブルに情報が保存されているとします。
アソシエーションの関係は以下になります。
- 従業員(usersテーブル) :companiesテーブルとdepartmentsテーブルに対して子テーブル
- 部署(departmentsテーブル) : companiesテーブルに対して子テーブル
- 会社(companiesテーブル) : departmentsテーブルとusersテーブルに対して親テーブル
アソシエーションの設定はしたとします。
viewアクション
「http://localhost/companies/view/1」にアクセスした時に下記の表示にするのを目標にします。
会社の中で部署に紐づいているユーザーがいますが部署に紐づくユーザーがいなくなったら削除ボタンが表示されるようにします。
Companiesコントローラーのviewアクションのコードは下記になります。
public function view($id = null)
{
$company = $this->Companies->get($id, [
'contain' => ['Users'],
]);
$companyId = $this->Auth->user('company_id');
$departments = $this->Companies->Departments->find()
->where(['company_id' => $companyId])
->contain(['Users'])
->all();
$this->set(compact('company', 'departments'));
}
今回の実装に関係あるのは7行目〜12行目です。
会社に紐づく部署を考えるので9行目の「->Companies->Departments」があります。
実装に使う会社はユーザーがログインしている会社にしないといけないので会社idを7行目で取得して10行目で使っています。
これだけだとユーザーが考慮されていないのでユーザーも含めます。
ユーザーがログインしている会社の中のユーザーを使うので11行目があります。
companiesテーブルに紐づくdepartmentsテーブルに紐づくユーザーが取得できているかを確認します。
viewアクションの中に下記の記述をします。
public function view($id = null)
{
$company = $this->Companies->get($id, [
'contain' => ['Users'],
]);
$companyId = $this->Auth->user('company_id');
$departments = $this->Companies->Departments->find()
->where(['company_id' => $companyId])
->contain(['Users'])
->all();
dd($departments); //←この行を追加
$this->set(compact('company', 'departments'));
}
すると会社の部署に紐づいているユーザーがいる場合は下記の表示になります。
会社の部署に紐づいているユーザーがいない場合は下記の表示になります。
viewアクションで表示するビューのコードを下記にします。
<div class="related">
<h4><?= __('部署') ?></h4>
<div class="table-responsive">
<table>
<tr>
<th><?= __('Name') ?></th>
<th class="actions"><?= __('Actions') ?></th>
</tr>
<?php foreach ($departments as $department) : ?>
<tr>
<td><?= h($department->name) ?></td>
<td class="actions">
<?= $this->Html->link(__('View'), ['controller' => 'Departments', 'action' => 'view', $department->id]) ?>
<?= $this->Html->link(__('Edit'), ['controller' => 'Departments', 'action' => 'edit', $department->id]) ?>
<?php if(count($department->users) == 0): ?>
<?= $this->Form->postLink(__('Delete'), ['controller' => 'Departments', 'action' => 'delete', $department->id], ['confirm' => __('Are you sure you want to delete # {0}?', $department->id)]) ?>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</table>
</div>
</div>
部署に紐づく従業員が0人の時のみ削除ボタンを表示させているのが15行目です。
viewアクションで「dd($departments);」をして部署に紐づくユーザーを取得するのは「$department->users」です。(usersは下記参照)
これで実装が完成です。
1つのカラムを登録すると他のカラムも自動的に登録される
例えばusersテーブルにnameカラムとemailカラムとpasswordカラムがあったとします。
postsテーブルに紐づくビューでusersテーブルのnameカラムのデータをリストで表示させて名前を選択して登録すると自動的に名前に紐づいているemailカラム・passwordカラムもpostsテーブルに登録させたいみたいな時に使えます。
簡潔にすると「レコードの1つのカラムを登録すると他のカラムが自動的に登録される」と言えます。
今回はnameカラムを登録した時にemailカラムも登録できるようにします。
allコマンドでモデル・ビュー・コントローラーは作成したのを前提に話を進めます。
ユーザーの登録(usersテーブル)はコントローラー・ビュー共にデフォルトのまま使っているのでコードの掲載はしません。
テーブル
usersテーブルは下記です、nameカラム・emailカラム・passwordカラムがありますがnameカラムとemailカラムを使います。
postsテーブルは下記です、titleカラム・contentカラムがあります。
ビュー
コードを下記にします。
<div class="row">
<aside class="column">
<div class="side-nav">
<h4 class="heading"><?= __('Actions') ?></h4>
<?= $this->Html->link(__('List Posts'), ['action' => 'index'], ['class' => 'side-nav-item']) ?>
</div>
</aside>
<div class="column-responsive column-80">
<div class="posts form content">
<?= $this->Form->create() ?>
<fieldset>
<legend><?= __('Add Post') ?></legend>
<?= $this->Form->select('title', ['options' => $users]) ?>
</fieldset>
<?= $this->Form->button(__('Submit')) ?>
<?= $this->Form->end() ?>
</div>
</div>
</div>
13行目が登録フォームです、「’options’ => $users」の$usersはコントローラーから情報を渡します。
コントローラー
コードを下記にします。
public function add()
{
$this->loadModel('Users');
$users = $this->Users
->find('list', ['keyField' => 'name', 'valueField' => 'name'])
->toArray();
$post = $this->Posts->newEmptyEntity();
if ($this->request->is('post')) {
$postData = $this->request->getData();
$user = $this->Users->find()->where(['name' => $postData['title']])->first();
if ($user) {
$postData['content'] = $user->email;
$post = $this->Posts->patchEntity($post, $postData);
if ($this->Posts->save($post)) {
$this->Flash->success(__('The post has been saved.'));
return $this->redirect(['action' => 'index']);
} else {
$this->Flash->error(__('The post could not be saved. Please, try again.'));
}
}
$this->set(compact('users'));
}
5行目の「[‘keyField’ => ‘name‘, ‘valueField’ => ‘name‘]」は「KeyField」が連想配列のkeyに当たりValueFieldが連想配列のvalueに当たります。
keyとvalueに「name」を使っていますがusersテーブルのnameカラムになります、存在しないカラム名を使うとエラーになります。
valueFieldのnameが登録フォームに表示される名前でkeyFieldのnameが登録フォームで名前を選択して登録した時にテーブルに保存される値です。
本当に値が入っているかを確認します。
dd関数を使います。
public function add()
{
$this->loadModel('Users');
$users = $this->Users
->find('list', ['keyField' => 'name', 'valueField' => 'name'])
->toArray();
dd($users); //←この行を追加
・
・
・
登録画面をリロードすると下記の表示になります。
//左の項目がkeyFieldで右の項目がvalueField
[
'jonio' => 'jonio',
'jonio2' => 'jonio2',
]
右の項目が登録フォームに表示される名前で左の項目が実際に登録される値です。
カラム名を2つともnameにして見にくいですが表示される名前と登録される名前が同じの方が対応がわかりやすいと思いわざと同じにしています。
コントローラーのコードに話を戻して登録フォームで名前を選択して登録した時にpostsテーブルのtitleカラムにusersテーブルのnameカラムの値が登録されているかを確認します。
dd関数を使います。
public function add()
{
$this->loadModel('Users');
$users = $this->Users
->find('list', ['keyField' => 'name', 'valueField' => 'name'])
->toArray();
$post = $this->Posts->newEmptyEntity();
if ($this->request->is('post')) {
$postData = $this->request->getData();
dd($postData); //←を追加
登録フォームで名前を選択して登録すると下記の表示になります。
//名前の選択でjonio2を選択しています
[
'title' => 'jonio2',
]
「dd($postData)」ではなく「dd($postData[‘title’])」にするとtitleカラムの値(jonio2)を取得できているのを確認できます。
話をまたコントローラーのコードに戻して今のままではcontentカラムに値を登録することができませんがそれをできるようにする媒体みたいな物が13行目です。
これがあることで登録フォームで選択した名前(今回の例だとjonio2)があるレコードを取得しています。
ちなみに「where([‘name’ => $postData[‘title’]])」で登録レコードを選択して「$postData[‘title’]」でjonio2が取得できます。
今からcontentカラムにemailを登録しますが13行目の$userに何が入っているかをdd関数で見てみます。
public function add()
{
$this->loadModel('Users');
$users = $this->Users
->find('list', ['keyField' => 'name', 'valueField' => 'name'])
->toArray();
$post = $this->Posts->newEmptyEntity();
if ($this->request->is('post')) {
$postData = $this->request->getData();
$user = $this->Users->find()->where(['name' => $postData['title']])->first();
dd($user); //←この行
するとemailカラムの値を含んだオブジェクトが取得できます。
object(App\Model\Entity\User) id:0 {
'id' => (int) 2
'name' => 'jonio2'
'email' => 'b@demo.com'
'password' => 'xxxxxxx'
'[new]' => false
'[accessible]' => [ ]
'[dirty]' => [ ]
'[original]' => [ ]
'[virtual]' => [ ]
'[hasErrors]' => false
'[errors]' => [ ]
'[invalid]' => [ ]
'[repository]' => 'Users'
}
よって16行目の「$user->email」でjonio2に紐づいたemailカラムの値を取得してpostsテーブルのcontentカラム($postData[‘content’])に登録します。
あとはコードを読めば分かると思います。
jQuery(JavaScript)にコントローラー側からの変数を含める
CakePHP3.1で実装しています。
コントローラーのアクションに下記の記述をしたとします、ajaxを使おうとしています。
$this->set('_serialize', ['names', 'departments']);
namesには名前が入っていてdepartmentsには部署が入っていたとします。
そしてjQueryに下記の記述をしたとします。(必要な部分のに記載しています)
$(document).ready(function() {
function loadCards() {
$.ajax({
url: '/users/index/',
data: { page: page },
dataType: 'json'
})
.done(function(response) {
response.departments.forEach(department => {
let department_content = `
<div class="department-wrap">${department.content}</div>
`;
if (response.names == 'jonio') {
department_content += `
<p>2024年</p>
`;
}
・
・
・
アクションのnamesをjQueryの21行目で使っています。
存在しないURLにアクセスすると特定のページにリダイレクト
CakePHP4.5.4で実装しました、3系では動かないかもしれません。
ErrorController.php
「CakePHPのプロジェクト > src > Controller > ErrorController.php」の「beforeRender」メソッドに記述します。
public function beforeRender(EventInterface $event)
{
parent::beforeRender($event);
if ($this->response->getStatusCode() == 404) {
return $this->redirect('リダイレクトする時のコントローラーとアクションを指定');
}
}
beforeRenderメソッドはビューを表示する前に処理をする為の記述をする時に使うみたいです。
だからリダイレクトの処理はこのメソッドに記述します。
リダイレクト時のフラッシュの表示
コードを下記にします。
<?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.4
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
namespace App\Controller;
use Cake\Event\EventInterface;
/**
* Error Handling Controller
*
* Controller used by ExceptionRenderer to render error responses.
*/
class ErrorController extends AppController
{
/**
* Initialization hook method.
*
* @return void
*/
public function initialize(): void
{
$this->loadComponent('RequestHandler');
$this->loadComponent('Flash'); //この行を追加
}
・
・
・
/**
* beforeRender callback.
*
* @param \Cake\Event\EventInterface $event Event.
* @return \Cake\Http\Response|null|void
*/
public function beforeRender(EventInterface $event)
{
parent::beforeRender($event);
if ($this->response->getStatusCode() == 404) {
$this->Flash->error(__('アクセスしたURLは存在しません')); //この行を追加
return $this->redirect('/');
}
}
}
通常のコントローラーだと「$this->loadComponent(‘Flash’);」の記述があるAppControllerを継承しているのでいきなり56行目の記述ができますがErrorControllerはAppControllerを継承しても「$this->loadComponent(‘Flash’);」が適用されないみたいです。
だからinitializeメソッドに記述(37行目)しています。
これで404の時に特定のページにリダイレクトしてフラッシュメッセージも表示されます。
ログインなしでアクションを許可している時に動作しない
何らかのコントローラーで下記の記述をすればログインしなくてもアクションの動作ができるようになります。
use Cake\Event\EventInterface;
・
・
・
public function beforeFilter(EventInterface $event)
{
parent::beforeFilter($event);
$this->Authentication->addUnauthenticatedActions(['add']);
}
この場合はaddアクションが対象になります。
アクションを修正した時に起こる可能性がありますがアクションに下記などのログイン情報を記述していたとします。
$this->request->getAttribute('identity');
cookbookに記載がなくネットで情報を見つけることができなかったのですがアクションに対してログインを必要なくしてもアクションにログイン関連の記述をするとログインが必須になる仕様みたいです。
特定のデバイスでページにアクセスできなくする
例えばログインページにはリンクからアクセスしてもURLからアクセスしてもスマホとタブレットからしかアクセスできなくしたかったとします。
AppController.php
コードに下記を追記します。
protected function isMobileOrTablet()
{
$user_device = $this->request->getEnv('HTTP_USER_AGENT');
$target_devices = ['Android', 'iPhone', 'iPad', 'iPod', 'BlackBerry', 'Opera Mini', 'IEMobile'];
foreach ($target_devices as $target_device) {
if (strpos($user_device, $target_device) == true) {
return true;
}
}
return false;
}
3行目でアクセスしようとしている人の端末をチェックしています。
5行目でアクセスを可能にする端末を指定しています、これだけあれば十分だと思います。
7行目〜11行目で自分の端末がアクセス可能の端末に当てはまっているかをチェックしています。
次はコントローラーです。
loginアクションがあるコントローラー
下記のコードを追記します。
public function login()
{
//ここから追加
if (!$this->isMobileOrTablet()) {
return $this->redirect('/');
}
//ここまで追加
if ($this->request->is('post')) {
$user = $this->Auth->identify();
if ($user) {
$this->Auth->setUser($user);
return $this->redirect(['action' => 'index']);
}
$this->Flash->error(__('Invalid username or password, try again'));
}
}
今回はアクセス禁止の端末の場合にトップページにリダイレクトさせていますがお好みで変えてください。
ユーザーを登録する際にランダムな文字列を生成
場合によってはユーザー登録の際にパスワードとしてランダムな文字列を生成して登録ユーザーにメールでパスワードを送ってユーザーしかパスワードが分からない状態にしたいかもしれません。
そんな時に有効だと思います。
Cakephp3.6で動作の確認をしています。
UsersController.php
コードに下記の追記をします。
use Cake\Utility\Security; //この行を追加
・
・
・
public function add()
{
$user = $this->Users->newEntity();
if ($this->request->is('post')) {
//ここから追加
$plain_password = bin2hex(Security::randomBytes(6));
$this->log('Generated Password: ' . $plain_password, 'debug');
//ここまで追加
$user = $this->Users->patchEntity($user, $this->request->getData());
$user->password=$plain_password; //この行を追加
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'));
}
18行目はパスワードを確認するために付けていますが本番の環境でパスワードをログに出力すると丸わかりになるので付けるべきではありません。
16行目がポイントです。
「Security::randomBytes(6)」で安全な6バイトのバイナリデータ(パソコンしか認識できない文字列)に変換します。
これではパスワードが分からないので人間でも分かるパスワードに変換する為に「bin2hex」を付けてパスワードを16進数に変換します。
10進数とか他の進数でもいいですがパスワードが長くなったり短くなったりするとパスワードとして適さないのでパスワードとして適する12文字にする為に16進数に変換します。
ビュー
パスワードは登録しないので下記のコードにします。
<div class="users form large-9 medium-8 columns content">
<?= $this->Form->create($user) ?>
<fieldset>
<legend><?= __('Add User') ?></legend>
<?php
echo $this->Form->control('name');
echo $this->Form->control('email');
?>
</fieldset>
<?= $this->Form->button(__('Submit')) ?>
<?= $this->Form->end() ?>
</div>
データ型をTinyIntにする
マイグレーションファイルに下記の記述をすればint型からTinyInt型に変えることができます。
<?php
use Migrations\AbstractMigration;
use Phinx\Db\Adapter\MysqlAdapter; //この行を追加
class Createxxxxxxxxxxxx extends AbstractMigration
{
public function change()
{
$table = $this->table('xxxxxx');
$table
->addColumn('stamp_password_check', 'integer', [
'limit' => MysqlAdapter::INT_TINY //この行を追加
])
->create();
}
}
登録フォームで姓と名に分けた名前の値を1つのカラムに登録
下記画像のように姓と名の2つに別れたフォームの値をつなげてテーブルの1つのカラムに登録したい場合があります。
そんな時のコードの書き方です。
テンプレート
まずは登録フォームです。
下記のコードを書いたとします。
<?= $this->Form->create($demo, ['url' => ['action' => 'edit']]) ?>
<div>
<p>お名前</p>
<div>
<?= $this->Form->control('last_name', [
'label' => false,
'placeholder' => '姓',
'name' => 'last_name'
]) ?>
</div>
<div class="half-width">
<?= $this->Form->control('first_name', [
'label' => false,
'placeholder' => '名',
'name' => 'responsible_last_name'
]) ?>
</div>
</div>
<?= $this->Form->button('登録') ?>
<?= $this->Form->end() ?>
8行目と9行目でname属性を設定しています。
コントローラー
コードを下記にします。
public function アクション名()
{
・
・
・
if ($this->getRequest()->is(['patch', 'post', 'put'])) {
$formData = $this->getRequest()->getData();
$user = $this->Users->patchEntity($user, $formData);
$formData['name'] = $formData['first_name'] . '/' . $formData['last_name'];
$user->name = $formData['name'];
・
・
・
}
このやり方で登録するとテーブルに保存される値は「姓のフォームに入力した値/名のフォームに入力した値」になります。
姓と名の間に「/」がありますがこれは姓と名を表示する時に使うからです。
姓と名の表示
コントローラーのコードを下記にします。
アクションはテンプレートへの表示用を使っています。
public function アクション名()
{
$userInfo = $this->Users->get(値);
$name = explode("/", $userInfo->name);
・
・
・
$this->set(compact('name'));
}
そしてテンプレートのコードは下記にします。
<?= $this->Form->create($demo, ['url' => ['action' => 'edit']]) ?>
<div>
<p>お名前</p>
<div>
<?= $this->Form->control('last_name', [
'label' => false,
'placeholder' => '姓',
'value' => $name[0], //この行を追加
'name' => 'last_name'
]) ?>
</div>
<div class="half-width">
<?= $this->Form->control('first_name', [
'label' => false,
'placeholder' => '名',
'value' => $name[1], //この行を追加
'name' => 'responsible_last_name'
]) ?>
</div>
</div>
<?= $this->Form->button('登録') ?>
<?= $this->Form->end() ?>
これでnameカラムの値を姓と名に分けてフォームの中に表示できます。
1つのフォームに複数のsubmit
1つのフォームの中に
ネットに少ないながらも情報はありますが全てうまくいきませんでした。
業務で上司に質問して「動作しない理由が分からない」とのことで無理矢理動くようにしました。
ネットの情報
下記のコードで押したボタンのvalue属性が表示されると説明がありました。
//テンプレート
<?= $this->Form->create(null, ['url' => ['action' => 'demo']] )?>
<?= $this->Form->button('寝る', ['type' => 'submit', 'class' => 'btn btn-danger', 'name' => 'action', 'value' => 'sleepAction']) ?>
<?= $this->Form->button('働く', ['type' => 'submit', 'class' => 'btn btn-info', 'name' => 'action', 'value' => 'workAction']) ?>
<?= $this->Form->button('勉強', ['type' => 'submit', 'class' => 'btn btn-primary', 'name' => 'action', 'value' => 'studyAction']) ?>
<?= $this->Form->end() ?>
//コントローラー
if ($this->request()->is('post')) {
dd($this->getRequest()->getData());
exit;
}
//結果
[
'action' => 'work'
]
しかしうまくいきませんでした。
対応
テンプレートのコードを下記にします。
<?= $this->Form->create(null, ['url' => ['action' => 'demo']] )?>
<?= $this->Form->hidden('action', ['id' => 'actionType']) ?> //この行をつける
<?= $this->Form->button('寝る', ['type' => 'button', 'class' => 'btn btn-danger action', 'id' => 'sleep', 'name' => 'action', 'value' => 'sleepAction']) ?>
<?= $this->Form->button('働く', ['type' => 'button', 'class' => 'btn btn-info action', 'id' => 'work', 'name' => 'action', 'value' => 'workAction']) ?>
<?= $this->Form->button('勉強', ['type' => 'button', 'class' => 'btn btn-primary action', 'id' => 'study', 'name' => 'action', 'value' => 'studyAction']) ?>
<?= $this->Form->end() ?>
2行目を追記します。
そしてtype属性はsubmitだったのをbuttonに変更してクラス名にactionを追記します。
そしてjQueryに下記のコードを書きます。
<script>
(function($) {
$('.action').on('click', function() {
var text = $(this).val();
$('#actionType').val(text);
$('form').submit();
});
})(jQuery);
</script>
これで寝る・働く・勉強ボタンを押した時にvalue属性が表示されます。
//コントローラー
if ($this->request()->is('post')) {
dd($this->getRequest()->getData());
exit;
}
//「寝る」ボタンを押した場合の結果
[
'action' => 'sleepAction'
]
検索をした時にURLからパラメーターを消す
テンプレートに下記の記述をしたとします。
<?= $this->Form->create(null, ['type' => 'get', 'url' => ['action' => 'index']]) ?>
<div>
<?= $this->Form->radio('search_type', [
['value' => 'name', 'text' => 'ユーザー名'],
['value' => 'name_kana', 'text' => 'ふりがな']
]) ?>
<?= $this->Form->control('keyword', ['placeholder' => '検索キーワードを入力', 'label' => false]) ?>
<?= $this->Form->button('検索', ['type' => 'submit']) ?>
</div>
<?= $this->Form->end() ?>
コントローラーに下記の記述をしたとします。
$users = $this->User->find();
$searchType = $this->getRequest()->getQuery('search_type');
$keyword = $this->getRequest()->getQuery('keyword');
if (!empty($keyword)) {
switch ($searchType) {
case 'name':
$users->where(['Users.name LIKE' => '%' . $keyword . '%']);
break;
case 'name_kana':
$users->where(['Users.name_kana LIKE' => '%' . $keyword . '%']);
break;
}
}
$users = $this->paginate($users);
これで検索すると検索語のURLが下記になります。
http://localhost/users?search_type=&search_type=name&keyword=a
検索結果のURLに「?search_type=&search_type=name&keyword=a」が残って相当使いにくいです。
jQueryで対応します。
jQuery
if (window.history.replaceState) {
let url = new URL(window.location);
url.searchParams.delete('search_type');
url.searchParams.delete('keyword');
window.history.replaceState(null, null, url.pathname);
}
これで検索後のURLから「?search_type=&search_type=name&keyword=a」を消すことができます。
5行目の「url.pathname」で「http://localhost/users」の「/users」を取得して「http://localhost」 + 「/users」で元々のURLになります。