見出し画像

PHPでデータベースをCRUD操作する②

 前回はプロジェクトの事前準備として、データベースの作成、テーブルの作成、プロジェクトフォルダの作成、ドキュメントルートの変更を行いました。
 今回からCRUD操作のための諸機能を作成していきます。


筆者の開発環境

PC:Apple M1 チップ搭載MacBook Air
OS:macOS Ventura 13.6
MAMP:6.8
PHP:8.2.0

基本機能の実装

システムを稼働させる基本的な機能の実装を先に行います。

ルーティングとフロントコントローラーの作成

 ルーティングとフロントコントローラーの作成は本稿のテーマを超えますのでライブラリを導入します。下記のコマンドを実行しルーティングライブラリをインストールしてください。

composer require nikic/fast-route

下記のコマンドを実行し.htaccessを作成してください。

touch public/.htaccess

.htaccessファイルを下記のように編集してください。

<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteRule ^ index.php [QSA,L]
</IfModule>

下記コマンドを実行しbootstrap.phpを作成してください。

touch bootstrap.php

bootstrap.phpを下記のように編集してください。

<?php

use App\Controllers\UsersController;

session_start();

require_once __DIR__ . '/vendor/autoload.php';

$dispatcher = FastRoute\simpleDispatcher(function(FastRoute\RouteCollector $r) {
    // ここにルーティング定義を追加していきます
});

$httpMethod = $_SERVER['REQUEST_METHOD'];
$uri = $_SERVER['REQUEST_URI'];

if (false !== $pos = strpos($uri, '?')) {
    $uri = substr($uri, 0, $pos);
}
$uri = rawurldecode($uri);

$routeInfo = $dispatcher->dispatch($httpMethod, $uri);
switch ($routeInfo[0]) {
    case FastRoute\Dispatcher::NOT_FOUND:
        echo '404 Not Found';
        break;
    case FastRoute\Dispatcher::METHOD_NOT_ALLOWED:
        echo '405 Method Not Allowed';
        break;
    case FastRoute\Dispatcher::FOUND:
        $handler = $routeInfo[1];
        $vars = $routeInfo[2];
        echo $handler($vars);
        break;
}

下記のコマンドを実行し、フロントコントローラーを作成してください。

touch public/index.php

index.phpを下記のように編集してください。

<?php

require_once __DIR__ . '/../bootstrap.php';

オートロード設定

 appフォルダ配下のクラスファイルをオートロードできるようにします。下記のコマンドを実行してappフォルダを作成してください。

mkdir app

composer.jsonを下記のように編集してください。

{
    "require": {
        "nikic/fast-route": "^1.3"
    },
    "autoload": {
        "psr-4": {
            "App\\": "app"
        }
    }
}

.envの作成

 データベースの接続情報等の秘匿情報をプログラムの中に直接記述することはセキュリティ上禁止事項とされています。一般的にはサーバーの環境変数を用いて取得します。開発中に擬似的に環境変数を再現できる.envという特殊なファイルを作成します。下記のコマンドを実行し.env用のライブラリをインストールしてください。

composer require vlucas/phpdotenv

 インストールできたら、下記のコマンドを実行し.envファイルを作成してください。

touch .env

 .envファイルを下記のように編集してください。データベース接続の設定情報はMAMPのものになります。
!!!注意!!!
本番環境ではrootユーザーの使用、推測可能なパスワードの使用を絶対に行わないでください。

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=8889
DB_DATABASE=php_crud
DB_USERNAME=root
DB_PASSWORD=root

bootstrap.phpを下記のように編集してください。

<?php

use App\Controllers\UsersController;

session_start();

require_once __DIR__ . '/vendor/autoload.php';

// 下記の2行を追加
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->safeLoad();

$dispatcher = FastRoute\simpleDispatcher(function(FastRoute\RouteCollector $r) {
    // ここにルーティング定義を追加していきます
});

(省略)

