ハンズオン

ここからは、もっと実践的なwebサイトを作成しながら、Chandraの各機能をご紹介します。
「喫茶チャンドラ」は老舗の喫茶店です。静的サイトとして作成したwebサイトをCMS(コンテンツ管理システム)にして、PCに不慣れなオーナーでも手軽に「お知らせ」を更新できるようにします。

開発環境では、まず、クイックスタートで紹介しているXAMPP、Composerのインストールを実施してください。


ハンズオン

喫茶チャンドラサイトについて

「喫茶チャンドラ」サイトのデザインを見てみましょう。

https://learnchi.github.io/cafechandra_design/

「お知らせ」の欄はこの時点では固定です。管理画面からデータを追加することで、お知らせを追加できるようにします。
訪問者が閲覧するのは、直下のindex.htmlページだけです。

CMS化するにあたり、サイト管理者がコンテンツを管理するためのページを追加します。以下の機能を実現します。

CafeChandra CMS サイト構成図

プロジェクトを作る

cafechandraプロジェクトを作成します。C:\xampp\htdocs(または/Applications/XAMPP/xamppfiles/htdocs/)に、cafechandraディレクトリを作成してください。
Composerプロジェクトとして初期化します。ターミナルからcafechandraディレクトリに移動し、

composer init

として、composer.jsonファイルを生成します。

対話形式で、composer.json設定ファイルの内容を設定していきます。
Package nameは、ユーザー名/cafechandraが既定値として提案されます。これで良ければEnterキーを押してください。
それ以降の項目は全て空欄で構いません。
composer.jsonファイルの内容は、以下の内容があればじゅうぶんです。

{
    "name": "studiogau/cafechandra",
    "require": {}
}
Do you confirm generation [yes]? 

確認に対してy[es]をして、composer.jsonが生成されることを確認します。

Chandraをインストールします。

composer require studiogau/chandra

composer.lockファイルと、vendorディレクトリが作成されます。

静的サイトのデザインをそのまま適用させます。公開用のディレクトリとして、publicディレクトリを作成し、この中にcafechandra_design内のファイルを設置してください。