データベースの接続処理

 下記のコマンドを実行しappフォルダの直下に「Utilities」フォルダを作成してください。

mkdir app/Utilities

下記のコマンドを実行しDatabaseConnector.phpを作成してください。

touch app/Utilities/DatabaseConnector.php

DatabaseConnector.phpを下記のように編集してください。

<?php

namespace App\Utilities;

use PDO;

class DatabaseConnector
{
    /**
     * コンストラクタの呼び出しを禁止
     */
    private function __construct() {}

    /**
     * シングルトンパターンで実装
     *
     * @return PDO
     */
    public static function connect(): PDO
    {
        static $dbh;

        if (empty($dbh)) {
            $dbh = new PDO(
                sprintf(
                    '%s:host=%s;port=%s;dbname=%s;charset=utf8',
                    $_ENV['DB_CONNECTION'],
                    $_ENV['DB_HOST'],
                    $_ENV['DB_PORT'],
                    $_ENV['DB_DATABASE']
                ),
                $_ENV['DB_USERNAME'],
                $_ENV['DB_PASSWORD']
            );
            $dbh->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
            $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        }

        return $dbh;
    }
}

テンプレートシステム

 簡単なテンプレートシステムを自作します。下記のコマンドを実行しView.phpを作成してください。

touch app/Utilities/View.php

View.phpを下記のように編集してください。

<?php

namespace App\Utilities;

class View
{
    /**
     * テンプレートを表示する
     *
     * @param string $viewPath
     * @param array $params
     * @param array $layoutParams
     * @return void
     */
    public function render(string $viewPath, array $params = [], array $layoutParams = []): void
    {
        $content = $this->load($viewPath, $params);
        $html = $this->load('layouts/app.php', array_merge(compact('content'), $layoutParams));
        echo $html;
    }

    /**
     * コンテンツを読み込む
     *
     * @param string $viewPath
     * @param array $params
     * @return string
     */
    public function load(string $viewPath, array $params = []): string
    {
        foreach ($params as $key => $value) {
            $$key = $value;
        }
        require(__DIR__ . '/../../resources/views/' . $viewPath);
        return $content;
    }

    /**
     * サニタイズ関数
     *
     * @param string $var
     * @return string
     */
    public function h(string $var): string
    {
        return htmlspecialchars($var, ENT_QUOTES, 'UTF-8');
    }
}

 各画面の共通のHTMLをレイアウトファイルにまとめます。下記のコマンドを実行し、各フォルダを作成してください。

mkdir resources
mkdir resources/views
mkdir resources/views/layouts

 下記のコマンドを実行しresources/views/layoutsフォルダにapp.phpを作成してください。

touch resources/views/layouts/app.php

 app.phpを下記のように編集してください。画面のデザインにBootstrap5を使用しますのでCDNから読み込んでいます。

<?php ob_start(); ?>

<!doctype html>
<html lang="ja">

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title><?= $title ?? 'ユーザー管理'; ?></title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
</head>

<body>
    <div class="container">
        <div class="row">
            <div class="col">
                <?= $content; ?>
            </div>
        </div>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script>
</body>

</html>

<?php $content = ob_get_clean(); ?>

ユーザー登録フォームの作成

 それでは準備が整いましたのでCRUD処理を作成していきます。まずは登録処理を作成します。ユーザーの入力値をサーバーにPOSTするフォームから作成していきます。

UsersControllerの作成

 UsersControllerを作成します。下記のコマンドを実行しコントローラーを格納するフォルダを作成してください。

mkdir app/Controllers

下記のコマンドを実行しUsersController.phpを作成してください。

touch app/Controllers/UsersController.php

 UsersController.phpを下記のように編集してください。ユーザー登録フォーム用のテンプレートを呼び出しています。テンプレートに渡す引数はありませんので第2引数には空の配列を渡しています。第3引数はレイアウトの$title変数に渡す文字列を設定しています。

<?php

namespace App\Controllers;