ブラウザから[http://localhost/cafechandra/public/を表示し、喫茶チャンドラの静的サイトが表示されることを確認します。](http://localhost/cafechandra/public/`を表示し、喫茶チャンドラの静的サイトが表示されることを確認します。)

現時点でディレクトリ構成は以下のようになっています:

cafechandra
│  composer.json
│  composer.lock
│
├─public
│  │  index.html
│  │  styles.css
│  │
│  └─img
│          bean.png ほか
│
└─vendor
    │  autoload.php
    │
    ├─composer
    │      ファイル名省略
    │
    ├─studiogau
    │  └─chandra
    │     ファイル名省略
    │
    └─symfony
        ファイル名省略

データベースの作成

データベースを作成する前に

XAMPP を使っている場合、MySQL の root ユーザーにパスワードが未設定のことがあります。開発機でもパスワードは設定しておいた方が扱いやすいです。

phpMyAdmin([http://localhost/phpmyadmin/)を開き、ユーザアカウントタブから、root@localhostの「権限を編集」リンクをクリックし、「Change](http://localhost/phpmyadmin/)を開き、ユーザアカウントタブから、root@localhostの「権限を編集」リンクをクリックし、「Change) password」タブで変更します。 続けて、phpMyAdminをパスワードでログインできるよう、C:\xampp\phpMyAdmin\config.inc.php`を編集します。

/* Authentication type and info */
$cfg['Servers'][$i]['auth_type'] = 'cookie'; # configから変更
$cfg['Servers'][$i]['user'] = 'root';
$cfg['Servers'][$i]['password'] = '';
$cfg['Servers'][$i]['extension'] = 'mysqli';
$cfg['Servers'][$i]['AllowNoPassword'] = false;  # true から変更
$cfg['Lang'] = '';

再びphpMyAdmin([http://localhost/phpmyadmin/)を開いたとき、ログイン画面が表示されます。](http://localhost/phpmyadmin/`)を開いたとき、ログイン画面が表示されます。)

データベースについて

このチュートリアルでは、次のデータベースを作成します。

  • MySQL の管理ユーザー: root
  • パスワード: 自分で設定したもの
  • データベース名: cafechandra

テーブルは次の2つです。

  • users … 管理画面ログイン用
  • news … お知らせ用

データベースとテーブルを作成する

cafechandraというデータベースに、以下の2つのテーブルを作成します。

  • users … 管理画面ログイン用
  • news … お知らせ用

phpMyAdminから、SQLタブをクリックし、以下のSQLを実行してデータベースを作成します。

CREATE DATABASE cafechandra  
DEFAULT CHARACTER SET utf8mb4  
COLLATE utf8mb4_unicode_ci;

cafechandraデータベースを選択後、SQLタブをクリックし、以下のSQLを実行してusers テーブルを作成します。

CREATE TABLE users (  
id INT UNSIGNED AUTO_INCREMENT NOT NULL,  
login_id VARCHAR(16) NOT NULL,  
password_hash VARCHAR(255) NOT NULL,  
user_name VARCHAR(40) NOT NULL,  
permissions VARCHAR(50),  
created_at DATETIME NOT NULL,  
created_by VARCHAR(16) NOT NULL,  
updated_at DATETIME,  
updated_by VARCHAR(16),  
PRIMARY KEY (id),  
UNIQUE KEY (login_id)  
);

カラムについて説明します。
permissions 列は権限管理を実現するための項目です。Chandraでは、特定のカラムから権限を管理するようロジックを構築することができます。cafechandraでは、画面名を列挙することで、その画面の権限があることを示すこととします。
監査列はpermissions列に続く4列です。Chandraでは、作成者、作成日、更新者、更新日をそれぞれこの定義の通りに作成すれば、レコードのinsertやupdateの時に自動的に値を保管します。詳細は「監査列にログインユーザーを反映する」節を参照してください。

同様にnewsテーブルを作成します。

CREATE TABLE news (  
id INT UNSIGNED AUTO_INCREMENT NOT NULL,  
title VARCHAR(100) NOT NULL,  
body TEXT NOT NULL,  
published_at DATE NOT NULL,  
is_published BOOLEAN NOT NULL DEFAULT TRUE,  
created_at DATETIME NOT NULL,  
created_by VARCHAR(16) NOT NULL,  
updated_at DATETIME,  
updated_by VARCHAR(16),  
PRIMARY KEY (id)  
);

テーブルにデータを入れる

以下のSQLを実行して、サンプルのお知らせ情報を作成します。

INSERT INTO `news` (`id`, `title`, `body`, `published_at`, `is_published`, `created_at`, `created_by`, `updated_at`, `updated_by`) VALUES 
(NULL, '新しいケーキセットが登場しました', 'このたび、新しいケーキセットの提供を開始いたしました。\r\n季節のフルーツを使用したケーキと、当店自慢のコーヒーの組み合わせをお楽しみいただけます。\r\n\r\n午後のひとときを、ゆったりとした時間とともにお過ごしください。\r\n数量限定のため、売り切れの際はご容赦ください。', '2026-04-05', '1', '2026-04-05 10:07:49.000000', 'admin', NULL, NULL), 
(NULL, 'ゴールデンウィーク営業のお知らせ', 'ゴールデンウィーク期間中は、通常通り営業いたします。\r\nなお、一部日程では営業時間が変更となる場合がございますので、ご来店の際はご注意ください。\r\n\r\n混雑が予想されるため、お時間に余裕を持ってお越しいただけますと幸いです。\r\n皆さまのご来店を心よりお待ちしております。', '2026-04-15', '1', '2026-04-15 10:07:49.000000', 'admin', NULL, NULL), 
(NULL, '春の限定「さくらブレンド」登場!', '春の訪れに合わせて、期間限定の「さくらブレンド」をご用意いたしました。\r\nほのかに甘い香りとやわらかな酸味が特徴の、季節を感じる一杯です。\r\n\r\n店内でゆっくりとお楽しみいただくのはもちろん、テイクアウトでもご提供しております。\r\n数量限定のため、なくなり次第終了となります。ぜひこの機会にお試しください。', '2026-04-20', '1', '2026-04-20 10:07:49.000000', 'admin', NULL, NULL)

データが入っていることを確認します。

newsテーブルの内容

users テーブルに登録するパスワードは、平文ではなくハッシュ化した値を使います。
XAMPP Control Panel の Shell を開いて、次のコマンドを実行してください。

XAMPP Control Panel

このコマンドは、文字列「admin1234」に対するハッシュ値を出力するコマンドです。

php -r "echo password_hash('admin1234', PASSWORD_DEFAULT), PHP_EOL;"

実行すると、次のような文字列が表示されます。

$2y$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

この文字列をコピーして、users.password_hash に登録します。以下のSQLを発行することで、パスワードが「admin1234」のadminユーザーを作成します。

INSERT INTO users (  
login_id, password_hash, user_name, permissions,  
created_at, created_by  
) VALUES (  
'admin',  
'$2y$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',  
'管理者',  
'news_list,news_create,news_edit,news_delete',  
NOW(),  
'SYSTEM'  
);

usersテーブルの内容

接続設定を作る

Chandraでは、MySQL 接続情報を ini ファイルまたは環境変数から読み込めます。今回は ini ファイルを使う方法で接続します。
プロジェクト内にconfig ディレクトリを作り、dbconfig.ini を置きます。

dbhost=127.0.0.1  
dbport=3306  
dbuser=root  
dbpass=ここに自分で設定したパスワード  
dbname=cafechandra  
charset=utf8mb4

【コミットポイント】ここまでの状態のコミットを「」で確認できます。コミットでは、dbconfig.sample.iniを確認できます。また、.gitignoredbconfig.ini をpushしないよう設定しています。

テストの準備

このハンズオンでは、PHPUnitを使ってテストを行いながらアプリを作成する手法を、同時にご紹介します。テストには、クラス単体を確認する単体テストのほか、実際のデータベースやWebページを使う結合テスト・HTTP機能テストも含まれます。本ハンズオンでは、これらをまとめて「テスト」と表記します。

【テスト】と記載されている節は、テストファーストで開発したい方向けに書かれています。まずは開発を進めて全体を通したい場合、この節は読み飛ばして構いません。

PHPUnitの取り込み

ComposerからPHPUnitを取得して、PHPのテストが行えるように準備します。
また、PHPUnit と一緒に Symfony BrowserKit と Symfony HttpClient をインストールします。
Symfony BrowserKit は、PHPのテストコードからブラウザのようにページへアクセスしたり、フォームを送信したりするためのライブラリです。Symfony HttpClient は、そのアクセスを実際のHTTP通信として行うために使います。

ターミナルからcafechandraディレクトリに移動し、以下のcomposerコマンドを実行します。

composer require --dev phpunit/phpunit symfony/browser-kit symfony/http-client

composer.jsonの、require-devに、phpunitが指定されます。また、vendorディレクトリにphpunitに関連するディレクトリが追加されます。

{
    "name": "studiogau/cafechandra",
    "require": {
        "studiogau/chandra": "^0.2.0"
    },
    "require-dev": {
        "phpunit/phpunit": "^11.5",
        "symfony/browser-kit": "^7.4",
        "symfony/http-client": "^7.4"
    }
}

次にプロジェクトのルートにphpunit.xmlを作成します。これはPHPUnit 実行時に参照される設定ファイルです。

<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
  <testsuites>
    <testsuite name="default">
      <directory>tests</directory>
    </testsuite>
  </testsuites>
</phpunit>

疎通確認用にテストクラスを作成します。testsディレクトリに、SampleTest.phpファイルを作成します。

<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
final class SampleTest extends TestCase
{
    public function test_phpunit_is_working(): void
    {
        $this->assertTrue(true);
    }
}

PHPUnitを実行してみましょう。ターミナルからcafechandraディレクトリに移動し、以下のコマンドを実行します。

.\vendor\bin\phpunit

テスト結果が

OK (1 test, 1 assertion)

のように表示されます。

表示画面

表示画面の概要

表示画面はデザインページをほぼそのまま利用しますが、「お知らせ」内容はデータベースのデータを表示するように変更します。
データベースのデータを取得して、返却するロジックを新しく作成し、表示画面から呼び出します。

【テスト】 「表示画面を作成する」のテスト

tests\public\IndexPageTest.phpを追加して、表示画面が正しく表示されることを確認するテストとします。

<?php
declare(strict_types=1);

use PHPUnit\Framework\TestCase;
use Symfony\Component\BrowserKit\HttpBrowser;
use Symfony\Component\HttpClient\HttpClient;

final class IndexPageTest extends TestCase
{
    private const INDEX_URL = 'http://localhost/cafechandra/public/index.php';

    /**
     * index.phpがHTTPリクエストで正常に表示されることをテスト
     */
    public function testIndexPageCanBeDisplayed(): void
    {
        $browser = new HttpBrowser(HttpClient::create());

        $browser->request('GET', self::INDEX_URL);
        $response = $browser->getResponse();
        $content = $response->getContent();

        // HTTP 200で表示できること
        $this->assertSame(200, $response->getStatusCode());
        // 画面のHTMLが空ではないこと
        $this->assertNotEmpty($content);
        // <title>Cafe Chandra</title> が含まれていること
        $this->assertStringContainsString('<title>Cafe Chandra</title>', $content);
    }
}

ターミナルからcafechandraディレクトリに移動し、以下のPHPUnitを実行してみましょう。

.\vendor\bin\phpunit

結果が失敗することを確認します。

表示画面を作成する

‘cafechandra\public\index.html‘を、index.phpに変更します。先頭にPHPタグを追記します。

<?php
date_default_timezone_set('Asia/Tokyo');
require_once(dirname(__DIR__) . '/vendor/autoload.php');
?>
<!DOCTYPE html>    <!-- 以下省略 -->

ブラウザから[http://localhost/cafechandra/public/が変わらず表示されることを確認します。](http://localhost/cafechandra/public/`が変わらず表示されることを確認します。)
【テスト】結果が成功することを確認します。

【テスト】 「データをセレクトする」のテスト

データベースのデータを取得して、返却するロジックについてのテストをあらかじめ作成します。
このテストでは、Repositories\NewsクラスのgetLatestForDisplayというメソッドを呼ぶテストをします。実際にデータベースを呼び出すのではなく、インターフェースが期待通りであることを確認しています。
tests\Repositories\NewsTest.phpを作成します。

<?php
declare(strict_types=1);

use Cafechandra\Repositories\News;
use PHPUnit\Framework\TestCase;
use Studiogau\Chandra\Database\Database;

final class NewsTest extends TestCase
{
    public function testGetLatestForDisplayFetchesLatestPublishedNews(): void
    {
        $expectedRows = [
            [
                'title' => 'お知らせ1',
                'body' => '本文1',
                'published_at' => '2026-05-01',
            ],
            [
                'title' => 'お知らせ2',
                'body' => '本文2',
                'published_at' => '2026-04-28',
            ],
        ];

        $expectedSql = 'SELECT title, body, published_at FROM news WHERE is_published = 1 ORDER BY published_at DESC LIMIT 3';

        $database = $this->createMock(Database::class);
        $database->expects($this->once())
            ->method('fetchList')
            ->with($expectedSql)
            ->willReturn($expectedRows);

        $repository = new News($database);
        $actualRows = $repository->getLatestForDisplay();
        $this->assertSame($expectedRows, $actualRows);
    }
}

ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行します。
Newsクラスをまだ作成していないため、エラーとなります。

データをセレクトする

Repositories\News.phpに、Newsクラスを作成します。
getLatestForDisplay関数を追加し、DatabaseクラスのfetchListメソッドを実行して結果を戻り値にします。fetchListメソッドは、引数のSQLから、複数レコードのデータを取得します。
getLatestForDisplayでは、is_publishedフラグが1のレコードのうち、更新日の新しい順で3レコードを取り出しています。

<?php
namespace Cafechandra\Repositories;
use Studiogau\Chandra\Database\Database;
class News {
	private Database $database;
	public function __construct(Database $database) {
        $this->database = $database;
	}
	public function getLatestForDisplay() {
		$data = null;
		$sql = "SELECT title, body, published_at FROM news WHERE is_published = 1 ORDER BY published_at DESC LIMIT 3";
		$data = $this->database->fetchList($sql);
		return $data;
	}
}

namespace付きの自作クラスを自動読み込みできるようにするため、composer.jsonに、autoload設定を追記します。

{
    "name": "studiogau/cafechandra",
    "autoload": {
        "psr-4": {
            "Cafechandra\\Repositories\\": "Repositories/"
        }
    }
}

autoload設定を反映させるためのコマンドを実行します。

composer dump-autoload

【テスト】ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行して、テストが成功することを確認します。

データを表示する

index.phpから、Databaseクラス、Loggerクラス、Newsクラスを読み込みインスタンス化します。
Utilityクラスは、HTML出力時に文字列をエスケープするhメソッドを使うために読み込みます。
NewsクラスのgetLatestForDisplay()を呼び出して結果を$newsListに保持します。

<?php
date_default_timezone_set('Asia/Tokyo');
require_once(dirname(__DIR__) . '/vendor/autoload.php');
use Studiogau\Chandra\Database\Database;
use Studiogau\Chandra\Logging\Logger;
use Cafechandra\Repositories\News;
use Studiogau\Chandra\Support\Utility;

$logger = Logger::createDefault(dirname(__DIR__));
$database = Database::fromConfiguredSource(dirname(__DIR__).'/config/dbconfig.ini', $logger);
$news = new News($database);
$newsList = $news->getLatestForDisplay();

?>

div.news-box内を以下の通り修正し、データベースからの内容を表示します。

<div class="news-box">
  <h2 class="news-title">お知らせ <span>News</span></h2>
  <ul class="news-content">
    <?php foreach (array_slice($newsList, 0, 3) as $item): ?>
      <li class="news-item">
        <span class="date"><?= Utility::h(date('Y/m/d', strtotime($item['published_at']))); ?></span>
        <span class="title"><?= Utility::h($item['title']); ?></span>
      </li>
    <?php endforeach; ?>
  </ul>
  <div class="wooden-button"><button>お知らせ一覧へ</button></div>
</div><!-- news-box -->

ブラウザから[http://localhost/cafechandra/public/が表示されることを確認します。](http://localhost/cafechandra/public/が表示されることを確認します。) phpMyAdmin`からデータを操作し、変化を確認してください。


管理画面:お知らせ一覧

管理画面の概要

管理画面では、管理者が「お知らせ」情報を編集します。まずは核となる「お知らせ」テーブルのレコードを追加、更新、削除する機能を作成します。その後、ログイン、CSRF トークンによるセキュリティ強化、エラー対応、認可について順に追加します。
管理者は、ログイン後、全てのお知らせが一覧表示されている画面から情報を選択して更新したり、新規のお知らせを追加したりします。これらの画面は、adminディレクトリにまとめて管理しやすくします。

【テスト】 「お知らせ一覧を取得する」のテスト

Repositories\Newsクラスに、全てのお知らせを一覧で取得するlistAllメソッドを追加するので、あらかじめテストを作成します。
実際にデータベースを呼び出すのではなく、インターフェースが期待通りであることを確認しています。
tests\Repositories\NewsTest.phpに、次のメソッドを追加します。


public function testListAllFetchesAllNews(): void
{
    $expectedRows = [
        [
            'id' => 1,
            'title' => 'お知らせ1',
            'body' => '本文',
            'published_at' => '2026-05-01',
            'is_published' => 1,
        ],
        [
            'id' => 2,
            'title' => 'お知らせ2',
            'body' => '本文',
            'published_at' => '2026-04-28',
            'is_published' => 0,
        ],
    ];

    $expectedSql = 'SELECT id, title, body, published_at, is_published FROM news ORDER BY published_at DESC';

    $database = $this->createMock(Database::class);
    $database->expects($this->once())
        ->method('fetchList')
        ->with($expectedSql)
        ->willReturn($expectedRows);

    $repository = new News($database);
    $actualRows = $repository->listAll();
    $this->assertSame($expectedRows, $actualRows);
}

ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行します。
listAll()メソッドをまだ作成していないため、エラーとなります。

お知らせ一覧を取得する

Repositories\news.phpに、以下のlistAllメソッドを追加します。このメソッドは、newsテーブルからwhere条件指定なしで全てのレコードを取得し、そのまま返却しています。

public function listAll() {

	$data = null;
	$sql = "SELECT id, title, body, published_at, is_published FROM news ORDER BY published_at DESC";
	$data = $this->database->fetchList($sql);
	return $data;
}

【テスト】ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行して、テストが成功することを確認します。

【テスト】「お知らせ一覧を表示する」のテスト

tests\public\admin\NewsListPageTest.phpを追加して、表示画面が正しく表示されることを確認するテストとします。

<?php
declare(strict_types=1);

use PHPUnit\Framework\TestCase;
use Symfony\Component\BrowserKit\HttpBrowser;
use Symfony\Component\HttpClient\HttpClient;

final class NewsListPageTest extends TestCase
{
    private const NEWS_LIST_URL = 'http://localhost/cafechandra/public/admin/news_list.php';

    /**
     * news_list.phpがHTTPリクエストで正常に表示されることをテスト
     */
    public function testNewsListPageCanBeDisplayed(): void
    {
        $browser = new HttpBrowser(HttpClient::create());

        $browser->request('GET', self::NEWS_LIST_URL);
        $response = $browser->getResponse();
        $content = $response->getContent();

        $this->assertSame(200, $response->getStatusCode());
        $this->assertNotEmpty($content);
        $this->assertStringContainsString('<title>お知らせ管理</title>', $content);
    }
}

ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行します。
結果が失敗することを確認します。

お知らせ一覧を表示する

お知らせ一覧を表示する画面を作成します。admin/news_list.phpを作成し、以下の無いようにしてください。
画面にアクセスした際、News->listAll()メソッドを呼び出し、結果を$newsListに格納します。foreach で1レコードずつ取り出して$itemに格納し、更にデータベースのカラム名をキーとして、$item['title']のように値にアクセスします。

<?php
date_default_timezone_set('Asia/Tokyo');
require_once(dirname(__DIR__,2).'/vendor/autoload.php');

use Cafechandra\Repositories\News;
use Studiogau\Chandra\Database\Database;
use Studiogau\Chandra\Logging\Logger;
use Studiogau\Chandra\Support\Utility;

$logger = Logger::createDefault(dirname(__DIR__,2));
$database = Database::fromConfiguredSource(dirname(__DIR__,2).'/config/dbconfig.ini', $logger);
$news = new News($database);
$newsList = $news->listAll();

?>
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>お知らせ管理</title>
</head>
<body>

	<h1>お知らせ管理</h1>

    <table border="1" cellpadding="10" cellspacing="0">
        <tr>
            <th>ID</th>
            <th>公開状態</th>
            <th>公開日時</th>
            <th>タイトル</th>
            <th>内容</th>
        </tr>
        <?php foreach ($newsList as $item): ?>
        <tr>
            <td><?= Utility::h($item['id']); ?></td>
            <td><?= Utility::h((string)$item['is_published'] === '1' ? '公開' : '非公開'); ?></td>
            <td><?= Utility::h(date('Y/m/d', strtotime($item['published_at']))); ?></td>
            <td><?= Utility::h($item['title']); ?></td>
            <td><?= Utility::h(mb_strlen((string)$item['body']) > 100 ? mb_substr((string)$item['body'], 0, 100) . '...' : (string)$item['body']); ?></td>
        </tr>
        <?php endforeach; ?>

    </table>

</body>
</html>

ブラウザから[http://localhost/cafechandra/public/admin/news_list.phpが表示されることを確認します。](http://localhost/cafechandra/public/admin/news_list.phpが表示されることを確認します。) 【テスト】ターミナルからcafechandra`ディレクトリに移動し、PHPUnitを実行して、テストが成功することを確認します。

管理画面:お知らせ更新

データベースの更新について

このハンズオンでは、小規模なプロジェクトをシンプルに実装することを目的としているため、更新処理は単純な UPDATE の実行にとどめています。
実際の業務システムでは、更新対象が存在するかの確認、同一トランザクション内での SELECT、更新前後の値の差分確認、同時更新時のロックなど、追加で検討すべき点があります。
これらの考え方については、「リファレンス」で詳しく解説します。

【テスト】「お知らせ情報を取得する」のテスト

Repositories\Newsクラスに、idを引数にお知らせを1レコードだけ取得するfetchByIdメソッドを追加するので、あらかじめテストを作成します。
実際にデータベースを呼び出すのではなく、インターフェースが期待通りであることを確認しています。
tests\Repositories\NewsTest.phpに、次のメソッドを追加します。

public function testFetchByIdFetchesNewsById(): void
{
    $id = 1;
    $expectedRow = [
        'id' => 1,
        'title' => 'お知らせ1',
        'body' => '本文',
        'published_at' => '2026-05-01',
        'is_published' => 1,
		'created_at' => '2026-05-01 09:00:00',
        'created_by' => 'admin',
		'updated_at' => '2026-05-01 09:30:00',
        'updated_by' => 'admin',
    ];

    // 指定されたIDのお知らせを取得するSQLを想定する
    $expectedSql = 'SELECT id, title, body, published_at, is_published, created_at, created_by, updated_at, updated_by FROM news WHERE id = :id';
    $expectedBindings = [
        ':id' => ['value' => $id, 'datatype' => \PDO::PARAM_INT],
    ];

    $database = $this->createMock(Database::class);
    $database->expects($this->once())
        //想定通りのSQLとバインド値が発行されることを確認する
        ->method('fetchOne')
        ->with($expectedSql, $expectedBindings)
        ->willReturn($expectedRow);

    $repository = new News($database);

    // リポジトリはDB層の取得結果をそのまま詳細データとして返す
    $actualRow = $repository->fetchById($id);

    $this->assertSame($expectedRow, $actualRow);
}

ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行します。
NewsクラスにfetchByIdメソッドをまだ作成していないため、エラーとなります。

お知らせ情報を取得する

Repositories\news.phpに、以下のfetchByIdメソッドを追加します。DatabaseクラスのfetchOneメソッドを実行して結果を戻り値にします。fetchOneメソッドは、引数のSQLやバインディング値から、1レコードだけ取得します。ここでは、newsテーブルのid項目を条件にしています。$bindings は、SQL内の :id などのバインド変数に対して、実際に渡す値と PDO のデータ型を指定する配列です。

public function fetchById($id) {
	$data = null;
	$sql = "SELECT id, title, body, published_at, is_published, created_at, created_by, updated_at, updated_by FROM news WHERE id = :id";
	$bindings = [
		':id' => ['value' => $id, 'datatype' => \PDO::PARAM_INT],
	];
	$data = $this->database->fetchOne($sql, $bindings);
	return $data;
}

【テスト】ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行して、テストが成功することを確認します。

【テスト】「お知らせの編集画面を表示する」のテスト

tests\public\admin\newsEditPageTest.phpを追加して、表示画面が正しく表示されることを確認するテストとします。

<?php
declare(strict_types=1);

use PHPUnit\Framework\TestCase;
use Symfony\Component\BrowserKit\HttpBrowser;
use Symfony\Component\HttpClient\HttpClient;

final class NewsEditPageTest extends TestCase
{
    private const NEWS_EDIT_URL = 'http://localhost/cafechandra/public/admin/news_edit.php?id=1';

    /**
     * news_edit.phpがHTTPリクエストで正常に表示されることをテスト
     */
    public function testNewsEditPageCanBeDisplayed(): void
    {
        $browser = new HttpBrowser(HttpClient::create());

        $browser->request('GET', self::NEWS_EDIT_URL);
        $response = $browser->getResponse();
        $content = $response->getContent();

        $this->assertSame(200, $response->getStatusCode());
        $this->assertNotEmpty($content);
        $this->assertStringContainsString('<title>お知らせ編集</title>', $content);
    }
}


ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行します。
結果が失敗することを確認します。

お知らせの編集画面を表示する

public\admin\news_edit.phpを追加します。

<?php
date_default_timezone_set('Asia/Tokyo');
require_once(dirname(__DIR__,2).'/vendor/autoload.php');

use Cafechandra\Repositories\News;
use Studiogau\Chandra\Database\Database;
use Studiogau\Chandra\Logging\Logger;
use Studiogau\Chandra\Support\Utility;

$logger = Logger::createDefault(dirname(__DIR__,2));

$database = Database::fromConfiguredSource(dirname(__DIR__,2).'/config/dbconfig.ini', $logger);

$id = (string)($_GET['id'] ?? '');
$news = new News($database);

$storedNewsData = $news->fetchById((int)$id);

$publishedAtValue = '';
if (!empty($storedNewsData['published_at'])) {
    $publishedAt = new \DateTimeImmutable((string)$storedNewsData['published_at']);
    $publishedAtValue = $publishedAt->format('Y-m-d');
}
$newsData = [
    'title' => (string)($storedNewsData['title'] ?? ''),
    'body' => (string)($storedNewsData['body'] ?? ''),
    'published_at' => $publishedAtValue,
    'is_published' => (string)($storedNewsData['is_published'] ?? '1'),
    'created_at' => (string)($storedNewsData['created_at'] ?? ''),
    'created_by' => (string)($storedNewsData['created_by'] ?? ''),
    'updated_at' => (string)($storedNewsData['updated_at'] ?? ''),
    'updated_by' => (string)($storedNewsData['updated_by'] ?? ''),
];
?>
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>お知らせ編集</title>
</head>
<body>

    <h1>お知らせ編集</h1>

    <form>
        <input type="hidden" name="id" value="<?= Utility::h($id); ?>">

        <div style="display:flex; margin:16px;">
            <label for="title" style="min-width:100px;">タイトル</label><br>
            <input type="text" id="title" name="title" value="<?= Utility::h($newsData['title']); ?>" maxlength="100" size="100" required>
        </div>

        <div style="display:flex; margin:16px;">
            <label for="body" style="min-width:100px;">本文</label><br>
            <textarea id="body" name="body" rows="10" cols="60" required><?= Utility::h($newsData['body']); ?></textarea>
        </div>

        <div style="display:flex; margin:16px;">
            <label for="published_at" style="min-width:100px;">公開日時</label><br>
            <input type="date" id="published_at" name="published_at" value="<?= Utility::h($newsData['published_at']); ?>" required>
        </div>

        <div style="display:flex; margin:16px;">
            <div style="min-width:100px;">公開状態</div>
            <label for="is_published_1">
                <input type="radio" id="is_published_1" name="is_published" value="1" <?= (string)$newsData['is_published'] === '1' ? 'checked' : ''; ?>>
                公開
            </label>
            <label for="is_published_0">
                <input type="radio" id="is_published_0" name="is_published" value="0" <?= (string)$newsData['is_published'] === '0' ? 'checked' : ''; ?>>
                非公開
            </label>
        </div>

        <div style="display:flex; margin:16px;">
            <div style="min-width:100px;">作成日時</div>
            <div><?= Utility::h($newsData['created_at']); ?></div>
        </div>

        <div style="display:flex; margin:16px;">
            <div style="min-width:100px;">作成者</div>
            <div><?= Utility::h($newsData['created_by']); ?></div>
        </div>

        <div style="display:flex; margin:16px;">
            <div style="min-width:100px;">更新日時</div>
            <div><?= Utility::h($newsData['updated_at']); ?></div>
        </div>

        <div style="display:flex; margin:16px;">
            <div style="min-width:100px;">更新者</div>
            <div><?= Utility::h($newsData['updated_by']); ?></div>
        </div>

    </form>

</body>
</html>

【テスト】ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行して、テストが成功することを確認します。
ブラウザから[http://localhost/cafechandra/public/admin/news_edit.php?id=1が表示されることを確認します。](http://localhost/cafechandra/public/admin/news_edit.php?id=1`が表示されることを確認します。)

お知らせの編集画面への遷移

お知らせ一覧で、タイトルから編集画面に遷移できるようリンクを追加します。リンク先は、news_edit.phpに、idをクエリパラメータとして付与します。

public\admin\news_list.phpで、以下のようにタイトル部分を変更します。

<td><?= Utility::h($item['title']); ?></td>

<td><a href="news_edit.php?id=<?= Utility::h($item['id']); ?>"><?= Utility::h($item['title']); ?></a></td>

また、編集画面から一覧画面に戻れるよう、リンクを追加します。
public\admin\news_edit.phpに、戻るリンクを追加します。

<div>
    <a href="news_list.php">一覧へ戻る</a>
</div>

一覧画面と編集画面とを行き来できることを確認します。

【テスト】「お知らせ更新処理を追加する」のテスト

Repositories\Newsクラスに、idで特定したお知らせレコードを更新するupdateByIdメソッドを追加するので、あらかじめテストを作成します。
実際にデータベースを呼び出すのではなく、インターフェースが期待通りであることを確認しています。
tests\Repositories\NewsTest.phpに、次のメソッドを追加します。

public function testUpdateByIdUpdatesNewsById(): void
{
    $id = 1;
    $newsData = [
        'title' => 'Updated title',
        'body' => 'Updated body',
        'published_at' => '2026-05-02',
        'is_published' => 1,
    ];
    $expectedValues = [
        'title' => ['value' => $newsData['title'], 'datatype' => \PDO::PARAM_STR],
        'body' => ['value' => $newsData['body'], 'datatype' => \PDO::PARAM_STR],
        'published_at' => ['value' => $newsData['published_at'], 'datatype' => \PDO::PARAM_STR],
        'is_published' => ['value' => $newsData['is_published'], 'datatype' => \PDO::PARAM_INT],
    ];
    $expectedConditions = [
        'id' => ['value' => $id, 'datatype' => \PDO::PARAM_INT],
    ];

    $database = $this->createMock(Database::class);
    $database->expects($this->once())
        ->method('update')
        ->with('news', $expectedValues, $expectedConditions)
        ->willReturn(1);

    $repository = new News($database);

    $updatedCount = $repository->updateById($id, $newsData);

    $this->assertSame(1, $updatedCount);
}

ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行します。
NewsクラスにupdateByIdメソッドをまだ作成していないため、エラーとなります。

お知らせ更新処理を追加する

Repositories\news.phpに、以下のupdateByIdメソッドを追加します。Databaseクラスのupdateメソッドを実行します。updateメソッドは、引数でテーブル名、値、where条件をそれぞれ指定することで、データベースのupdateを行います。Chandraでは、update時に監査列(作成者、作成日、更新者、更新日)を自動的に値を保管します。

public function updateById($id, array $newsData) {

	$values = [
		'title' => ['value' => $newsData['title'], 'datatype' => \PDO::PARAM_STR],
		'body' => ['value' => $newsData['body'], 'datatype' => \PDO::PARAM_STR],
		'published_at' => ['value' => $newsData['published_at'], 'datatype' => \PDO::PARAM_STR],
		'is_published' => ['value' => $newsData['is_published'], 'datatype' => \PDO::PARAM_INT],
	];
	$conditions = [
		'id' => ['value' => $id, 'datatype' => \PDO::PARAM_INT],
	];

	return $this->database->update('news', $values, $conditions);
}

【テスト】ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行して、テストが成功することを確認します。

【テスト】「お知らせ更新リクエストを処理する」のテスト

お知らせの編集画面から、更新ボタンを押すことで更新(news_update)処理を行い、一覧(news_list)に戻ります。public\admin\news_update.phpに対するテストクラスtests\public\admin\newsUpdatePageTest.phpを以下の通り作成します。このテストでは、Repositories\news.phpupdateByIdメソッドを呼び出すため、その前後でテーブル情報を退避したり戻したりしています。

<?php
declare(strict_types=1);

use PHPUnit\Framework\TestCase;
use Studiogau\Chandra\Database\Database;
use Studiogau\Chandra\Logging\Logger;
use Symfony\Component\HttpClient\HttpClient;

final class NewsUpdatePageTest extends TestCase
{
    private const NEWS_UPDATE_URL = 'http://localhost/cafechandra/public/admin/news_update.php';
    private const TARGET_NEWS_ID = 1;

    private const SEED_NEWS_SQL = <<<'SQL'
INSERT INTO `news` (`id`, `title`, `body`, `published_at`, `is_published`, `created_at`, `created_by`, `updated_at`, `updated_by`) VALUES
(1, 'テストデータ1', 'テストデータ1', '2026-04-05', '1', sysdate(), 'SYSTEM', NULL, NULL);
SQL;

    // news_update.php と同じDB設定で接続し、実際の更新処理に近い形で結果を確認する。
    private ?Database $database = null;

    // テスト開始前のnewsテーブル全データ。テスト後にこの内容へ戻す。
    private array $backupRows = [];

    // テスト開始前のAUTO_INCREMENT値。データ復元後にこの値へ戻す。
    private ?int $backupAutoIncrement = null;

    /**
     * 各テストの前準備を行う。
     */
    protected function setUp(): void
    {
        $projectRoot = dirname(__DIR__, 3);
        $logger = Logger::createDefault($projectRoot);
        $this->database = Database::fromConfiguredSource($projectRoot . '/config/dbconfig.ini', $logger);

        $this->backupNewsTable();
        $this->replaceNewsTableWithSeedData();
    }

    /**
     * 各テストの後片付けを行う。
     */
    protected function tearDown(): void
    {
        if ($this->database !== null) {
            $this->restoreNewsTable();
            $this->database->close();
        }
    }

    /**
     * 想定通りのPOST値をnews_update.phpへ送ると、newsテーブルが更新されることを確認する。
     */
    public function testNewsUpdatePageUpdatesNewsTableWithPostedValues(): void
    {
        $this->assertNotNull($this->database);

        $updatedTitle = '更新後タイトル';

        $response = HttpClient::create()->request('POST', self::NEWS_UPDATE_URL, [
            'body' => [
                'id' => (string)self::TARGET_NEWS_ID,
                'title' => '  ' . $updatedTitle . '  ',
                'body' => "  updated body\n  ",
                'published_at' => '2026-05-12',
                'is_published' => '1',
            ],
            'max_redirects' => 0,
        ]);

        // 更新後は一覧画面へ303で戻ること
        $this->assertSame(303, $response->getStatusCode());
        $this->assertSame(['news_list.php'], $response->getHeaders(false)['location'] ?? []);

        $updatedRow = $this->database->fetchOne(
            'SELECT title, body, published_at, is_published FROM news WHERE id = :id',
            [
                ':id' => ['value' => self::TARGET_NEWS_ID, 'datatype' => \PDO::PARAM_INT],
            ]
        );

        $this->assertSame($updatedTitle, $updatedRow['title']);
        $this->assertSame('updated body', $updatedRow['body']);
        $this->assertSame('2026-05-12', $updatedRow['published_at']);
        $this->assertSame(1, (int)$updatedRow['is_published']);
    }

    /**
     * テスト開始前のnewsテーブルの状態を退避する。
     */
    private function backupNewsTable(): void
    {
        $this->assertNotNull($this->database);

        $this->backupRows = $this->database->fetchList(
            'SELECT id, title, body, published_at, is_published, created_at, created_by, updated_at, updated_by FROM news ORDER BY id ASC'
        );

        $tableStatus = $this->database->fetchOne("SHOW TABLE STATUS LIKE 'news'");
        $this->backupAutoIncrement = (int)$tableStatus['Auto_increment'];
    }

    /**
     * newsテーブルをテスト用の固定データへ差し替える。
     */
    private function replaceNewsTableWithSeedData(): void
    {
        $this->assertNotNull($this->database);

        $pdo = $this->database->getConnection()->getPdo();
        $pdo->exec('DELETE FROM news');
        $pdo->exec(self::SEED_NEWS_SQL);
    }

    /**
     * newsテーブルをテスト開始前の状態へ復元する。
     */
    private function restoreNewsTable(): void
    {
        $this->assertNotNull($this->database);

        $pdo = $this->database->getConnection()->getPdo();
        $pdo->exec('DELETE FROM news');

        $stmt = $pdo->prepare(
            'INSERT INTO news (id, title, body, published_at, is_published, created_at, created_by, updated_at, updated_by) ' .
            'VALUES (:id, :title, :body, :published_at, :is_published, :created_at, :created_by, :updated_at, :updated_by)'
        );

        foreach ($this->backupRows as $row) {
            $stmt->execute([
                ':id' => (int)$row['id'],
                ':title' => $row['title'],
                ':body' => $row['body'],
                ':published_at' => $row['published_at'],
                ':is_published' => (int)$row['is_published'],
                ':created_at' => $row['created_at'],
                ':created_by' => $row['created_by'],
                ':updated_at' => $row['updated_at'],
                ':updated_by' => $row['updated_by'],
            ]);
        }

        if ($this->backupAutoIncrement !== null) {
            $pdo->exec('ALTER TABLE news AUTO_INCREMENT = ' . $this->backupAutoIncrement);
        }
    }
}

ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行します。
public\admin\news_update.phpをまだ作成していないため、テストは失敗となります。

お知らせ更新リクエストを処理する

お知らせの編集画面から、更新ボタンを押すことで更新(news_update)処理を行い、一覧(news_list)に戻ります。news_updateには画面がありませんが、独立したphpファイルとして作成します。

public\admin\news_update.phpを追加します。

<?php
date_default_timezone_set('Asia/Tokyo');
require_once(dirname(__DIR__,2).'/vendor/autoload.php');
use Cafechandra\Repositories\News;
use Studiogau\Chandra\Database\Database;
use Studiogau\Chandra\Logging\Logger;

$logger = Logger::createDefault(dirname(__DIR__, 2));
$database = Database::fromConfiguredSource(dirname(__DIR__,2).'/config/dbconfig.ini', $logger);
// 更新対象のニュース情報をPOSTからまとめて取り出す
$newsData = [
    'id' => (string)($_POST['id'] ?? ''),
    'title' => trim((string)($_POST['title'] ?? '')),
    'body' => trim((string)($_POST['body'] ?? '')),
    'published_at' => (string)($_POST['published_at'] ?? ''),
    'is_published' => (string)($_POST['is_published'] ?? ''),
];

// 公開日時をDB保存用の日時として扱える形に変換する
$publishedAtValue = '';
if (!empty($newsData['published_at'])) {
    $publishedAt = new \DateTimeImmutable((string)$newsData['published_at']);
    $publishedAtValue = $publishedAt->format('Y-m-d');
}

$news = new News($database);
// 指定されたIDのニュースを、フォーム入力の内容で更新する
$news->updateById((int)$newsData['id'], [
    'title' => $newsData['title'],
    'body' => $newsData['body'],
    'published_at' => $publishedAtValue,
    'is_published' => (int)$newsData['is_published'],
]);
// 更新後は一覧画面へ戻す
header('Location: news_list.php', true, 303);

news_editから、更新ボタンを押下するとnews_update.phpが呼び出されるようにします。
public\admin\news_edit.phpのformタグに、以下の通り属性値を追加します。

<form id="news-update-form" method="post" action="news_update.php" onsubmit="return window.confirm('更新してもいいですか?');">

public\admin\news_edit.phpに、更新ボタンを追加します。

<button type="submit" form="news-update-form">更新</button>

【テスト】ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行して、テストが成功することを確認します。
お知らせ一覧から任意のお知らせを選択し、お知らせ編集画面を表示します。内容を編集し、更新ボタンを押すことで、編集したお知らせが保存できることを確認します。保存したお知らせを再度編集画面に表示し、更新日時、更新者が自動更新されていることを確認します。

管理画面:お知らせ登録

【テスト】「お知らせの登録画面を表示する」のテスト

お知らせ編集画面に対して、登録をする画面を作成します。
tests\public\admin\NewsCreatePageTest.phpを追加して、表示画面が正しく表示されることを確認するテストとします。

<?php
declare(strict_types=1);

use PHPUnit\Framework\TestCase;
use Symfony\Component\BrowserKit\HttpBrowser;
use Symfony\Component\HttpClient\HttpClient;

final class NewsCreatePageTest extends TestCase
{
    private const NEWS_CREATE_URL = 'http://localhost/cafechandra/public/admin/news_create.php';

    /**
     * news_create.phpがHTTPリクエストで正常に表示されることをテスト
     */
    public function testNewsCreatePageCanBeDisplayed(): void
    {
        $browser = new HttpBrowser(HttpClient::create());

        $browser->request('GET', self::NEWS_CREATE_URL);
        $response = $browser->getResponse();
        $content = $response->getContent();

        $this->assertSame(200, $response->getStatusCode());
        $this->assertNotEmpty($content);
        $this->assertStringContainsString('<title>お知らせ新規登録</title>', $content);
    }
}

ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行します。
結果が失敗することを確認します。

お知らせの登録画面を表示する

public\admin\news_create.phpを追加します。public\admin\news_edit.phpと似ていますが、データベースからお知らせを呼び出しません。

<?php
date_default_timezone_set('Asia/Tokyo');
require_once(dirname(__DIR__,2).'/vendor/autoload.php');
use Studiogau\Chandra\Support\Utility;
$newsData = [
    'title' => '',
    'body' => '',
    'published_at' => date('Y-m-d\TH:i'),
    'is_published' => '1',
];

?>
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>お知らせ新規登録</title>
</head>
<body>

    <h1>お知らせ新規登録</h1>
    <form>
        <div style="display:flex; margin:16px;">
            <label for="title" style="min-width:100px;">タイトル</label><br>
            <input type="text" id="title" name="title" value="<?= Utility::h($newsData['title']); ?>" maxlength="100" size="100" required>
        </div>
        <div style="display:flex; margin:16px;">
            <label for="body" style="min-width:100px;">本文</label><br>
            <textarea id="body" name="body" rows="10" cols="60" required><?= Utility::h($newsData['body']); ?></textarea>
        </div>
        <div style="display:flex; margin:16px;">
            <label for="published_at" style="min-width:100px;">公開日時</label><br>
            <input type="date" id="published_at" name="published_at" value="<?= Utility::h($newsData['published_at']); ?>" required>
        </div>
        <div style="display:flex; margin:16px;">
            <div style="min-width:100px;">公開状態</div>
            <label for="is_published_1">
                <input type="radio" id="is_published_1" name="is_published" value="1" <?= (string)$newsData['is_published'] === '1' ? 'checked' : ''; ?>>
                公開
            </label>
            <label for="is_published_0">
                <input type="radio" id="is_published_0" name="is_published" value="0" <?= (string)$newsData['is_published'] === '0' ? 'checked' : ''; ?>>
                非公開
            </label>
        </div>
        <div>
            <button type="submit">登録</button>
        </div>
    </form>
    <div>
        <a href="news_list.php">一覧へ戻る</a>
    </div>
</body>
</html>

【テスト】ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行して、テストが成功することを確認します。
ブラウザから[http://localhost/cafechandra/public/admin/news_create.phpが表示されることを確認します。](http://localhost/cafechandra/public/admin/news_create.php`が表示されることを確認します。)

お知らせ登録画面への遷移

お知らせの登録は、一覧画面から行えるようにリンクを追加します。
news_list.phpのHTML内の任意の場所に、以下のリンクを追加してください。

<a href="news_create.php">新規</a>

ブラウザでお知らせ一覧画面を開き、「新規」リンクからお知らせ登録画面が表示できることを確認します。

【テスト】「お知らせ登録処理を追加する」のテスト

Repositories\Newsクラスに、お知らせテーブルにレコードを登録するinsertメソッドを追加するので、あらかじめテストを作成します。
実際にデータベースを呼び出すのではなく、インターフェースが期待通りであることを確認しています。
tests\Repositories\NewsTest.phpに、次のメソッドを追加します。


public function testInsertInsertsNews(): void
{
    $newsData = [
        'title' => 'New title',
        'body' => 'New body',
        'published_at' => '2026-05-03',
        'is_published' => 1,
    ];
    $expectedValues = [
        'title' => ['value' => $newsData['title'], 'datatype' => \PDO::PARAM_STR],
        'body' => ['value' => $newsData['body'], 'datatype' => \PDO::PARAM_STR],
        'published_at' => ['value' => $newsData['published_at'], 'datatype' => \PDO::PARAM_STR],
        'is_published' => ['value' => $newsData['is_published'], 'datatype' => \PDO::PARAM_INT],
    ];

    $database = $this->createMock(Database::class);
    $database->expects($this->once())
        ->method('insert')
        ->with('news', $expectedValues)
        ->willReturn(10);

    $repository = new News($database);

    $insertedId = $repository->insert($newsData);

    $this->assertSame(10, $insertedId);
}

ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行します。
NewsクラスにupdateByIdメソッドをまだ作成していないため、エラーとなります。

お知らせ登録処理を追加する

Repositories\news.phpに、以下のinsertメソッドを追加します。Databaseクラスのinsertメソッドを実行します。
insertメソッドは、引数でテーブル名と値を指定することで、データベースのinsertを行います。Chandraでは、insert時に監査列(作成者、作成日、更新者、更新日)を自動的に値を保管します。

public function insert(array $newsData) {

	$values = [
		'title' => ['value' => $newsData['title'], 'datatype' => \PDO::PARAM_STR],
		'body' => ['value' => $newsData['body'], 'datatype' => \PDO::PARAM_STR],
		'published_at' => ['value' => $newsData['published_at'], 'datatype' => \PDO::PARAM_STR],
		'is_published' => ['value' => $newsData['is_published'], 'datatype' => \PDO::PARAM_INT],
	];

	return $this->database->insert('news', $values);
}

【テスト】ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行して、テストが成功することを確認します。

【テスト】「お知らせ登録リクエストを処理する」のテスト

お知らせ登録画面から、登録ボタンを押すことで登録(news_insert)処理を行い、一覧(news_list)に戻ります。public\admin\news_insert.phpに対するテストクラスtests\public\admin\newsINsertPageTest.phpを以下の通り作成します。このテストでは、Repositories\news.phpinsertメソッドを呼び出すため、その前後でテーブル情報を退避したり戻したりしています。

<?php
declare(strict_types=1);

use PHPUnit\Framework\TestCase;
use Studiogau\Chandra\Database\Database;
use Studiogau\Chandra\Logging\Logger;
use Symfony\Component\HttpClient\HttpClient;

final class NewsInsertPageTest extends TestCase
{
    private const NEWS_INSERT_URL = 'http://localhost/cafechandra/public/admin/news_insert.php';

    // news_insert.php と同じDB設定で接続し、実際の登録処理に近い形で結果を確認する。
    private ?Database $database = null;

    // テスト開始前のnewsテーブル全データ。テスト後にこの内容へ戻す。
    private array $backupRows = [];

    // テスト開始前のAUTO_INCREMENT値。データ復元後にこの値へ戻す。
    private ?int $backupAutoIncrement = null;

    /**
     * 各テストの前準備を行う。
     */
    protected function setUp(): void
    {
        $projectRoot = dirname(__DIR__, 3);
        $logger = Logger::createDefault($projectRoot);
        $this->database = Database::fromConfiguredSource($projectRoot . '/config/dbconfig.ini', $logger);

        $this->backupNewsTable();
        $this->emptyNewsTableForInsertTest();
    }

    /**
     * 各テストの後片付けを行う。
     */
    protected function tearDown(): void
    {
        // NewsUpdatePageTest と同一
    }

    /**
     * 想定通りのPOST値をnews_insert.phpへ送ると、newsテーブルへ登録されることを確認する。
     */
    public function testNewsInsertPageInsertsNewsTableWithPostedValues(): void
    {
        $this->assertNotNull($this->database);

        $insertedTitle = '登録後タイトル';

        $response = HttpClient::create()->request('POST', self::NEWS_INSERT_URL, [
            'body' => [
                'title' => '  ' . $insertedTitle . '  ',
                'body' => "  inserted body\n  ",
                'published_at' => '2026-05-14',
                'is_published' => '0',
            ],
            'max_redirects' => 0,
        ]);

        // 登録後は一覧画面へ303で戻ること
        $this->assertSame(303, $response->getStatusCode());
        $this->assertSame(['news_list.php'], $response->getHeaders(false)['location'] ?? []);

        $countRow = $this->database->fetchOne('SELECT COUNT(*) AS count FROM news');
        $insertedRow = $this->database->fetchOne(
            'SELECT title, body, published_at, is_published FROM news LIMIT 1'
        );

        $this->assertSame(1, (int)$countRow['count']);
        $this->assertSame($insertedTitle, $insertedRow['title']);
        $this->assertSame('inserted body', $insertedRow['body']);
        $this->assertSame('2026-05-14', $insertedRow['published_at']);
        $this->assertSame(0, (int)$insertedRow['is_published']);
    }

    /**
     * テスト開始前のnewsテーブルの状態を退避する。
     */
    private function backupNewsTable(): void
    {
        // NewsUpdatePageTest と同一
    }

    /**
     * newsテーブルをinsert結果が1件だけになる状態へ差し替える。
     */
    private function emptyNewsTableForInsertTest(): void
    {
        $this->assertNotNull($this->database);

        $pdo = $this->database->getConnection()->getPdo();
        $pdo->exec('DELETE FROM news');
    }

    /**
     * newsテーブルをテスト開始前の状態へ復元する。
     */
    private function restoreNewsTable(): void
    {
        // NewsUpdatePageTest と同一
    }
}

ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行します。
public\admin\news_insert.phpをまだ作成していないため、テストは失敗となります。

お知らせ登録リクエストを処理する

お知らせの編集画面から、更新ボタンを押すことで更新(news_insert)処理を行い、一覧(news_list)に戻ります。news_insertには画面がありませんが、独立したphpファイルとして作成します。

public\admin\news_insert.phpを追加します。

<?php
date_default_timezone_set('Asia/Tokyo');
require_once(dirname(__DIR__,2).'/vendor/autoload.php');

use Cafechandra\Repositories\News;
use Studiogau\Chandra\Database\Database;
use Studiogau\Chandra\Logging\Logger;

$logger = Logger::createDefault(dirname(__DIR__, 2));

$database = Database::fromConfiguredSource(dirname(__DIR__,2).'/config/dbconfig.ini', $logger);

$newsData = [
    'title' => trim((string)($_POST['title'] ?? '')),
    'body' => trim((string)($_POST['body'] ?? '')),
    'published_at' => (string)($_POST['published_at'] ?? ''),
    'is_published' => (string)($_POST['is_published'] ?? ''),
];

$publishedAtValue = '';
if (!empty($newsData['published_at'])) {
    $publishedAt = new \DateTimeImmutable((string)$newsData['published_at']);
    $publishedAtValue = $publishedAt->format('Y-m-d');
}

$news = new News($database);
$news->insert([
    'title' => $newsData['title'],
    'body' => $newsData['body'],
    'published_at' => $publishedAt->format('Y-m-d H:i:s'),
    'is_published' => (int)$newsData['is_published'],
]);
header('Location: news_list.php', true, 303);


news_createから情報を入力し、登録ボタンを押下するとnews_insert.phpが呼び出されるようにします。
public\admin\news_create.phpのformタグに、以下の通り属性値を追加します。

<form method="post" action="news_insert.php" onsubmit="return window.confirm('登録してもいいですか?');">

【テスト】ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行して、テストが成功することを確認します。
お知らせ一覧から登録ボタンを押下し、お知らせ登録画面を表示します。内容を入力し、登録ボタンを押すことで、お知らせが保存できることを確認します。保存したお知らせを編集画面に表示し、登録日時、登録者が自動登録されていることを確認します。

管理画面:お知らせ削除

【テスト】「お知らせ削除処理を追加する」のテスト

Repositories\Newsクラスに、お知らせテーブルにレコードを登録するinsertメソッドを追加するので、あらかじめテストを作成します。
実際にデータベースを呼び出すのではなく、インターフェースが期待通りであることを確認しています。
tests\Repositories\NewsTest.phpに、次のメソッドを追加します。

public function testDeleteDeletesNewsById(): void
{
	$id = 1;
	$expectedConditions = [
		'id' => ['value' => $id, 'datatype' => \PDO::PARAM_INT],
	];

	$database = $this->createMock(Database::class);
	$database->expects($this->once())
		->method('delete')
		->with('news', $expectedConditions)
		->willReturn(1);

	$repository = new News($database);

	$deletedCount = $repository->delete($id);

	$this->assertSame(1, $deletedCount);
}

ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行します。
Newsクラスにdeleteメソッドをまだ作成していないため、エラーとなります。

お知らせ削除処理を追加する

Repositories\news.phpに、以下のdeleteメソッドを追加します。Databaseクラスのdeleteメソッドを実行します。

public function delete($id) {
	$conditions = [
		'id' => ['value' => $id, 'datatype' => \PDO::PARAM_INT],
	];
	return $this->database->delete('news', $conditions);
}

【テスト】ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行して、テストが成功することを確認します。

【テスト】「お知らせ削除リクエストを処理する」のテスト

お知らせ編集画面から、削除ボタンを押すことで削除(news_delete)処理を行い、一覧(news_list)に戻ります。public\admin\news_delete.phpに対するテストクラスtests\public\admin\newsDeletePageTest.phpを以下の通り作成します。このテストでは、Repositories\news.phpdeleteメソッドを呼び出すため、その前後でテーブル情報を退避したり戻したりしています。

<?php
declare(strict_types=1);

use PHPUnit\Framework\TestCase;
use Studiogau\Chandra\Database\Database;
use Studiogau\Chandra\Logging\Logger;
use Symfony\Component\HttpClient\HttpClient;

final class NewsDeletePageTest extends TestCase
{
    private const NEWS_DELETE_URL = 'http://localhost/cafechandra/public/admin/news_delete.php';
    private const TARGET_NEWS_ID = 1;
    private const REMAINING_NEWS_ID = 2;

    private const SEED_NEWS_SQL = <<<'SQL'
INSERT INTO `news` (`id`, `title`, `body`, `published_at`, `is_published`, `created_at`, `created_by`, `updated_at`, `updated_by`) VALUES
(1, 'テストデータ1', 'テストデータ1', '2026-04-05', '1', sysdate(), 'SYSTEM', NULL, NULL),
(2, 'テストデータ2', 'テストデータ2', '2026-04-06', '1', sysdate(), 'SYSTEM', NULL, NULL);
SQL;

    // news_delete.php と同じDB設定で接続し、実際の削除処理に近い形で結果を確認する。
    private ?Database $database = null;

    // テスト開始前のnewsテーブル全データ。テスト後にこの内容へ戻す。
    private array $backupRows = [];

    // テスト開始前のAUTO_INCREMENT値。データ復元後にこの値へ戻す。
    private ?int $backupAutoIncrement = null;

    /**
     * 各テストの前準備を行う。
     */
    protected function setUp(): void
    {
        // NewsUpdatePageTest と同一
    }

    /**
     * 各テストの後片付けを行う。
     */
    protected function tearDown(): void
    {
        // NewsUpdatePageTest と同一
    }

    /**
     * 想定通りのPOST値をnews_delete.phpへ送ると、newsテーブルから対象行が削除されることを確認する。
     */
    public function testNewsDeletePageDeletesNewsTableWithPostedId(): void
    {
        $this->assertNotNull($this->database);

        $response = HttpClient::create()->request('POST', self::NEWS_DELETE_URL, [
            'body' => [
                'id' => (string)self::TARGET_NEWS_ID,
            ],
            'max_redirects' => 0,
        ]);

        // 削除後は一覧画面へ303で戻ること
        $this->assertSame(303, $response->getStatusCode());
        $this->assertSame(['news_list.php'], $response->getHeaders(false)['location'] ?? []);

        $deletedRowCount = $this->database->fetchOne(
            'SELECT COUNT(*) AS count FROM news WHERE id = :id',
            [
                ':id' => ['value' => self::TARGET_NEWS_ID, 'datatype' => \PDO::PARAM_INT],
            ]
        );
        $remainingRowCount = $this->database->fetchOne(
            'SELECT COUNT(*) AS count FROM news WHERE id = :id',
            [
                ':id' => ['value' => self::REMAINING_NEWS_ID, 'datatype' => \PDO::PARAM_INT],
            ]
        );

        $this->assertSame(0, (int)$deletedRowCount['count']);
        $this->assertSame(1, (int)$remainingRowCount['count']);
    }

    /**
     * テスト開始前のnewsテーブルの状態を退避する。
     */
    private function backupNewsTable(): void
    {
        // NewsUpdatePageTest と同一
    }

    /**
     * newsテーブルをテスト用の固定データへ差し替える。
     */
    private function replaceNewsTableWithSeedData(): void
    {
        // NewsUpdatePageTest と同一
    }

    /**
     * newsテーブルをテスト開始前の状態へ復元する。
     */
    private function restoreNewsTable(): void
    {
        // NewsUpdatePageTest と同一
    }
}

ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行します。
public\admin\news_delete.phpをまだ作成していないため、テストは失敗となります。

お知らせ削除リクエストを処理する

お知らせの編集画面から、更新ボタンを押すことで更新(news_delkete)処理を行い、一覧(news_list)に戻ります。news_deleteには画面がありませんが、独立したphpファイルとして作成します。

public\admin\news_delete.phpを追加します。

<?php
date_default_timezone_set('Asia/Tokyo');
require_once(dirname(__DIR__,2).'/vendor/autoload.php');

use Cafechandra\Repositories\News;
use Studiogau\Chandra\Database\Database;
use Studiogau\Chandra\Logging\Logger;

$logger = Logger::createDefault(dirname(__DIR__,2));
$database = Database::fromConfiguredSource(dirname(__DIR__,2).'/config/dbconfig.ini', $logger);

// 削除対象のお知らせidをPOSTから取り出す
$id = (string)($_POST['id'] ?? '');

$news = new News($database);
// 指定されたIDのお知らせを削除する
$deleted = $news->delete((int)$id);
header('Location: news_list.php', true, 303);

public\admin\news_edit.phpのHTML部分に、削除ボタン用のformを用意し、更新時と呼び分けます。news-delete-formフォームを、news-update-formフォームとは別に追加したうえで、削除ボタンを追加します。

<form id="news-update-form" method="post" action="news_update.php" onsubmit="return window.confirm('更新してもいいですか?');">
	<!-- 省略 -->
</form>

<form id="news-delete-form" method="post" action="news_delete.php" onsubmit="return window.confirm('削除してもいいですか?');">
    <?= Utility::renderCsrfHiddenInput('news.delete.exec') ?>
    <input type="hidden" name="id" value="<?= Utility::h($id); ?>">
</form>

<div style="display:flex; gap:8px; margin-top:8px;">
    <button type="submit" form="news-update-form">更新</button>
    <button type="submit" form="news-delete-form">削除</button>
</div>

【テスト】ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行して、テストが成功することを確認します。
お知らせ一覧からタイトルリンクを押下し、お知らせ編集画面を表示します。削除ボタンを押すことで、お知らせが削除できることを確認します。

ログイン

Chandraを使った認証

ここまでで、管理画面で管理者が操作する機能を作成し終えました。次からは、管理画面を支えるログイン、セキュリティ強化、エラー対応、認可について順に追加します。
Chandra の認証は、AuthService クラスの login メソッドで行います。
ログイン時には、ユーザーIDとパスワードによる資格情報(LoginCredentials)と、ログイン試行時の状況(LoginAttempt)を渡します。
資格情報の照合は AuthService が直接行うのではなく、アプリ側の UserRepositoryInterface の実装に委譲されます。
また、LoginAttempt は、試行回数や接続元の妥当性などを検証するための情報を扱うための枠組みであり、具体的なロジックはアプリ側で実装します。

このチュートリアルでは、資格情報の妥当性の検証にとどめ、ログイン試行回数や接続元などの状況は考慮しないものとします。

このチュートリアルで実装するのは以下の通りです。

  • ユーザーを取得する
    • ユーザー情報をユーザーIDをキーに取得するRepositories\Usersクラス
    • お知らせテーブルに関するレポジトリと同様に、ユーザーテーブルに関するレポジトリを作成する
  • ユーザーを検証する
    • ユーザー情報のログイン資格の妥当性を検証するRepositories\AuthUserクラス
    • 図③Application内の、UserRepositoryInterfaceの実装にあたる
  • ログイン画面を作成する
    • ユーザーにログイン情報を入力してもらうログイン画面および、ログイン成功時に遷移するメニュー画面
    • 図①のログイン画面にあたる
    • LoginCredentialsを作成する

【テスト】「ユーザーを取得する」のテスト

Repositories\Userクラスを追加するので、あらかじめテストを作成します。
実際にデータベースを呼び出すのではなく、インターフェースが期待通りであることを確認しています。
tests\Repositories\UsersTest.phpを追加します。

<?php
declare(strict_types=1);

use Cafechandra\Repositories\Users;
use PHPUnit\Framework\TestCase;
use Studiogau\Chandra\Database\Database;

/**
 * Usersリポジトリをテストするクラス。
 */
final class UsersTest extends TestCase
{
    /**
     * selectByLoginId() がログインIDで指定されたユーザーを取得することをテストする。
     */
    public function testSelectByLoginIdFetchesUserByLoginId(): void
    {
        $loginId = 'admin';
        $expectedRow = [
            'id' => 1,
            'login_id' => 'admin',
            'user_name' => 'Administrator',
            'password_hash' => '$2y$10$examplepasswordhash',
            'permissions' => 'admin',
        ];

        $expectedSql = 'SELECT id, login_id, user_name, password_hash, permissions FROM users WHERE login_id=:login_id';
        $expectedBindings = [
            ':login_id' => ['value' => $loginId, 'datatype' => \PDO::PARAM_STR],
        ];

        $database = $this->createMock(Database::class);
        $database->expects($this->once())
            ->method('fetchOne')
            ->with($expectedSql, $expectedBindings)
            ->willReturn($expectedRow);

        $repository = new Users($database);
        $actualRow = $repository->selectByLoginId($loginId);
        $this->assertSame($expectedRow, $actualRow);
    }
}

ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行します。
Usersクラスをまだ作成していないため、エラーとなります。

ユーザーを取得する

Repositories\Users.phpは以下の通りです。selectByLoginId関数ではDatabaseクラスのfetchOneメソッドを実行して結果を戻り値にします。

<?php
namespace Cafechandra\Repositories;
use Studiogau\Chandra\Database\Database;

/**
 * ユーザーデータを担当するリポジトリクラス。
 */
class Users {

	private Database $database;

	public function __construct(Database $database) {
        $this->database = $database;
	}

	/**
	 * 指定されたログインIDを持つユーザーを取得。
	 */
	public function selectByLoginId($login_id) {

		$data = null;
		$sql = "SELECT id, login_id, user_name, password_hash, permissions FROM users WHERE login_id=:login_id";
		$bindings = [
			':login_id' => ['value' => $login_id, 'datatype' => \PDO::PARAM_STR],
		];
		$data = $this->database->fetchOne($sql, $bindings);
		return $data;
	}
}

【テスト】ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行して、テストが成功することを確認します。

【テスト】「ユーザーを検証する」のテスト

Repositories\AuthUserクラスを追加するので、あらかじめテストを作成します。
実際にデータベースを呼び出すのではなく、インターフェースが期待通りであることを確認しています。
tests\Repositories\AuthUserTest.phpを追加します。

<?php
declare(strict_types=1);

use Cafechandra\Repositories\AuthUser;
use PHPUnit\Framework\TestCase;
use Studiogau\Chandra\Database\Database;

/**
 * AuthUserリポジトリをテストするクラス。
 */
final class AuthUserTest extends TestCase
{
    /**
     * findByCredentials() がパスワード照合に成功した場合、認証済みユーザー情報を返すことをテストする。
     */
    public function testFindByCredentialsReturnsUser(): void
    {
        $loginId = 'admin';
        $password = 'correct-password';
        $storedUser = [
            'id' => 1,
            'login_id' => $loginId,
            'user_name' => 'Administrator',
            'password_hash' => password_hash($password, PASSWORD_DEFAULT),
            'permissions' => 'news_edit,news_edit,admin',
        ];
        $expectedUser = [
            'id' => 1,
            'login_id' => $loginId,
            'user_name' => 'Administrator',
            'permissions' => ['news_edit', 'admin'],
        ];

        $expectedSql = 'SELECT id, login_id, user_name, password_hash, permissions FROM users WHERE login_id=:login_id';
        $expectedBindings = [
            ':login_id' => ['value' => $loginId, 'datatype' => \PDO::PARAM_STR],
        ];

        $database = $this->createMock(Database::class);
        $database->expects($this->once())
            ->method('fetchOne')
            ->with($expectedSql, $expectedBindings)
            ->willReturn($storedUser);

        $repository = new AuthUser($database);
        $actualUser = $repository->findByCredentials($loginId, $password);
        $this->assertSame($expectedUser, $actualUser);
    }
}

ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行します。
AuthUserクラスをまだ作成していないため、エラーとなります。

ユーザーを検証する

AuthUser.phpは、UserRepositoryInterface を実装し、ログインIDとパスワードを使ったユーザー認証を担当するクラスです。ユーザー情報の取得自体は Usersクラスに委ね、取得した情報をもとにパスワードを検証して、LoginUserの生成に必要な認証済みユーザー情報を返します。

<?php
namespace Cafechandra\Repositories;
use Studiogau\Chandra\Database\Database;
use Studiogau\Chandra\Auth\UserRepositoryInterface;

/**
 * 認証ユーザー情報を扱うリポジトリクラス。
 */
class AuthUser implements UserRepositoryInterface {

	private Database $database;
	public function __construct(Database $database) {
        $this->database = $database;
	}

	/**
	 * ログインIDとパスワードをもとに、認証済みユーザー情報を取得する。
	 */
    public function findByCredentials(string $login_id, string $password): ?array {

		$data = null;
        $user = new Users($this->database);
        $data = $user->selectByLoginId($login_id);

		// 入力されたパスワードと、データベースに保存されているハッシュ値を照合する。
		if (!$data || !password_verify($password, $data['password_hash'])) {
			return null;
		}

		return [
			'id' => $data['id'],
			'login_id' => $data['login_id'],
			'user_name' => $data['user_name'],
			'permissions' => array_values(array_unique(explode(',', $data['permissions'])))
		];
	}
}

【テスト】ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行して、テストが成功することを確認します。

【テスト】「ログイン画面を表示する」のテスト

ログインの画面と処理について、あらかじめテストを作成します。
tests\public\admin\indexPageTest.phpを追加します。

<?php
declare(strict_types=1);

use PHPUnit\Framework\TestCase;
use Symfony\Component\BrowserKit\HttpBrowser;
use Symfony\Component\HttpClient\HttpClient;

final class AdminIndexPageTest extends TestCase
{
    private const ADMIN_INDEX_URL = 'http://localhost/cafechandra/public/admin/index.php';

    /**
     * admin/index.phpがHTTPリクエストで正常に表示されることをテスト
     */
    public function testIndexPageCanBeDisplayed(): void
    {
        $browser = new HttpBrowser(HttpClient::create());

        $browser->request('GET', self::ADMIN_INDEX_URL);
        $response = $browser->getResponse();
        $content = $response->getContent();

        $this->assertSame(200, $response->getStatusCode());
        $this->assertNotEmpty($content);
        $this->assertStringContainsString('<title>ログイン</title>', $content);
        $this->assertStringContainsString('action="login.php"', $content);
    }
}


ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行します。
結果が失敗することを確認します。

ログイン画面を表示する

ログイン画面を作成します。
public/admin/index.phpでは、ログインIDとパスワードを入力し、ログインボタンを押下します。入力された情報はpublic/admin/login.php に POST 送信され、そこでAuthServiceクラスの loginメソッドを使用してログイン処理を行います。認証に成功した場合はお知らせ一覧画面を表示します。

public/admin/index.phpは以下の通りです。

<?php
date_default_timezone_set('Asia/Tokyo');
// composerを使用
require_once(dirname(__DIR__,2).'/vendor/autoload.php');

?>
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>ログイン</title>
</head>
<body>

	<form method="post" name="login" action="login.php">
		<h1>ログイン</h1>
		<input type="text" name="user" value="admin" maxlength="16" placeholder="ログインID">
		<input type="password" name="pass" value="admin1234" maxlength="128" placeholder="パスワード">
		<button type="submit" name="admin">ログイン</button>
	</form>

</body>
</html>

【テスト】ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行して、テストが成功することを確認します。
ブラウザから[http://localhost/cafechandra/public/admin/が表示されることを確認します。](http://localhost/cafechandra/public/admin/`が表示されることを確認します。)

【テスト】「ログインを処理する」のテスト

ログイン画面から、ログインボタンを押すことでログイン(login)処理を行い、一覧(news_list)に遷移します。public\admin\login.phpに対するテストクラスtests\public\admin\loginPageTest.phpを以下の通り作成します。このテストではログインを行うため、その前後でテーブル情報を退避したり戻したりしています。

<?php
declare(strict_types=1);

use PHPUnit\Framework\TestCase;
use Studiogau\Chandra\Database\Database;
use Studiogau\Chandra\Logging\Logger;
use Symfony\Component\BrowserKit\Cookie;
use Symfony\Component\BrowserKit\HttpBrowser;
use Symfony\Component\HttpClient\HttpClient;

final class LoginPageTest extends TestCase
{
    private const BASE_URL = 'http://localhost/cafechandra/public/admin/';
    private const TEST_LOGIN_ID = 'phpunit_admin';
    private const TEST_PASSWORD = 'phpunit-password';

    private ?Database $database = null;
    private array $backupRows = [];
    private ?int $backupAutoIncrement = null;

    /**
     * 各テストの前準備を行う。
     */
    protected function setUp(): void
    {
        $projectRoot = dirname(__DIR__, 3);
        $logger = Logger::createDefault($projectRoot);
        $this->database = Database::fromConfiguredSource($projectRoot . '/config/dbconfig.ini', $logger);

        $this->backupUsersTable();
        $this->replaceUsersTableWithTestUser();
    }

    /**
     * 各テストの後片付けを行う。。
     */
    protected function tearDown(): void
    {
        if ($this->database !== null) {
            $this->restoreUsersTable();
            $this->database->close();
        }
    }

    /**
     * 想定通りのPOST値をlogin.phpへ送ると、ログインして一覧画面へ進めることを確認する。
     */
    public function testLoginPageLogsInWithPostedCredentials(): void
    {
        $loginResponse = HttpClient::create()->request('POST', self::BASE_URL . 'login.php', [
            'body' => [
                'user' => self::TEST_LOGIN_ID,
                'pass' => self::TEST_PASSWORD,
            ],
            'headers' => [
                'User-Agent' => 'PHPUnit BrowserKit',
            ],
            'max_redirects' => 0,
        ]);

        // ログイン成功後は一覧画面へ303で遷移すること
        $this->assertSame(303, $loginResponse->getStatusCode());
        $this->assertSame(['news_list.php'], $loginResponse->getHeaders(false)['location'] ?? []);

        $browser = new HttpBrowser(HttpClient::create());
        foreach ($loginResponse->getHeaders(false)['set-cookie'] ?? [] as $cookieHeader) {
            $browser->getCookieJar()->set(Cookie::fromString($cookieHeader, self::BASE_URL . 'login.php'));
        }

        $browser->request('GET', self::BASE_URL . 'news_list.php');
        $newsListResponse = $browser->getResponse();
        $content = $newsListResponse->getContent();

        // 発行されたセッションCookieで一覧画面を表示できること
        $this->assertSame(200, $newsListResponse->getStatusCode());
        $this->assertStringContainsString('<title>お知らせ管理</title>', $content);
    }

    /**
     * テスト開始前のusersテーブルの状態を退避する。
     */
    private function backupUsersTable(): void
    {
        $this->assertNotNull($this->database);

        $this->backupRows = $this->database->fetchList(
            'SELECT id, login_id, password_hash, user_name, permissions, created_at, created_by, updated_at, updated_by FROM users ORDER BY id ASC'
        );

        $tableStatus = $this->database->fetchOne("SHOW TABLE STATUS LIKE 'users'");
        $this->backupAutoIncrement = (int)$tableStatus['Auto_increment'];
    }

    /**
     * usersテーブルをテスト用の固定データへ差し替える。
     */
    private function replaceUsersTableWithTestUser(): void
    {
        $this->assertNotNull($this->database);

        $pdo = $this->database->getConnection()->getPdo();
        $pdo->exec('DELETE FROM users');

        $stmt = $pdo->prepare(
            'INSERT INTO users (login_id, password_hash, user_name, permissions, created_at, created_by, updated_at, updated_by) ' .
            'VALUES (:login_id, :password_hash, :user_name, :permissions, sysdate(), :created_by, NULL, NULL)'
        );
        $stmt->execute([
            ':login_id' => self::TEST_LOGIN_ID,
            ':password_hash' => password_hash(self::TEST_PASSWORD, PASSWORD_DEFAULT),
            ':user_name' => 'PHPUnit Admin',
            ':permissions' => 'news_list,news_create,news_edit,news_delete',
            ':created_by' => 'PHPUnit',
        ]);
    }

    /**
     * usersテーブルをテスト開始前の状態へ復元する。
     */
    private function restoreUsersTable(): void
    {
        $this->assertNotNull($this->database);

        $pdo = $this->database->getConnection()->getPdo();
        $pdo->exec('DELETE FROM users');

        $stmt = $pdo->prepare(
            'INSERT INTO users (id, login_id, password_hash, user_name, permissions, created_at, created_by, updated_at, updated_by) ' .
            'VALUES (:id, :login_id, :password_hash, :user_name, :permissions, :created_at, :created_by, :updated_at, :updated_by)'
        );

        foreach ($this->backupRows as $row) {
            $stmt->execute([
                ':id' => (int)$row['id'],
                ':login_id' => $row['login_id'],
                ':password_hash' => $row['password_hash'],
                ':user_name' => $row['user_name'],
                ':permissions' => $row['permissions'],
                ':created_at' => $row['created_at'],
                ':created_by' => $row['created_by'],
                ':updated_at' => $row['updated_at'],
                ':updated_by' => $row['updated_by'],
            ]);
        }

        if ($this->backupAutoIncrement !== null) {
            $pdo->exec('ALTER TABLE users AUTO_INCREMENT = ' . $this->backupAutoIncrement);
        }
    }
}


ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行します。
public\admin\login.phpをまだ作成していないため、テストは失敗となります。

ログインを処理する

login.phpには画面がありませんが、独立したphpファイルとして作成します。

public\admin\login.phpを追加します。

<?php
date_default_timezone_set('Asia/Tokyo');
session_start();
// composerを使用
require_once(dirname(__DIR__,2).'/vendor/autoload.php');

use Cafechandra\Repositories\AuthUser;
use Studiogau\Chandra\Auth\AuthService;
use Studiogau\Chandra\Auth\LoginCredentials;
use Studiogau\Chandra\Auth\LoginAttempt;
use Studiogau\Chandra\Database\Database;
use Studiogau\Chandra\Logging\Logger;

$logger = Logger::createDefault(dirname(__DIR__,2));
$database = Database::fromConfiguredSource(dirname(__DIR__,2).'/config/dbconfig.ini', $logger);

// 認証サービスを生成
$auth = new AuthService(
            new AuthUser($database),    // 認証ユーザーテーブル用のロジッククラスを渡す
            $logger,
            []                          // LoginAttemptに差し込むガードはなし
        );

// ログイン処理
$credentials = new LoginCredentials((string) ($_POST['user'] ?? ''), (string) ($_POST['pass'] ?? ''));
$attempt = LoginAttempt::fromServer($credentials->getUserId(), $_SERVER);
$auth->login($credentials, $attempt);

// ログイン後は一覧画面へ
header('Location: news_list.php', true, 303);

Chandraでのログインは以下のコードにより認証しています。

$auth->login($credentials, $attempt);

ログイン成功したユーザー情報をセッションに保存するため、 session_start() が必要です。これにより、以降の画面でログイン成功情報が引き継がれます。

ブラウザから[http://localhost/cafechandra/public/admin/を表示し、ログインIDにadmin、パスワードにadmin1234(password_hash作成時に使用したパスワード)を入力してログインボタンを押します。](http://localhost/cafechandra/public/admin/を表示し、ログインIDにadmin、パスワードにadmin1234`(password_hash作成時に使用したパスワード)を入力してログインボタンを押します。)
お知らせ一覧画面が表示されることを確認します。
ログインページに戻り、IDやパスワードを変更してログインを試みると、エラーとなることも確認します。

【テスト】ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行して、テストが成功することを確認します。

認証

管理画面を表示するときに、ログインされているかどうかをチェックします。この節では例として、news_list.phpに適用させて紹介しますが、管理者がアクセスするページや機能には、全て適用する必要があります。

  • public/admin/news_create.php 新規登録フォーム表示
  • public/admin/news_insert.php 新規登録POST処理
  • public/admin/news_edit.php 編集フォーム表示
  • public/admin/news_update.php 更新POST処理
  • public/admin/news_delete.php 削除POST処理

【テスト】「お知らせ一覧の認証チェック」のテスト

tests\public\admin\NewsListPageTest.phpを以下の通り変更し、ログインをしてからテストをするようにします。

<?php
declare(strict_types=1);

use PHPUnit\Framework\TestCase;
use Studiogau\Chandra\Database\Database;
use Studiogau\Chandra\Logging\Logger;
use Symfony\Component\BrowserKit\Cookie;
use Symfony\Component\BrowserKit\HttpBrowser;
use Symfony\Component\HttpClient\HttpClient;

final class NewsListPageTest extends TestCase
{
    private const BASE_URL = 'http://localhost/cafechandra/public/admin/';
    private const TEST_LOGIN_ID = 'phpunit_admin';
    private const TEST_PASSWORD = 'phpunit-password';
    private ?Database $database = null;
    private array $backupRows = [];
    private ?int $backupAutoIncrement = null;

    /**
     * 各テストの前準備を行う。
     */
    protected function setUp(): void
    {
        $projectRoot = dirname(__DIR__, 3);
        $logger = Logger::createDefault($projectRoot);
        $this->database = Database::fromConfiguredSource($projectRoot . '/config/dbconfig.ini', $logger);

        $this->backupUsersTable();
        $this->replaceUsersTableWithTestUser();
    }

    /**
     * 各テストの後片付けを行う。
     */
    protected function tearDown(): void
    {
        // LoginPageTest と同一
    }

    /**
     * ログイン後にnews_list.phpがHTTPリクエストで正常に表示されることをテスト
     */
    public function testNewsListPageCanBeDisplayedAfterLogin(): void
    {
        $browser = new HttpBrowser(HttpClient::create());

        $this->loginWithTestUser($browser);
        $browser->request('GET', self::BASE_URL . 'news_list.php');

        $response = $browser->getResponse();
        $content = $response->getContent();

        // ログイン後、一覧画面をHTTP 200で表示できること
        $this->assertSame(200, $response->getStatusCode());
        // 画面のHTMLが空ではないこと
        $this->assertNotEmpty($content);
        // <title>お知らせ管理</title> が含まれていること
        $this->assertStringContainsString('<title>お知らせ管理</title>', $content);
    }

    /**
     * HttpClientでlogin.phpにPOSTし、発行されたセッションCookieをBrowserKitへ渡す。
     */
    private function loginWithTestUser(HttpBrowser $browser): void
    {
        $response = HttpClient::create()->request(
            'POST',
            self::BASE_URL . 'login.php',
            [
                'body' => [
                    'user' => self::TEST_LOGIN_ID,
                    'pass' => self::TEST_PASSWORD,
                ],
                'headers' => [
                    'User-Agent' => 'PHPUnit BrowserKit',
                ],
                'max_redirects' => 0,
            ]
        );

        $this->assertSame(303, $response->getStatusCode());
        $this->assertSame(['news_list.php'], $response->getHeaders(false)['location'] ?? []);

        foreach ($response->getHeaders(false)['set-cookie'] ?? [] as $cookieHeader) {
            $browser->getCookieJar()->set(Cookie::fromString($cookieHeader, self::BASE_URL . 'login.php'));
        }
    }

    /**
     * テスト開始前のusersテーブルの状態を退避する。
     */
    private function backupUsersTable(): void
    {
        // LoginPageTest と同一
    }

    /**
     * usersテーブルをテスト用の固定データへ差し替える。
     */
    private function replaceUsersTableWithTestUser(): void
    {
        // LoginPageTest と同一
    }

    /**
     * usersテーブルをテスト開始前の状態へ復元する。
     */
    private function restoreUsersTable(): void
    {
        // LoginPageTest と同一
    }
}

お知らせ一覧の認証チェック

public\admin\news_list.phpに、以下の通り変更します。

<?php
date_default_timezone_set('Asia/Tokyo');
session_start();
// composerを使用
require_once(dirname(__DIR__,2).'/vendor/autoload.php');

use Cafechandra\Repositories\AuthUser;
use Cafechandra\Repositories\News;
use Studiogau\Chandra\Auth\AuthService;
use Studiogau\Chandra\Database\Database;
use Studiogau\Chandra\Logging\Logger;
use Studiogau\Chandra\Support\SessionHelper;
use Studiogau\Chandra\Support\Utility;

$logger = Logger::createDefault(dirname(__DIR__,2));
$database = Database::fromConfiguredSource(dirname(__DIR__,2).'/config/dbconfig.ini', $logger);

// 認証チェック
$auth = new AuthService(new AuthUser($database), $logger);
$currentUser = $auth->getCurrentUser();
if (!$auth->checkUserSession()) {
	$logger->error(basename(__FILE__)." checkUserSession failed for user id id=".$currentUser?->getLoginId());
	if (session_status() !== PHP_SESSION_ACTIVE) {
		session_start();
	}
	header("Location: index.php");
	exit;
}
$news = new News($database);
$newsList = $news->listAll();

?>
<!DOCTYPE html>
<!-- 以下同一 -->

ログアウト

Chandra ログアウトは、AuthService クラスの login メソッドを呼び出すだけです。

【テスト】「ログアウトを処理する」のテスト

一覧(news_list)画面から、ログアウトリンクを押すことでログアウト(logout)処理を行い、ログイン画面に遷移します。public\admin\logout.phpに対するテストクラスtests\public\admin\LogoutPageTest.phpを以下の通り作成します。

<?php
final class LogoutPageTest extends TestCase
{
    // フィールド定義はLoginPageTest と同一

    /**
     * logout.phpをログイン済み状態で呼び出せるよう、テスト用ユーザーを準備する。
     */
    protected function setUp(): void
    {
        // LoginPageTest と同一
    }

    /**
     * テストで差し替えたusersテーブルを元に戻す。
     */
    protected function tearDown(): void
    {
        // LoginPageTest と同一
    }

    /**
     * ログアウト後に認証が必要な一覧画面へ戻れないことを確認する。
     */
    public function testLogoutPageLogsOutCurrentUser(): void
    {
        $browser = new HttpBrowser(HttpClient::create());
        $browser->followRedirects(false);

        $this->loginWithTestUser($browser);
        $browser->request('GET', self::BASE_URL . 'logout.php');

        $logoutResponse = $browser->getResponse();
        $this->assertSame(303, $logoutResponse->getStatusCode());
        $this->assertSame('index.php', $logoutResponse->getHeader('Location'));

        // ログアウト後は一覧画面へアクセスできず、ログイン画面へ戻ること。
        $browser->request('GET', self::BASE_URL . 'news_list.php');
        $newsListResponse = $browser->getResponse();

        $this->assertSame(302, $newsListResponse->getStatusCode());
        $this->assertSame('index.php', $newsListResponse->getHeader('Location'));
    }

    /**
     * HttpClientでlogin.phpにPOSTし、発行されたセッションCookieをBrowserKitへ渡す。
     */
    private function loginWithTestUser(HttpBrowser $browser): void
    {
        // NewsListPageTest と同一
    }

    /**
     * テスト開始前のusersテーブルの状態を退避する。
     */
    private function backupUsersTable(): void
    {
        // LoginPageTest と同一
    }

    /**
     * usersテーブルをテスト用の固定データへ差し替える。
     */
    private function replaceUsersTableWithTestUser(): void
    {
        // LoginPageTest と同一
    }

    /**
     * usersテーブルをテスト開始前の状態へ復元する。
     */
    private function restoreUsersTable(): void
    {
        // LoginPageTest と同一
    }
}


ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行します。
public\admin\logout.phpをまだ作成していないため、テストは失敗となります。

ログアウトを処理する

public\admin\logout.phpを追加します。

<?php
date_default_timezone_set('Asia/Tokyo');
session_start();
require_once(dirname(__DIR__, 2) . '/vendor/autoload.php');

use Cafechandra\Repositories\AuthUser;
use Studiogau\Chandra\Auth\AuthService;
use Studiogau\Chandra\Database\Database;
use Studiogau\Chandra\Logging\Logger;

$logger = Logger::createDefault(dirname(__DIR__, 2));
$database = Database::fromConfiguredSource(dirname(__DIR__, 2) . '/config/dbconfig.ini', $logger);
$auth = new AuthService(new AuthUser($database), $logger);

// ログインユーザーのセッションを削除してログイン画面へ戻す。
$auth->logout();
header('Location: index.php', true, 303);
exit;

public\admin\news_list.phpに、ログアウトリンクを追加します。

<a href="logout.php">ログアウト</a>

ブラウザからログインし、お知らせ一覧画面にログアウトリンクが表示されることを確認します。ログアウトリンクを押下すると、ログイン画面が表示されることを確認します。この状態で、ブラウザURLにURLを入力して、お知らせ一覧画面を表示しようとしても、ログイン画面に戻ることを確認します。

【テスト】ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行して、テストが成功することを確認します。


これで管理者がお知らせを編集するための基本的な機能は全て実装できました。次からは、入力値チェックやエラー処理、セキュリティ対策といった、システムに必要な機能を実装していきます。

メッセージ表示

セッション経由のエラーメッセージ

SessionHelper::flushError は、画面に表示するエラーメッセージをセッションに一時保存するためのメソッドです。最新の1件だけが保持(上書き)されます。取り出すときはSessionHelper::getFlushError()によりメッセージを取り出します。取り出したメッセージはセッションから破棄されます。

メッセージのセット例。エラーを感知した時点でメッセージをセットしておきます。

SessionHelper::flushError("不正なIDです。");

メッセージの表示例。画面表示タイミングでメッセージがあれば1度だけ取り出して表示します。

<h3 style="color:red;"><?= Utility::h(SessionHelper::getFlushError()) ?></h3>

セッション経由の成功メッセージ

SessionHelper::flushSuccessは、SessionHelper::flushError と同じ働きをしますが、別セッションに保存します。処理完了時に通知することを想定しています。

SessionHelper::flushSuccess("お知らせを更新しました。");

画面表示タイミングでメッセージがあれば1度だけ取り出して表示します。

<h3 style="color:green;"><?= Utility::h(SessionHelper::getFlushSuccess()) ?></h3>

処理成功メッセージを表示する

お知らせの登録、変更、削除それぞれが成功したときに、お知らせ一覧にメッセージを表示します。
news_list.phpの画面部分に以下を追加します。

<h3 style="color:red;"><?= Utility::h(SessionHelper::getFlushError()) ?></h3>
<h3 style="color:green;"><?= Utility::h(SessionHelper::getFlushSuccess()) ?></h3>

news_update.phpで、$news->updateById()を実行したあとに、メッセージをセットします。

// セッションヘルパークラスを使用
use Studiogau\Chandra\Support\SessionHelper;  // ★追加

// 省略

// 指定されたIDのお知らせを、フォーム入力の内容で更新する
$news->updateById((int)$newsData['id'], [
    'title' => $newsData['title'],
    'body' => $newsData['body'],
    'published_at' => $publishedAtValue,
    'is_published' => (int)$newsData['is_published'],
]);
// 更新後は一覧画面へ戻す
SessionHelper::flushSuccess("お知らせを更新しました。");  // ★追加
header('Location: news_list.php', true, 303);

news_insert.phpで、$news->insert()を実行したあとに、メッセージをセットします。


// セッションヘルパークラスを使用
use Studiogau\Chandra\Support\SessionHelper;  // ★追加

// 省略

// 指定されたIDのお知らせを、フォーム入力の内容で登録する
$news->insert([
    'title' => $newsData['title'],
    'body' => $newsData['body'],
    'published_at' => $publishedAt->format('Y-m-d H:i:s'),
    'is_published' => (int)$newsData['is_published'],
]);
// 登録後は一覧画面へ戻す
SessionHelper::flushSuccess("お知らせを登録しました。");  // ★追加
header('Location: news_list.php', true, 303);

news_delete.phpで、$news->delete()を実行したあとに、メッセージをセットします。


// セッションヘルパークラスを使用
use Studiogau\Chandra\Support\SessionHelper;  // ★追加

// 省略

// 指定されたIDのお知らせを削除する
$deleted = $news->delete((int)$id);
// 登録後は一覧画面へ戻す
SessionHelper::flushSuccess("お知らせを削除しました。");  // ★追加
header('Location: news_list.php', true, 303);

ブラウザからお知らせの登録、変更、削除を行い、完了時にお知らせ一覧画面にそれぞれメッセージが表示されることを確認します。

入力チェック

入力チェックについて

ユーザーが画面のフォーム備品を通じて送信するデータから、意図しない形式のものをあらかじめ検知し、エラーメッセージを表示します。$_POST / $_GET で送信されたデータを信用せず、必須チェック、文字数や数値、日付形式のチェックなどをおこないます。エラー検出時にエラーメッセージをセッションにセットし、適切な画面にリダイレクトします。

このプロジェクトでは、以下の入力値チェックを行います:

  • お知らせ編集(news_edit.php)→エラー時はお知らせ一覧(news_list.php)にエラーメッセージを表示する
    • クエリパラメータのIDが数値であること
  • お知らせ削除リクエスト(news_delete.php))→エラー時はお知らせ一覧(news_list.php)にエラーメッセージを表示する
    • リクエストがPOSTである
    • 削除対象のお知らせのIDが数値である
  • お知らせ更新リクエスト(news_update.php)→エラー時はお知らせ編集(news_edit.php)にエラーメッセージを表示する
    • リクエストがPOSTである
    • 更新対象のお知らせIDが数値である
    • 入力されたタイトルが空白ではない、かつ、100文字以上ではない
    • 本文が空白ではない
    • 公開日が空白ではない、かつ、有効な日付形式である
    • 公開状態の値が0または1である
  • お知らせ登録リクエスト(news_insert.php))→エラー時はお知らせ登録(news_create.php)にエラーメッセージを表示する
    • リクエストがPOSTである
    • 入力されたタイトルが空白ではない、かつ、100文字以上ではない
    • 本文が空白ではない
    • 公開日が空白ではない、かつ、有効な日付形式である
    • 公開状態の値が0または1である

【テスト】「お知らせ更新リクエストの入力チェック」のテスト

例として、お知らせ更新リクエストでの入力チェックを実施します。

tests\public\admin\newsUpdatePageTest.phpに、以下のテストケースを追加します。


// POST以外のリクエストは不正な画面遷移として拒否されることを確認する。
public function testNewsUpdatePageRejectsNonPostRequest(): void
{
	$this->assertNotNull($this->database);

	$cookieHeader = $this->loginAndCreateCookieHeader();
	$response = HttpClient::create()->request('GET', self::BASE_URL . 'news_update.php', [
		'headers' => [
			'Cookie' => $cookieHeader,
			'User-Agent' => 'PHPUnit BrowserKit',
		],
		'max_redirects' => 0,
	]);

	$this->assertSame(303, $response->getStatusCode());
	$this->assertSame(['news_list.php'], $response->getHeaders(false)['location'] ?? []);

	$redirectResponse = HttpClient::create()->request('GET', self::BASE_URL . 'news_list.php', [
		'headers' => [
			'Cookie' => $cookieHeader,
			'User-Agent' => 'PHPUnit BrowserKit',
		],
		'max_redirects' => 0,
	]);

	$this->assertSame(200, $redirectResponse->getStatusCode());
	$this->assertStringContainsString('不正な画面遷移です。', $redirectResponse->getContent());

	// 不正なリクエストの場合はお知らせが更新されないことを確認する。
	$row = $this->database->fetchOne(
		'SELECT title, body, published_at, is_published FROM news WHERE id = :id',
		[
			':id' => ['value' => self::TARGET_NEWS_ID, 'datatype' => \PDO::PARAM_INT],
		]
	);

	$this->assertSame('テストデータ1', $row['title']);
	$this->assertSame('テストデータ1', $row['body']);
	$this->assertSame('2026-04-05', $row['published_at']);
	$this->assertSame(1, (int)$row['is_published']);
}
// 更新対象のお知らせIDが数値であることの入力チェックを確認する。
public function testNewsUpdatePageRejectsNonNumericNewsId(): void
{
    $cookieHeader = $this->loginAndCreateCookieHeader();
    $response = HttpClient::create()->request('POST', self::BASE_URL . 'news_update.php', [
        'body' => [
            'id' => 'invalid-id',
            'title' => 'Valid title',
            'body' => 'Valid body',
            'published_at' => '2026-05-12',
            'is_published' => '1',
        ],
        'headers' => [
            'Cookie' => $cookieHeader,
            'User-Agent' => 'PHPUnit BrowserKit',
        ],
        'max_redirects' => 0,
    ]);

    $this->assertSame(303, $response->getStatusCode());
    $this->assertSame(['news_list.php'], $response->getHeaders(false)['location'] ?? []);

    $redirectResponse = HttpClient::create()->request('GET', self::BASE_URL . 'news_list.php', [
        'headers' => [
            'Cookie' => $cookieHeader,
            'User-Agent' => 'PHPUnit BrowserKit',
        ],
        'max_redirects' => 0,
    ]);

    $this->assertSame(200, $redirectResponse->getStatusCode());
    $this->assertStringContainsString('不正なIDです。', $redirectResponse->getContent());
}

// 入力されたタイトルが空白ではなく、100文字以上ではないことの入力チェックを確認する。
public function testNewsUpdatePageRejectsInvalidTitle(): void
{
    $cookieHeader = $this->loginAndCreateCookieHeader();

    foreach (['   ', str_repeat('a', 100)] as $title) {
        $response = HttpClient::create()->request('POST', self::BASE_URL . 'news_update.php', [
            'body' => [
                'id' => (string)self::TARGET_NEWS_ID,
                'title' => $title,
                'body' => 'Valid body',
                'published_at' => '2026-05-12',
                'is_published' => '1',
            ],
            'headers' => [
                'Cookie' => $cookieHeader,
                'User-Agent' => 'PHPUnit BrowserKit',
            ],
            'max_redirects' => 0,
        ]);

        $this->assertSame(303, $response->getStatusCode());
        $this->assertSame(['news_edit.php?id=' . self::TARGET_NEWS_ID], $response->getHeaders(false)['location'] ?? []);

        $redirectResponse = HttpClient::create()->request('GET', self::BASE_URL . 'news_edit.php?id=' . self::TARGET_NEWS_ID, [
            'headers' => [
                'Cookie' => $cookieHeader,
                'User-Agent' => 'PHPUnit BrowserKit',
            ],
            'max_redirects' => 0,
        ]);

        $this->assertSame(200, $redirectResponse->getStatusCode());
        $this->assertStringContainsString('タイトルを正しく入力してください。', $redirectResponse->getContent());
    }
}

// 本文が空白ではないことの入力チェックを確認する。
public function testNewsUpdatePageRejectsBlankBody(): void
{
    $cookieHeader = $this->loginAndCreateCookieHeader();
    $response = HttpClient::create()->request('POST', self::BASE_URL . 'news_update.php', [
        'body' => [
            'id' => (string)self::TARGET_NEWS_ID,
            'title' => 'Valid title',
            'body' => " \n ",
            'published_at' => '2026-05-12',
            'is_published' => '1',
        ],
        'headers' => [
            'Cookie' => $cookieHeader,
            'User-Agent' => 'PHPUnit BrowserKit',
        ],
        'max_redirects' => 0,
    ]);

    $this->assertSame(303, $response->getStatusCode());
    $this->assertSame(['news_edit.php?id=' . self::TARGET_NEWS_ID], $response->getHeaders(false)['location'] ?? []);

    $redirectResponse = HttpClient::create()->request('GET', self::BASE_URL . 'news_edit.php?id=' . self::TARGET_NEWS_ID, [
        'headers' => [
            'Cookie' => $cookieHeader,
            'User-Agent' => 'PHPUnit BrowserKit',
        ],
        'max_redirects' => 0,
    ]);

    $this->assertSame(200, $redirectResponse->getStatusCode());
    $this->assertStringContainsString('本文を入力してください。', $redirectResponse->getContent());
}

// 公開日が空白ではなく、有効な日付形式であることの入力チェックを確認する。
public function testNewsUpdatePageRejectsInvalidPublishedDate(): void
{
    $cookieHeader = $this->loginAndCreateCookieHeader();

    foreach (['', '2026-02-30'] as $publishedAt) {
        $response = HttpClient::create()->request('POST', self::BASE_URL . 'news_update.php', [
            'body' => [
                'id' => (string)self::TARGET_NEWS_ID,
                'title' => 'Valid title',
                'body' => 'Valid body',
                'published_at' => $publishedAt,
                'is_published' => '1',
            ],
            'headers' => [
                'Cookie' => $cookieHeader,
                'User-Agent' => 'PHPUnit BrowserKit',
            ],
            'max_redirects' => 0,
        ]);

        $this->assertSame(303, $response->getStatusCode());
        $this->assertSame(['news_edit.php?id=' . self::TARGET_NEWS_ID], $response->getHeaders(false)['location'] ?? []);

        $redirectResponse = HttpClient::create()->request('GET', self::BASE_URL . 'news_edit.php?id=' . self::TARGET_NEWS_ID, [
            'headers' => [
                'Cookie' => $cookieHeader,
                'User-Agent' => 'PHPUnit BrowserKit',
            ],
            'max_redirects' => 0,
        ]);

        $this->assertSame(200, $redirectResponse->getStatusCode());
        $this->assertStringContainsString('公開日時を正しく入力してください。', $redirectResponse->getContent());
    }
}

// 公開状態の値が0または1であることの入力チェックを確認する。
public function testNewsUpdatePageRejectsInvalidPublishedStatus(): void
{
    $cookieHeader = $this->loginAndCreateCookieHeader();
    $response = HttpClient::create()->request('POST', self::BASE_URL . 'news_update.php', [
        'body' => [
            'id' => (string)self::TARGET_NEWS_ID,
            'title' => 'Valid title',
            'body' => 'Valid body',
            'published_at' => '2026-05-12',
            'is_published' => '2',
        ],
        'headers' => [
            'Cookie' => $cookieHeader,
            'User-Agent' => 'PHPUnit BrowserKit',
        ],
        'max_redirects' => 0,
    ]);

    $this->assertSame(303, $response->getStatusCode());
    $this->assertSame(['news_edit.php?id=' . self::TARGET_NEWS_ID], $response->getHeaders(false)['location'] ?? []);

    $redirectResponse = HttpClient::create()->request('GET', self::BASE_URL . 'news_edit.php?id=' . self::TARGET_NEWS_ID, [
        'headers' => [
            'Cookie' => $cookieHeader,
            'User-Agent' => 'PHPUnit BrowserKit',
        ],
        'max_redirects' => 0,
    ]);

    $this->assertSame(200, $redirectResponse->getStatusCode());
    $this->assertStringContainsString('公開状態を正しく選択してください。', $redirectResponse->getContent());
}

お知らせ更新リクエストの入力チェック

public\admin\news_update.phpに、以下の通り変更し、入力チェックをします。

// ユーティリティクラスを使用
use Studiogau\Chandra\Support\Utility;

// $newsDataの定義(省略)

// IDの形式が正しくない場合はエラーにして一覧画面へ戻す
if (!Utility::checkNumeric($newsData['id'], 1)) {
    $logger->error(basename(__FILE__).' op=update.check msg="Invalid news id." id=' . $newsData['id']);
    SessionHelper::flushError("不正なIDです。");
    header('Location: news_list.php', true, 303);
    exit;
}

// タイトルが空、または100文字以上の場合はエラーにする
if ($newsData['title'] === '' || mb_strlen($newsData['title']) >= 100) {
    $logger->error(basename(__FILE__).' op=update.check msg="Invalid title." id=' . $newsData['id']);
    SessionHelper::flushError("タイトルを正しく入力してください。");
    header('Location: news_edit.php?id=' . rawurlencode($newsData['id']), true, 303);
    exit;
}

// 本文が空の場合はエラーにする
if ($newsData['body'] === '') {
    $logger->error(basename(__FILE__).' op=update.check msg="Invalid body." id=' . $newsData['id']);
    SessionHelper::flushError("本文を入力してください。");
    header('Location: news_edit.php?id=' . rawurlencode($newsData['id']), true, 303);
    exit;
}

// 公開日時を日付として扱える形に変換する
$publishedAt = \DateTimeImmutable::createFromFormat('Y-m-d', $newsData['published_at']);
if (!$publishedAt || $publishedAt->format('Y-m-d') !== $newsData['published_at']) {
    $logger->error(basename(__FILE__).' op=update.check msg="Invalid published_at." id=' . $newsData['id']);
    SessionHelper::flushError("公開日時を正しく入力してください。");
    header('Location: news_edit.php?id=' . rawurlencode($newsData['id']), true, 303);
    exit;
}
$publishedAtValue = $publishedAt->format('Y-m-d');
// is_publishedの値が0か1以外の場合はエラーにする
if (!in_array($newsData['is_published'], ['0', '1'], true)) {
    $logger->error(basename(__FILE__).' op=update.check msg="Invalid is_published." id=' . $newsData['id']);
    SessionHelper::flushError("公開状態を正しく選択してください。");
    header('Location: news_edit.php?id=' . rawurlencode($newsData['id']), true, 303);
    exit;
}


// $newsをインスタンス化し、$news->updateById()を実施する(省略)

public\admin\news_edit.phpに、以下の通り追加し、エラーメッセージが表示できるようにします。

// セッションヘルパークラスを使用
use Studiogau\Chandra\Support\SessionHelper;
<h3 style="color:red;"><?= Utility::h(SessionHelper::getFlushError()) ?></h3>
<h3 style="color:green;"><?= Utility::h(SessionHelper::getFlushSuccess()) ?></h3>

ブラウザからお知らせの変更を行って確認する場合は、開発者ツールからHTMLを編集し、required属性を削除するなどの変更を行ったあとのあと、変更ボタンを押してください。お知らせ編集画面にエラーメッセージが表示されることを確認します。

データの画面ごとのセッション保存

セッションについて

お知らせの編集や登録時に、入力エラー等で入力画面に戻ってエラーメッセージを表示する場合、ユーザーが入力した内容を再現すると、操作感が良くなります。入力内容を画面ごとにセッションに保存し、表示時に取り出しましょう。以下の通り機能を追加します。

お知らせ更新リクエスト(public\admin\news_update.php): エラー時に入力値をセッションに保存する
お知らせ編集画面(public\admin\news_edit.php): セッションに保存された値がある場合は画面表示する

お知らせ登録画面(public\admin\news_create.php): エラー時に入力値をセッションに保存する
お知らせ登録リクエスト(public\admin\news_insert.php): セッションに保存された値がある場合は画面表示する

Chandraで、画面ごとに任意のデータをセッションに保存するには、SessionHelper::setDataメソッドを使用します。

【テスト】「お知らせの編集画面に入力値を再現する」のテスト

tests\public\admin\NewsUpdatePageTest.phpに以下のテストを追加します。


// 入力エラーで編集画面へ戻ったとき、POSTされた入力値が一度だけ復元されることを確認する。
public function testNewsUpdatePageRestoresPostedValuesAfterValidationError(): void
{
    $cookieHeader = $this->loginAndCreateCookieHeader();

    $response = HttpClient::create()->request('POST', self::BASE_URL . 'news_update.php', [
        'body' => [
            'id' => (string)self::TARGET_NEWS_ID,
            'title' => 'Restored update title',
            'body' => " \n ",
            'published_at' => '2026-05-12',
            'is_published' => '0',
        ],
        'headers' => [
            'Cookie' => $cookieHeader,
            'User-Agent' => 'PHPUnit BrowserKit',
        ],
        'max_redirects' => 0,
    ]);

    $this->assertSame(303, $response->getStatusCode());
    $this->assertSame(['news_edit.php?id=' . self::TARGET_NEWS_ID], $response->getHeaders(false)['location'] ?? []);

    $redirectResponse = HttpClient::create()->request('GET', self::BASE_URL . 'news_edit.php?id=' . self::TARGET_NEWS_ID, [
        'headers' => [
            'Cookie' => $cookieHeader,
            'User-Agent' => 'PHPUnit BrowserKit',
        ],
        'max_redirects' => 0,
    ]);
    $content = $redirectResponse->getContent();

    $this->assertSame(200, $redirectResponse->getStatusCode());
    $this->assertStringContainsString('value="Restored update title"', $content);
    $this->assertStringContainsString('value="2026-05-12"', $content);
    $this->assertMatchesRegularExpression('/id="is_published_0"[^>]*checked/', $content);

    // 復元値は画面表示時にクリアされ、次の表示には残らないことを確認する。
    $secondResponse = HttpClient::create()->request('GET', self::BASE_URL . 'news_edit.php?id=' . self::TARGET_NEWS_ID, [
        'headers' => [
            'Cookie' => $cookieHeader,
            'User-Agent' => 'PHPUnit BrowserKit',
        ],
        'max_redirects' => 0,
    ]);

    $this->assertSame(200, $secondResponse->getStatusCode());
    $this->assertStringNotContainsString('Restored update title', $secondResponse->getContent());
}

ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行します。
結果が失敗することを確認します。

お知らせの編集画面に入力値を再現する

public\admin\news_edit.phpを以下の通り変更します。

<?php

// 以下は既存の内容
// 認証チェック
// クエリパラメータからIDを取得
// お知らせ一覧のロジッククラスを作成
// IDに該当するお知らせをデータベースから取得
// 公開日時を入力欄で扱える形式(YYYY-MM-DDTHH:MM)に変換する


// セッション内容を退避する
$formSessionKey = 'news.edit.' . $id;
$sessionNewsData = SessionHelper::getData($formSessionKey, 'newsData');

// セッション内容をクリア
SessionHelper::delData($formSessionKey);

// 画面表示用のデータを作成
$newsData = [
    'title' => (string)($storedNewsData['title'] ?? ''),
    'body' => (string)($storedNewsData['body'] ?? ''),
    'published_at' => $publishedAtValue,
    'is_published' => (string)($storedNewsData['is_published'] ?? '1'),
    'created_at' => (string)($storedNewsData['created_at'] ?? ''),
    'created_by' => (string)($storedNewsData['created_by'] ?? ''),
    'updated_at' => (string)($storedNewsData['updated_at'] ?? ''),
    'updated_by' => (string)($storedNewsData['updated_by'] ?? ''),
];

// セッションに保存された入力内容があれば初期値としてセットする
if (is_array($sessionNewsData)) {
    $newsData = array_merge($newsData, $sessionNewsData);
}

// 以下省略
?>

【テスト】ターミナルからcafechandraディレクトリに移動し、PHPUnitを実行して、テストが成功することを確認します。

例外処理

例外処理について

DB処理やログイン処理では、エラーが発生することがあります。
例外処理を行わないと、画面上にエラーの内容がそのまま表示されてしまい、ユーザーを混乱させる原因になります。

そのため、エラーが投げられる可能性がある処理は try-catch で囲みます。
catch では、必要に応じてエラー内容をログに残します。
一方で、ユーザーには詳細なエラー内容ではなく、「ログインに失敗しました」「システムエラーが発生しました」など、安全でわかりやすい文言を表示します。

例外には、DBエラーのようにシステム上の問題によって発生するものと、パスワード不一致のようにアプリケーションの仕様として意図的に発生させるものがあります。
どちらの場合も、適切に try-catch で処理することで、ユーザーに不要な混乱を与えずに済みます。

本ハンズオンでは、例として、お知らせ編集画面の表示時に、クエリーパラメータで指定されたIDがデータベースに存在しない場合の例外処理を行います。

【テスト】「DB処理時の例外処理」のテスト

tests\public\admin\newsEditPageTest.phpに、以下のテストケースを追加します。

public function testNewsEditPageRedirectsToListWhenNewsIsNotFound(): void
{
    $browser = new HttpBrowser(HttpClient::create());
    $browser->followRedirects(false);

    $this->loginWithTestUser($browser);

    $browser->request('GET', self::BASE_URL . 'news_edit.php?id=999999');

    $response = $browser->getResponse();
    $this->assertSame(303, $response->getStatusCode());
    $this->assertSame('news_list.php', $response->getHeader('Location'));

    $browser->request('GET', self::BASE_URL . 'news_list.php');
    $redirectResponse = $browser->getResponse();

    $this->assertSame(200, $redirectResponse->getStatusCode());
    $this->assertStringContainsString('対象のお知らせが見つかりません。', $redirectResponse->getContent());
}

DB処理時の例外処理

public\admin\news_edit.phpで、次の処理をtry~catchで囲みます。

// IDに該当するお知らせをデータベースから取得
$storedNewsData = $news->fetchById((int)$id);

// IDに該当するお知らせをデータベースから取得
try {
	$storedNewsData = $news->fetchById((int)$id);
} catch (\Throwable $e) {
    $logger->error(basename(__FILE__).' op=news.fetch msg="'.$e->getMessage().'" id=' . $id);
    SessionHelper::flushError("対象のお知らせが見つかりません。");
    header("Location: news_list.php", true, 303);
    exit;
}

[http://localhost/cafechandra/public/admin/news_edit.php?id=12345といった、IDとして存在しない数値をパラメータ指定してブラウザ表示すると、お知らせ一覧画面に「対象のお知らせが見つかりません。」メッセージが表示されることを確認します。](http://localhost/cafechandra/public/admin/news_edit.php?id=12345`といった、IDとして存在しない数値をパラメータ指定してブラウザ表示すると、お知らせ一覧画面に「対象のお知らせが見つかりません。」メッセージが表示されることを確認します。)

【テスト】「ログイン時の例外処理」のテスト

tests\public\admin\loginPageTest.phpに、以下のテストケースを追加します。

public function testLoginPageRedirectsToIndexWhenAuthenticationFails(): void
{
    $loginResponse = HttpClient::create()->request('POST', self::BASE_URL . 'login.php', [
        'body' => [
            'user' => self::TEST_LOGIN_ID,
            'pass' => 'wrong-password',
        ],
        'headers' => [
            'User-Agent' => 'PHPUnit BrowserKit',
        ],
        'max_redirects' => 0,
    ]);

    $this->assertSame(303, $loginResponse->getStatusCode());
    $this->assertSame(['index.php'], $loginResponse->getHeaders(false)['location'] ?? []);

    $browser = new HttpBrowser(HttpClient::create());
    foreach ($loginResponse->getHeaders(false)['set-cookie'] ?? [] as $cookieHeader) {
        $browser->getCookieJar()->set(Cookie::fromString($cookieHeader, self::BASE_URL . 'login.php'));
    }

    $browser->request('GET', self::BASE_URL . 'index.php');
    $indexResponse = $browser->getResponse();

    $this->assertSame(200, $indexResponse->getStatusCode());
    $this->assertStringContainsString('ログインID、または、パスワードを正しく入力してください', $indexResponse->getContent());
}

ログイン時の例外処理

public\admin\login.phpで、次の処理をtry~catchで囲みます。

$credentials = new LoginCredentials($loginData['user'], $loginData['pass']);
$attempt = LoginAttempt::fromServer($credentials->getUserId(), $_SERVER);
$auth->login($credentials, $attempt);

try {
	$credentials = new LoginCredentials($loginData['user'], $loginData['pass']);
	$attempt = LoginAttempt::fromServer($credentials->getUserId(), $_SERVER);
	$auth->login($credentials, $attempt);
} catch (AuthException $e) { 
	$logger->error(basename(__FILE__)." login returned Error:".$e->getMessage());
	SessionHelper::flushError("ログインID、または、パスワードを正しく入力してください");
	// チェック結果がエラーの場合ログイン画面に遷移
	header("Location: index.php", true, 303);
	exit;
}

冒頭で必要なクラスを読み込んでおきます。

use Studiogau\Chandra\Auth\AuthException;

ログイン画面から、間違ったパスワード等を入力してログインボタンを押した場合、### ログインID、または、パスワードを正しく入力してください」メッセージが表示されることを確認します。

CSRFトークン

CSRFトークンについて

ChandraのUtility::validateCsrfToken は、POST 送信された CSRF トークンがセッションに保存された正しいトークンかどうかを検証するメソッドです。 POST送信する場合は、画面にCSRFトークンを生成し、一緒にPOSTするようにします。POST値を受け取ったロジック側は、まずCSRFトークンの妥当性を検証することで、データの改ざんがないことを確認します。

本チュートリアルでは、お知らせ編集画面でCSRFトークンをPOST送信し、そのPOSTを受けるお知らせ更新リクエスト処理で妥当性をチェックします。

【テスト】「お知らせ編集でのCSRFトークンチェック」のテスト

tests\public\admin\newsUpdatePageTest.phpに編集画面からCSRFトークンを取り出す補助メソッドを追加します。

/**
 * 編集画面で発行される更新用CSRFトークンを取得する。
 */
private function issueUpdateCsrfToken(string $cookieHeader): string
{
    $response = HttpClient::create()->request('GET', self::BASE_URL . 'news_edit.php?id=' . self::TARGET_NEWS_ID, [
        'headers' => [
            'Cookie' => $cookieHeader,
            'User-Agent' => 'PHPUnit BrowserKit',
        ],
        'max_redirects' => 0,
    ]);

    $this->assertSame(200, $response->getStatusCode());

    return $this->extractCsrfToken($response->getContent());
}

/**
 * 表示されたフォームからCSRFトークンを取り出す。
 */
private function extractCsrfToken(string $content): string
{
    $this->assertMatchesRegularExpression('/name="_csrf_token" value="([^"]+)"/', $content);
    preg_match('/name="_csrf_token" value="([^"]+)"/', $content, $matches);

    return $matches[1];
}

testNewsUpdatePageUpdatesNewsTableWithPostedValuestestNewsUpdatePageRejectsNonNumericNewsIdなど、news_update.php へPOSTしているテストの body に _csrf_token を追加します。

$response = HttpClient::create()->request('POST', self::BASE_URL . 'news_update.php', [
    'body' => [
        '_csrf_token' => $this->issueUpdateCsrfToken($cookieHeader),
        'id' => (string)self::TARGET_NEWS_ID,
        'title' => '  ' . $updatedTitle . '  ',
        'body' => "  updated body\n  ",
        'published_at' => '2026-05-12',
        'is_published' => '1',
    ],
    'headers' => [
        'Cookie' => $cookieHeader,
        'User-Agent' => 'PHPUnit BrowserKit',
    ],
    'max_redirects' => 0,
]);

CSRFトークンは検証後に破棄されるため、複数回POSTするテストでは、POSTごとに新しいトークンを取得します。
最後に、CSRFトークンが不正な場合のテストを追加します。


// CSRFトークンチェックが通らない場合、一覧へ戻りシステムエラーが表示されることを確認する。
public function testNewsUpdatePageRedirectsToListWhenCsrfTokenIsInvalid(): void
{
    $cookieHeader = $this->loginAndCreateCookieHeader();

    $response = HttpClient::create()->request('POST', self::BASE_URL . 'news_update.php', [
        'body' => [
            '_csrf_token' => 'invalid-csrf-token',
            'id' => (string)self::TARGET_NEWS_ID,
            'title' => 'Valid title',
            'body' => 'Valid body',
            'published_at' => '2026-05-12',
            'is_published' => '1',
        ],
        'headers' => [
            'Cookie' => $cookieHeader,
            'User-Agent' => 'PHPUnit BrowserKit',
        ],
        'max_redirects' => 0,
    ]);

    $this->assertSame(303, $response->getStatusCode());
    $this->assertSame(['news_list.php'], $response->getHeaders(false)['location'] ?? []);

    $redirectResponse = HttpClient::create()->request('GET', self::BASE_URL . 'news_list.php', [
        'headers' => [
            'Cookie' => $cookieHeader,
            'User-Agent' => 'PHPUnit BrowserKit',
        ],
        'max_redirects' => 0,
    ]);

    $this->assertSame(200, $redirectResponse->getStatusCode());
    $this->assertStringContainsString('システムエラーが発生しました。再度お知らせ一覧からやり直してください。', $redirectResponse->getContent());
}

お知らせ編集でのCSRFトークンチェック

public\admin\news_edit.phpの更新用のForm内に以下のinputを追加します。これで、CSRFトークンを生成し、input hiddenとしてPOST送信されます。

<form id="news-update-form" method="post" action="news_update.php" onsubmit="return window.confirm('更新してもいいですか?');">
	<?= Utility::renderCsrfHiddenInput('news.update.exec') ?>

public\admin\news_update.phpでチェックを行います。リクエストがPOSTであるチェックをするブロックの後に以下のブロックを追加します。

// CSRFトークンの検証
$csrfToken = (string)($_POST[Utility::getCsrfFieldName()] ?? '');
if (!Utility::validateCsrfToken('news.update.exec', $csrfToken)) {
    $logger->error(basename(__FILE__).' op=update.check msg="Invalid csrf token"');
    SessionHelper::flushError("システムエラーが発生しました。再度お知らせ一覧からやり直してください。");
    header('Location: news_list.php', true, 303);
    exit;
}

認可

認可について

認可とは、ログイン済みのユーザーが特定の画面や操作を利用してよいかを確認する仕組みです。
Chandra は、アプリケーション側の UserRepositoryInterface 実装から返された permissions の値を、ログインユーザーの情報として保持し、$currentUser->can('news_create') で使えるようにします。どのように使うかの認可ルールは、アプリケーション側で自由に実装できます。
このシステムでは、AuthUserクラスでの戻り値として、permissions キーに対応する値が認可に使用する情報になります。認可が必要な画面や機能に入ったタイミングで確認し、権限がない場合は HTTP 403 を返し、画面表示や登録・更新・削除処理を実行しません。

Repositories\AuthUser.phpの抜粋を以下に示します。findByCredentialsメソッドの戻り値として、permissionsキーに、認可に使用する情報をマッピングしています。

class AuthUser implements UserRepositoryInterface {

    public function findByCredentials(string $login_id, string $password): ?array {

		$data = null;
        $user = new Users($this->database);
        $data = $user->selectByLoginId($login_id);

		// 入力されたパスワードと、データベースに保存されているハッシュ値を照合する。
		if (!$data || !password_verify($password, $data['password_hash'])) {
			return null;
		}

		return [
			'id' => $data['id'],
			'login_id' => $data['login_id'],
			'user_name' => $data['user_name'],
			'permissions' => array_values(array_unique(explode(',', $data['permissions'])))
		];
	}
}

【テスト】「お知らせ一覧の認可チェック」のテスト

tests\public\admin\NewsListPageTest.phpに以下のメソッドを追加します。

private const NO_PERMISSION_LOGIN_ID = 'phpunit_noauth';

// news_list権限がない場合、一覧画面が表示されず権限エラーになることを確認する。
public function testNewsListPageRejectsUserWithoutPermission(): void
{
    $browser = new HttpBrowser(HttpClient::create());

    $this->loginWithNoPermissionUser($browser);
    $browser->request('GET', self::BASE_URL . 'news_list.php');

    $response = $browser->getResponse();

    $this->assertSame(403, $response->getStatusCode());
    $this->assertStringContainsString('この画面を表示する権限がありません', $response->getContent());
}


private function loginWithNoPermissionUser(HttpBrowser $browser): void
{
    $browser->request('GET', self::BASE_URL . 'index.php');
    $csrfToken = $this->extractCsrfToken($browser->getResponse()->getContent());

    $cookies = [];
    foreach ($browser->getCookieJar()->allRawValues(self::BASE_URL . 'login.php') as $name => $value) {
        $cookies[] = $name . '=' . $value;
    }
    $this->assertNotEmpty($cookies);

    $response = HttpClient::create()->request(
        'POST',
        self::BASE_URL . 'login.php',
        [
            'body' => [
                '_csrf_token' => $csrfToken,
                'user' => self::NO_PERMISSION_LOGIN_ID,
                'pass' => self::TEST_PASSWORD,
            ],
            'headers' => [
                'Cookie' => implode('; ', $cookies),
                'User-Agent' => 'PHPUnit BrowserKit',
            ],
            'max_redirects' => 0,
        ]
    );

    $this->assertSame(303, $response->getStatusCode());
    $this->assertSame(['news_list.php'], $response->getHeaders(false)['location'] ?? []);

    foreach ($response->getHeaders(false)['set-cookie'] ?? [] as $cookieHeader) {
        $browser->getCookieJar()->set(Cookie::fromString($cookieHeader, self::BASE_URL . 'login.php'));
    }
}

権限のないユーザーもDBに追加されるよう、既存のreplaceUsersTableWithTestUserメソッドを以下の通り変更します。

private function replaceUsersTableWithTestUser(): void
{
    // 最後に以下を追加する
	$stmt->execute([
		':login_id' => self::NO_PERMISSION_LOGIN_ID,
		':password_hash' => password_hash(self::TEST_PASSWORD, PASSWORD_DEFAULT),
		':user_name' => 'PHPUnit No Permission',
		':permissions' => 'news_create',
		':created_by' => 'PHPUnit',
	]);
}

お知らせ一覧の認可チェック

public\admin\news_list.phpの、認証チェックの後に、認可チェックを行うブロックを追加します。

// 認可チェック
if (!$currentUser || !$currentUser->can('news_list')) {
    $logger->error(basename(__FILE__).' op=auth msg="Permission denied" required permission=news_list user_id='.$currentUser?->getLoginId());
	http_response_code(403);
	header('Content-Type: text/plain; charset=UTF-8');
	echo "この画面を表示する権限がありません";
	exit;
}

ブラウザでログイン時、usersテーブルのpermissionsカラムの値から、news_listを削除すると、ログイン後に「この画面を表示する権限がありません」文字が表示されることを確認します。

【テスト】「監査列にログインユーザーを反映する」のテスト

tests\public\admin\newsInsertPageTest.phpの、testNewsInsertPageInsertsNewsTableWithPostedValuesテストメソッドにで、created_byカラムも検証対象とするよう修正します。


public function testNewsInsertPageInsertsNewsTableWithPostedValues(): void
{
    // 省略
    $countRow = $this->database->fetchOne('SELECT COUNT(*) AS count FROM news');
    $insertedRow = $this->database->fetchOne(
        'SELECT title, body, published_at, is_published, created_by FROM news LIMIT 1'
    );  // ★created_byを追加

    $this->assertSame(1, (int)$countRow['count']);
    $this->assertSame($insertedTitle, $insertedRow['title']);
    $this->assertSame('inserted body', $insertedRow['body']);
    $this->assertSame('2026-05-14', $insertedRow['published_at']);
    $this->assertSame(0, (int)$insertedRow['is_published']);
    $this->assertSame(self::TEST_LOGIN_ID, $insertedRow['created_by']);  // ★追加
}

tests\public\admin\newsUpdatePageTest.phpの、testNewsUpdatePageUpdatesNewsTableWithPostedValuesテストメソッドにで、updated_byカラムも検証対象とするよう修正します。


public function testNewsUpdatePageUpdatesNewsTableWithPostedValues(): void
{
    // 省略
    $updatedRow = $this->database->fetchOne(
        'SELECT title, body, published_at, is_published, updated_by FROM news WHERE id = :id',
        [
            ':id' => ['value' => self::TARGET_NEWS_ID, 'datatype' => \PDO::PARAM_INT],
        ]
    );  // ★updated_byを追加

    $this->assertSame($updatedTitle, $updatedRow['title']);
    $this->assertSame('updated body', $updatedRow['body']);
    $this->assertSame('2026-05-12', $updatedRow['published_at']);
    $this->assertSame(1, (int)$updatedRow['is_published']);
    $this->assertSame(self::TEST_LOGIN_ID, $updatedRow['updated_by']);  // ★追加
}

監査列にログインユーザーを反映する

Chandra の Database::insert()Database::update() は、監査列 created_atcreated_byupdated_atupdated_by を自動的に設定します。 ここまでの実装では、実行ユーザーを Database に指定していないため、created_byupdated_by には既定のユーザーとして SYSTEM が入ります。 ログイン後の登録・更新処理では、AuthService から取得したログインユーザーを Database に渡しておくことで、監査列に実行者を記録できます。 public\admin\news_insert.phppublic\admin\news_update.php の認証・認可チェックの後、DB更新処理より前にも追加します。

$database->setCurrentUserId($currentUser->getLoginId());

追加位置は次のようになります。

if (!$currentUser || !$currentUser->can('news_create')) {
    $logger->error(basename(__FILE__).' op=auth msg="Permission denied" required permission=news_create user_id='.$currentUser?->getLoginId());
    http_response_code(403);
    header('Content-Type: text/plain; charset=UTF-8');
    echo "この画面を表示する権限がありません";
    exit;
}

$database->setCurrentUserId($currentUser->getLoginId());

// POST以外のリクエストは不正とみなしてエラーにする
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
    ...
}

これで、news_insert.php から登録した場合は created_by にログインIDが入り、news_update.php から更新した場合は updated_by にログインIDが入ります。リポジトリ側の News::insert()News::updateById() では、監査列を明示的に指定する必要はありません。

クイックスタート

クイックスタートを見る
クイックスタート

リファレンス

リファレンスを見る
リファレンス