use App\Utilities\View;

class UsersController
{
    /**
     * 登録フォームの表示
     *
     * @return void
     */
    public function create(): void
    {
        (new View)->render('users/create.php', [], ['title' => '新規登録 | ユーザー管理']);
    }
}

ルーティングの追加

 コントローラーにcreateメソッドを定義したのでルーティングを追加しましょう。bootstrap.phpを下記のように編集してください。

(省略)

$dispatcher = FastRoute\simpleDispatcher(function(FastRoute\RouteCollector $r) {
    // ここにルーティング定義を追加していきます
        $r->addRoute('GET', '/users/create', function () {
        return (new UsersController)->create();
    });
});

(省略)

登録フォームの作成

 先ほど作成したテンプレートシステムを使い、ユーザー登録フォームを作りましょう。
 下記のコマンドを実行し、ユーザー管理用のビューファイルを格納するフォルダを作成してください。

mkdir resources/views/users

下記のコマンドを実行しcreate.phpを作成してください。

touch resources/views/users/create.php

create.phpを下記のように編集してください。

<?php ob_start(); ?>

<form action="/users" method="POST" class="mt-5">
    <div class="mb-3">
        <label for="name" class="form-label">名前</label>
        <input type="text" id="name" class="form-control" name="name" maxlength="50">
    </div>
    <div class="mb-3">
        <label for="email" class="form-label">メールアドレス</label>
        <input type="text" id="name" class="form-control" name="email" maxlength="100">
    </div>
    <button type="submit" class="btn btn-primary">登録</button>
</form>

<?php $content = ob_get_clean(); ?>

編集できたら「http://localhost:8888/users/create」にアクセスしてみてください。下記のように表示されれば成功です。

ユーザー登録フォーム

ユーザー登録処理の作成

Userモデルの作成

 ユーザー関連の処理の中身を受け持つUserモデルを作成します。下記のコマンドを実行し、モデルクラスを格納するフォルダを作成してください。

mkdir app/Models

下記のコマンドを実行しUserクラスを作成してください。

touch app/Models/User.php

Userクラスを下記のように編集してください。

<?php

namespace App\Models;

use App\Utilities\DatabaseConnector;
use PDO;

class User
{
    /**
     * @var array
     */
    private $hash;

    /**
     * セッター
     *
     * @param string $key
     * @param mixed $value
     * @return void
     */
    public function __set(string $key, mixed $value): void
    {
        $this->hash[$key] = $value;
    }

    /**
     * ゲッター
     *
     * @param string $key
     * @return mixed
     */
    public function __get(string $key): mixed
    {
        if (!array_key_exists($key, $this->hash)) {
            $this->hash[$key] = '';
        }

        return $this->hash[$key];
    }

    /**
     * プライマリキーで取得
     *
     * @param integer $id
     * @return self
     */
    public static function find(int $id): self
    {
        $dbh = DatabaseConnector::connect();
        $sql = 'SELECT * FROM users WHERE id = :id';
        $stmt = $dbh->prepare($sql);
        $stmt->bindValue(':id', $id, PDO::PARAM_INT);
        $stmt->execute();
        $stmt->setFetchMode(PDO::FETCH_CLASS, self::class);

        return $stmt->fetch();
    }

    /**
     * 登録処理
     *
     * @param array $params
     * @return self
     */
    public static function create(array $params): self
    {
        $dbh = DatabaseConnector::connect();
        $sql = 'INSERT INTO users (name, email, created_at, updated_at) VALUES(:name, :email, now(), now())';
        $stmt = $dbh->prepare($sql);
        $stmt->bindValue(':name', $params['name'], PDO::PARAM_STR);
        $stmt->bindValue(':email', $params['email'], PDO::PARAM_STR);
        $stmt->execute();

        return self::find($dbh->lastInsertId());
    }
}

UsersControllerにstoreメソッドを追加

 UsersController.phpを下記のように編集し、storeメソッドを追加してください。
 フォームからPOSTされた値は$_POSTという特別な変数の中に入っています。foreachで1件ずつ取り出しながら前後の空白を除去して$params変数に詰め直しています。
 $params変数を先ほど作成したUserクラスのcreate()メソッドに渡しています。

<?php

namespace App\Controllers;

use App\Models\User;
use App\Utilities\View;

class UsersController
{
    /**
     * 登録フォームの表示
     *
     * @return void
     */
    public function create(): void
    {
        (new View)->render('users/create.php');
    }

    /**
     * 登録処理
     *
     * @return void
     */
    public function store(): void
    {
        $params = [];
        foreach ($_POST as $key => $value) {
            $params[$key] = trim($value);
        }

        User::create($params);
    }

ルーティングの追加

 storeメソッドを追加しましたのでルーティングも追加しましょう。bootstrap.phpファイルを下記のように編集してください。

<?php

(省略)

$dispatcher = FastRoute\simpleDispatcher(function(FastRoute\RouteCollector $r) {
    // ここにルーティング定義を追加していきます
    $r->addRoute('GET', '/users/create', function () {
        return (new UsersController)->create();
    });

    $r->addRoute('POST', '/users', function () {
        return (new UsersController)->store();
    });
});

(省略)

 ルーティングの追加が完了したら、実際にフォームからPOSTしてみてください。登録処理をして処理が終わっているので画面が真っ白になります。
 登録できたかどうかphpMyAdminで確認してみましょう。phpMyAdminを開いてください。上部のメニューから「表示」をクリックしてください。下記のように1件データが登録されていれば成功です。

phpMyAdmin

ユーザーの一覧画面の作成

 画面が真っ白になってしまっては困りますので、登録処理完了後はユーザーの一覧画面にリダイレクトするように実装を修正しましょう。まずユーザーリストの取得処理を作成し、ユーザーの一覧画面を作り、最後にリダイレクト処理を追加します。

ユーザーリストの取得処理

 Userクラスを下記のように編集してください。ORDER BY句で登録の新しいものから順に取得しています。

<?php

namespace App\Models;

use App\Utilities\DatabaseConnector;
use PDO;

class User
{
    (省略)

    /**
     * 一覧取得
     *
     * @return array
     */
    public static function all(): array
    {
        $dbh = DatabaseConnector::connect();
        $sql = 'SELECT * FROM users ORDER BY id DESC';
        $stmt = $dbh->prepare($sql);
        $stmt->execute();
        $stmt->setFetchMode(PDO::FETCH_CLASS, self::class);

        return $stmt->fetchAll();
    }

    /**
     * プライマリキーで取得
     *
     * @param integer $id
     * @return self
     */
    public static function find(int $id): self
    {
        $dbh = DatabaseConnector::connect();
        $sql = 'SELECT * FROM users WHERE id = :id';
        $stmt = $dbh->prepare($sql);
        $stmt->bindValue(':id', $id, PDO::PARAM_INT);
        $stmt->execute();
        $stmt->setFetchMode(PDO::FETCH_CLASS, self::class);

        return $stmt->fetch();
    }

(省略)

UserControllerにindexメソッドを追加

 UsersController.phpを下記のように編集し、indexメソッドを追加してください。第2引数でユーザーの一覧が格納された$users変数をテンプレートに渡しています。

<?php

namespace App\Controllers;

use App\Models\User;
use App\Utilities\View;

class UsersController
{
    /**
     * 一覧表示
     *
     * @return void
     */
    public function index(): void
    {
        $users = User::all();
        (new View)->render('users/index.php', compact('users'), ['title' => '一覧表示 | ユーザー管理']);
    }

    /**
     * 登録フォームの表示
     *
     * @return void
     */
    public function create(): void
    {
        (new View)->render('users/create.php', [], ['title' => '新規登録 | ユーザー管理']);
    }

(省略)

ルーティングの追加

 indexメソッドを追加しましたのでルーティングも追加しましょう。bootstrap.phpファイルを下記のように編集してください。

<?php

(省略)

$dispatcher = FastRoute\simpleDispatcher(function(FastRoute\RouteCollector $r) {
    // ここにルーティング定義を追加していきます
    $r->addRoute('GET', '/users', function () {
        return (new UsersController)->index();
    });

    $r->addRoute('GET', '/users/create', function () {
        return (new UsersController)->create();
    });

    $r->addRoute('POST', '/users', function () {
        return (new UsersController)->store();
    });
});

(省略)

一覧画面の作成

下記のコマンドを実行してください。

touch resources/views/users/index.php

 index.phpを下記のように編集してください。$users配列をforeachで1件ずつ取り出しテーブルに表示しています。ユーザーから入力された値を表示する時は必ずサニタイズ関数を通して表示してください!クロスサイトスクリプティング(XSS)攻撃へのセキュリティ対策です。ここでは$this->h()とViewクラスで定義したhメソッドを呼び出しています。

<?php ob_start(); ?>

<table class="table table-bordered mt-5">
    <thead>
        <tr>
            <th>ID</th>
            <th>名前</th>
            <th>メールアドレス</th>
        </tr>
    </thead>
    <tbody>
        <?php foreach ($users as $user) : ?>
            <tr>
                <td><?= $user->id; ?></td>
                <td><?= $this->h($user->name); ?></td>
                <td><?= $this->h($user->email); ?></td>
            </tr>
        <?php endforeach; ?>
    </tbody>
</table>

<?php $content = ob_get_clean(); ?>

 編集が完了したら、登録フォーム(/users/create)からもう何件か登録して一覧画面を表示してみましょう。http://localhost:8888/usersにアクセスしてください。下記のような画面になれば成功です。

ユーザーリスト画面

リダイレクト処理の追加

 リダイレクト処理を受け持つ専用のクラスを作成します。下記のコマンドを実行しRedirectorクラスを作成してください。

touch app/Utilities/Redirector.php

Redirectorクラスを下記のように編集してください。

<?php

namespace App\Utilities;

class Redirector
{
    /**
     * リダイレクト処理
     *
     * @param string $path
     * @return void
     */
    public function to(string $path): void
    {
        header ('Location: ' . $this->getUrl($path));
        exit;
    }

    /**
     * URL組み立て
     *
     * @param string $path
     * @return string
     */
    private function getUrl(string $path): string
    {
        $scheme = empty($_SERVER['HTTPS']) ? 'http' : 'https';

        return sprintf('%s://%s/%s', $scheme, $_SERVER['HTTP_HOST'], $path);
    }
}

 UsersControllerのstoreメソッドを下記のように修正してください。Redirectorクラスのto()メソッドにホスト名より下のパスを渡しています。useするのを忘れないようにしてください。

<?php

namespace App\Controllers;

use App\Models\User;
use App\Utilities\Redirector;
use App\Utilities\View;

class UsersController
{
    (省略)

    /**
     * 登録処理
     *
     * @return void
     */
    public function store(): void
    {
        $params = [];
        foreach ($_POST as $key => $value) {
            $params[$key] = trim($value);
        }

        User::create($params);

        (new Redirector)->to('users');
    }
}

 修正が完了したら追加でユーザーを登録してみて(/users/create)リダイレクトされるか確認してください。

CSRF対策

 ここまでで一通り登録処理が完成しましたが、クロスサイトリクエストフォージェリ(CSRF)対策が抜けています。このままでは悪意のある外部サイトからのリクエストも受けつけてしまいます。これを防ぐためにライブラリを導入して対策を行います。
 下記のコマンドを実行しEasyCSRFをインストールしてください。

composer require gilbitron/easycsrf

 UsersControllerのcreateメソッドを下記のように修正してください。フォームに埋め込むためのワンタイムトークンを作成しています。作成されたトークンはrender()メソッドの第2引数でテンプレートに渡されます。EasyCSRFのクラスのuseを忘れないようにしてください。

<?php

namespace App\Controllers;

use App\Models\User;
use App\Utilities\Redirector;
use App\Utilities\View;
use EasyCSRF\EasyCSRF;
use EasyCSRF\NativeSessionProvider;

class UsersController
{
    (省略)

    /**
     * 登録フォームの表示
     *
     * @return void
     */
    public function create(): void
    {
        $sessionProvider = new NativeSessionProvider();
        $easyCSRF = new EasyCSRF($sessionProvider);
        $csrfToken = $easyCSRF->generate($_ENV['CSRF_SALT']);

        (new View)->render('users/create.php', compact('csrfToken'), ['title' => '新規登録 | ユーザー管理']);
    }

    (省略)
}

 generateメソッドに渡されるソルト文字列を.envに設定します。.envファイルに下記を追加してください。文字列は何でもかまいません。

CSRF_SALT=php_crud

app/resources/views/users/create.phpを下記のように修正してください。buttonタグの直前にhiddenでトークンを埋め込んでいます。

<?php ob_start(); ?>

<form action="/users" method="POST" class="mt-5">
    <div class="mb-3">
        <label for="name" class="form-label">名前</label>
        <input type="text" id="name" class="form-control" name="name" maxlength="50">
    </div>
    <div class="mb-3">
        <label for="email" class="form-label">メールアドレス</label>
        <input type="text" id="name" class="form-control" name="email" maxlength="100">
    </div>
    <input type="hidden" name="csrf_token" value="<?= $csrfToken; ?>">
    <button type="submit" class="btn btn-primary">登録</button>
</form>

<?php $content = ob_get_clean(); ?>

 下記のようにHTMLが出力されていればOKです。開発者ツールを開くには画面上で右クリックし「検証」を選択してください。右サイドに開発者ツールが展開しますので、上部のメニューより要素を選択し、意図した箇所にhiddenが出力され値が埋め込まれていることを確認してください。

CSRFトークン

 UsersControllerのstoreアクションを下記のように修正してください。トークンをチェックし、不正な場合は例外を発生させます。EasyCSRF\Exceptions\InvalidCsrfTokenExceptionのuseを忘れないようにしてください。

<?php

namespace App\Controllers;

use App\Models\User;
use App\Utilities\Redirector;
use App\Utilities\View;
use EasyCSRF\EasyCSRF;
use EasyCSRF\Exceptions\InvalidCsrfTokenException;
use EasyCSRF\NativeSessionProvider;

class UsersController
{
    (省略)

    /**
     * 登録処理
     *
     * @return void
     */
    public function store(): void
    {
        try {
            $sessionProvider = new NativeSessionProvider();
            $easyCSRF = new EasyCSRF($sessionProvider);
            $easyCSRF->check($_ENV['CSRF_SALT'], $_POST['csrf_token']);

            $params = [];
            foreach ($_POST as $key => $value) {
                $params[$key] = trim($value);
            }

            User::create($params);

            (new Redirector)->to('users');
        } catch(InvalidCsrfTokenException $e) {
            echo $e->getMessage();
        }
    }
}

 修正できたら実際にユーザー登録をしてみて正常に登録処理が通ることを確認してください。
 また、下記のようにhiddenのvalueに設定したトークンに余分な文字をくっつけて例外が発生することを確認してください。

<input type="hidden" name="csrf_token" value="<?= $csrfToken; ?>a">
不正なトークンを検出

確認が終わったら余分な文字は削除して元に戻しておいてください。

<input type="hidden" name="csrf_token" value="<?= $csrfToken; ?>">

 解説は以上です。次回は更新処理、削除処理、詳細画面を実装しCRUD操作を完成させます。おつかれさまでした。

PHP/Laravelのシステム開発は株式会社パパグラムへぜひご相談ください。

いいなと思ったら応援しよう!