PROGRAMMING

【シンプル解説】フレームワークなしPHP8で掲示板を作成

こんにちは。フリーランスエンジニアのもんしょー(@sima199407)です。

そんな声があるかと思います。

今回は、素のPHPを使って掲示板を作っていこうと思います!

※Laravelを使った掲示板の作り方はこちら。

話の流れ

開発準備

  • 開発環境
  • ディレクトリ構造
  • SQLテーブル構造
  • PHPの紹介
  • Nginxの紹介
  • MySQLの紹介
  • docker-composeの紹介
  • コンテナ立ち上げ作業

掲示板について

  • ディレクトリごとの解説
  • トラブルシューティング

開発環境

ローカル環境

名前 バージョン
OS macOS Monterey 12.6
docker
docker-compose

これから作る環境(docker)

名前 バージョン コンテナ名
PHP 8.1 web
nginx 1.20 nginx
MySQL 8 mysql

ディレクトリ構造

.
├── docker-compose.yml
├── docker-config
│   ├── mysql
│   │   ├── data
│   │   ├── import_data
│   │   └── my.cnf
│   ├── nginx
│   │   ├── Dockerfile
│   │   └── default.conf
│   └── php
│       ├── Dockerfile
│       └── php.ini
└── src
    ├── common
    │   ├── database.php
    │   ├── head.php
    │   ├── log.php
    │   └── session.php
    ├── controller
    │   ├── auth_edit_controller.php
    │   ├── auth_login_controller.php
    │   ├── auth_logout_controller.php
    │   ├── auth_register_controller.php
    │   ├── post_create_controller.php
    │   └── post_delete_controller.php
    ├── images
    │   └── favicon.ico
    ├── index.php
    ├── layout
    │   ├── footer.php
    │   └── header.php
    ├── log
    │   └── debug.log
    └── views
        ├── auth
        ├── mypage
        └── post

今回は、PHPのみで開発する訳なんですが、MVCっぽくコントローラーとviewで分かれるようにしてます。

SQLテーブル構造

users

カラム データ型 拡張
id int auto_increment
name var
email VARCHAR
password VARCHAR
created_at TIMESTAMP
updated_at TIMESTAMP
user_type int 0:normal,99:admin

posts

カラム データ型 拡張
id int auto_increment
user_id int
body var
created_at TIMESTAMP
updated_at TIMESTAMP

PHPの紹介

docker-config/php/Dockerfile

FROM php:8.1-fpm
WORKDIR /var/www
ADD . /var/www

# permission
RUN chown -R www-data:www-data /var/www

# install packages
RUN apt-get update \
  && apt-get install -y \
  gcc \
  make \
  git \
  unzip \
  vim \
  curl \
  gnupg \
  openssl \
  && docker-php-ext-install pdo_mysql mysqli \
  && docker-php-ext-configure gd --with-freetype --with-jpeg \
  && docker-php-ext-install -j$(nproc) gd

# Add php.ini
COPY php.ini /usr/local/etc/php/

特に特別なものは入れなくてもいいかなと。

docker-config/php/php.ini

[Date]
date.timezone = "Asia/Tokyo"
[memory]
memory_limit = 1024M
[data]
post_max_size = 2048M
upload_max_filesize = 2048M

Nginxの紹介

docker-config/nginx/Dockerfile

FROM alpine:3.6

# nginxのインストール
RUN apk update && \
    apk add --no-cache nginx
RUN mkdir -p /run/nginx

# TimeZoneをAsia/Tokyoに設定する
RUN apk --no-cache add tzdata \
    && cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime \
    && apk del tzdata

# フォアグラウンドでnginx実行
CMD nginx -g "daemon off;"

docker-config/nginx/default.conf

server {
    listen 80;
    index index.php index.html;
    root /var/www/src;

    access_log /var/log/nginx/ssl-access.log;
    error_log  /var/log/nginx/ssl-error.log;

    location / {
      root /var/www/src;
      try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        root /var/www/src;
        try_files $uri =404;
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass web:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
        
    }
}

MySQLの紹介

docker-config/mysql/my.cnf

# MySQLサーバーへの設定
[mysqld]
# 文字コード/照合順序の設定
character-set-server = utf8mb4
collation-server = utf8mb4_bin
lower_case_table_names = 2

# タイムゾーンの設定
default-time-zone = SYSTEM
log_timestamps = SYSTEM

# デフォルト認証プラグインの設定
default-authentication-plugin = mysql_native_password

# エラーログの設定
# log-error = /var/log/mysql/mysql-error.log

# スロークエリログの設定
slow_query_log = 1
slow_query_log_file = /var/log/mysql/mysql-slow.log
long_query_time = 5.0
log_queries_not_using_indexes = 0

# 実行ログの設定
general_log = 1
general_log_file = /var/log/mysql/mysql-query.log

# mysqlオプションの設定
[mysql]
# 文字コードの設定
default-character-set = utf8mb4

# mysqlクライアントツールの設定
[client]
# 文字コードの設定
default-character-set = utf8mb4

docker-config/mysql/import_data/defaullt.sql

CREATE TABLE `users` (
	`id` INT NOT NULL AUTO_INCREMENT,
	`name` VARCHAR(255) NOT NULL,
	`email` VARCHAR(255) NOT NULL UNIQUE,
	`password` VARCHAR(64) NOT NULL,
	`user_type` INT(4) DEFAULT '0',
	`created_at` datetime  default current_timestamp,
	`updated_at` timestamp default current_timestamp on update current_timestamp,
	PRIMARY KEY (`id`)
);

CREATE TABLE `posts` (
	`id` INT NOT NULL AUTO_INCREMENT,
	`title` VARCHAR(255) DEFAULT 'no title',
	`body` VARCHAR(1024) NOT NULL,
	`created_at` datetime  default current_timestamp,
	`updated_at` timestamp default current_timestamp on update current_timestamp,
	PRIMARY KEY (`id`)
);

postsのtitleの部分はデフォルト値を設定します。

usersのuser_typeはadminや有料、無料ユーザーなどの区別で使えるかなと。(今回は使いません)

docker-composeの紹介

docker-compose.yml

version: '3' 

services:
    
  web: 
    build: ./docker-config/php
    volumes:
      - ./src:/var/www/src
    working_dir: /var/www/src
    depends_on:
      - mysql

  nginx:
    image: nginx
    build: ./docker-config/nginx
    ports:
      - "8888:80"
    volumes:
      - ./src:/var/www/src
      - ./docker-config/nginx/default.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      - web

  mysql:
    image: mysql:8.0
    ports:
      - 3386:3306
    environment:
      MYSQL_DATABASE: yt_dev
      MYSQL_ROOT_PASSWORD: root
      MYSQL_USER: yt_user
      MYSQL_PASSWORD: yt_user
      TZ: 'Asia/Tokyo'
      
    volumes:
      - ./docker-config/mysql/data:/var/lib/mysql
      # - ./docker-config/mysql/sql:/docker-entrypoint-initdb.d
      - ./docker-config/mysql/my.cnf:/etc/mysql/conf.d/my.cnf

    depends_on:
      - mysql-volume

  mysql-volume:
    image: busybox
    volumes:
      - ./docker-config/mysql:/var/lib/mysql

nginxのportを8888にしておりますが、通常の80番にするなら80:80 に、8080とかにするなら 8080:80 にすればOKです。

以前にも同じような内容を書いてますのでより深く知りたい方はこちらもどうぞ!

コンテナ立ち上げ作業

コマンドライン

cd youtube_keiziban-php8

docker compose up -d

オプション -d はデタッチモードと言って、バックグラウンドでコンテナが動いてくれます。試しに-dなしで立ち上げてどうなるか確認するも良いです。

2023/06から compose V1はサポートが切れるみたいで docker-composedocker compose と書くのが良いみたいですね。参考リンク

立ち上がったことを確認する。

docker ps

ディレクトリごとの解説

ここから実際のPHPコードを見ていきます。

ディレクトリごとに確認ましょう。

ルートディレクトリ

src/index.php

<?php
session_start();
$title = '新規投稿-PHP8掲示板';
$description = '投稿機能';
$path = './';

// Path List //
$header_path = $path . 'layout/header.php';
$footer_path = $path . 'layout/footer.php';
$head_path = $path . 'common/head.php';

require_once($path . 'common/database.php');
$items = postList();

?>

<!DOCTYPE html>
<html lang="ja">
<?php
//head //
include $head_path;
?>
</head>
</head>

<style lang="scss">
    .wrap-content {
        margin-bottom: 60px;
    }

    img {
        margin: auto;
        width: 80%;
    }

    .item {
        padding: 0 5px;
        text-decoration: none;
    }

    .item-card {
        transition: 0.5s;
        cursor: pointer;
    }

    .item-card-title {
        font-size: 15px;
        transition: 1s;
        cursor: pointer;
    }

    .item-card-title i {
        font-size: 15px;
        transition: 1s;
        cursor: pointer;
        color: #ffa710
    }

    .card:hover {
        /* transform: scale(1.05); */
        box-shadow: 10px 10px 15px rgba(0, 0, 0, 0.3);
    }

    .card-text {
        height: auto;
        padding: 5px 0;
    }

    .card::before,
    .card::after {
        position: absolute;
        top: 0;
        right: 0;
        bottom: 0;
        left: 0;
        transform: scale3d(0, 0, 1);
        transition: transform .3s ease-out 0s;
        background: rgba(255, 255, 255, 0.1);
        content: '';
        pointer-events: none;
    }

    .card::before {
        transform-origin: left top;
    }

    .card::after {
        transform-origin: right bottom;
    }

    .until-2lines {
        -webkit-line-clamp: 2;
        display: -webkit-box;
        -webkit-box-orient: vertical;
        overflow: hidden;
    }
</style>

<body>

    <!-- ヘッダー -->
    <?php include_once($header_path); ?>
    <div class="container wrap-content">
        <div class="row justify-content-center">
            <!-- メイン -->
            <div class="col-12 col-md-6 p-2">
                <!-- lists -->
                <?php foreach ($items as $item) { ?>
                    <div class="col-12 item mb-3">
                        <a href="/views/post/detail.php?id=<?php echo $item['id'] ?>" class="text-decoration-none link-dark">
                            <div class="card item-card card-block p-3">
                                <h4 class="card-title text-right"><i class="material-icons"><?php echo $item['title'] ?></i></h4>
                                <div class="card-text until-2lines"><?php echo $item['body'] ?></div>
                            </div>
                        </a>
                    </div>
                <?php } ?>
            </div>
        </div>
    </div>
    <!-- フッター -->
    <?php include_once $footer_path; ?>
</body>

</html>

ヘッダーとフッターは共通なのでincludeしてます。

common

src/common/database.php

<?php
require_once('log.php');
function db_connect()
{
    $dsn = 'mysql:dbname=yt_dev;host=mysql;port=3306';
    $user = 'root';
    $password = 'root';

    try {
        $dbh = new PDO( $dsn, $user, $password);
        return $dbh;
    } catch (PDOException $e) {
        print('Error:' . $e->getMessage());
        die();
    }   
}

function userInsert($request)
{
    if( empty($request)){
        print('wrong access!');
        return false;
    }
    $pdo = db_connect();

    try{
        $stmt = $pdo->prepare("INSERT INTO users (
        name, email, password
    ) VALUES (
        :name, :email, :password
    )");

        $_name = !empty($request['name']) ? $request['name'] : '';
        $_email = !empty($request['email']) ? $request['email'] : '';
        $_password = !empty($request['password']) ? password_hash($request['password'], PASSWORD_DEFAULT) : '';

        $stmt->bindParam(':name', $_name, PDO::PARAM_STR);
        $stmt->bindParam(':email', $_email, PDO::PARAM_STR);
        $stmt->bindParam(':password', $_password, PDO::PARAM_STR);

        $res = $stmt->execute();
        logToFile($res, __FILE__, __LINE__);
        if ($res == 1) {
            $response = userGetInfo($request);
        }

        $pdo = null;

        return $response;

    }catch(Exception $e){
        return $e;
    }

}

function userUpdate($request)
{
    $user = userGetInfoById($_SESSION['id']);
    
    if (empty($request)) {
        print('wrong access!');
        return false;
    }
    if (empty($request['uid'])) {
        print('invalid ID!');
        return false;
    }

    $pdo = db_connect();

    try {
        $stmt = $pdo->prepare("UPDATE users SET name=:name, email=:email, password=:password
     WHERE id = :id");
        $_name = !empty($request['name']) ? $request['name'] : $user['name'];
        $_email = !empty($request['email']) ? $request['email'] : $user['email'];
        $_password = !empty($request['password']) ? password_hash($request['password'], PASSWORD_DEFAULT) : $user['password'];

        $stmt->bindParam(':id', $user['id'], PDO::PARAM_INT);
        $stmt->bindParam(':name', $_name, PDO::PARAM_STR);
        $stmt->bindParam(':email', $_email, PDO::PARAM_STR);
        $stmt->bindParam(':password', $_password, PDO::PARAM_STR);


        $res = $stmt->execute();
        if ($res) {
            $response = userGetInfoById($_SESSION['id']);
        }
        $pdo = null;
        return $response;

    }catch(Exception $e){
        logToFile($e, __FILE__, __LINE__);
        return $e;
    }
}
function userGetInfo($request)
{
    if (empty($request)) {
        print('wrong access!');
        return false;
    } 
    try{
        $pdo = db_connect();
        $stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');

        $stmt->bindParam(':email', $request['email'], PDO::PARAM_STR);
        $res = $stmt->execute();
        $response = $stmt->fetch(PDO::FETCH_ASSOC);

        if (!password_verify($request['password'], $response['password'])) {
            return false;
        }
        $pdo = null;
        return $response;
    }catch(Exception $e){
        logToFile($e, __FILE__, __LINE__);
        return $e;
    }
}

function userGetInfoById($id)
{
    $pdo = db_connect();
    $stmt = $pdo->prepare("SELECT * FROM users WHERE id = :id");

    $stmt->bindParam(':id', $id, PDO::PARAM_STR);
    $res = $stmt->execute();

    if ($res) {
        $response = $stmt->fetch();
    }
    $pdo = null;
    return $response;
}

function postList()
{
    $pdo = db_connect();
    $stmt = $pdo->prepare("SELECT * FROM posts ORDER BY id desc");
    $res = $stmt->execute();

    if ($res) {
        $response = $stmt->fetchAll();
    }
    $pdo = null;
    return $response;
}

function postDetail($id)
{
    $pdo = db_connect();
    $stmt = $pdo->prepare("SELECT * FROM posts WHERE id = :id");
    $stmt->bindParam(':id', $id, PDO::PARAM_STR);
    $res = $stmt->execute();

    if ($res) {
        $response = $stmt->fetch();
    }

    $pdo = null;
    return $response;
}

function postInsert($request)
{
    if (empty($request)) {
        print('wrong access!');
        return false;
    }
    $pdo = db_connect();

    $stmt = $pdo->prepare("INSERT INTO posts (
        user_id,title, body
    ) VALUES (
        :user_id,:title, :body
    )");
    $stmt->bindParam(':user_id', $request['user_id'], PDO::PARAM_INT);
    $stmt->bindParam(':title', $request['title'], PDO::PARAM_STR);
    $stmt->bindParam(':body', $request['body'], PDO::PARAM_STR);


    $res = $stmt->execute();
    if ($res) {
        $response = $stmt->fetch();
    }
    $pdo = null;

    return $response;
}

function postDelete($request)
{
    if (empty($request)) {
        print('wrong access!');
        return false;
    }
    $pdo = db_connect();

    try {
        $stmt = $pdo->prepare('DELETE FROM posts WHERE id = :id');

        $stmt->bindParam(':id', $request['pid'], PDO::PARAM_STR);
        $res = $stmt->execute();
        $response = $stmt->fetch(PDO::FETCH_ASSOC);

        $pdo = null;
        return $response;
    } catch (Exception $e) {
        logToFile($e, __FILE__, __LINE__);
        return $e;
    }
}
?>

今回はデータベースの接続はここを起点にやっていきます。

src/common/session.php

<?php
$path = '../';

require_once('log.php');

function setSessionUser($user)
{   
    if (isset($user["email"]) && isset($user["name"])) {
        if(isset($_SESSION)){
            $_SESSION = array();
        }
        
        $_SESSION["id"] = $user["id"];
        $_SESSION["email"] = $user["email"];
        $_SESSION["name"] = $user["name"];
        $_SESSION["is_login"] = 1;
    }
    logToFile($_SESSION, __FILE__, __LINE__);
}

function deleteAllSession()
{
    session_start();
    $_SESSION = array();
    if (ini_get("session.use_cookies")) {
        setcookie(session_name(), '', time() - 42000, '/');
    }
    session_destroy();
    logToFile($_SESSION, __FILE__, __LINE__);
}
?>

セッションを使って、ログインの保持をしていきます。

src/common/head.php

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.min.js" integrity="sha384-cuYeSxntonz0PPNlHhBs68uyIAVpIIOZZ5JqeqvYYIcEL727kskC66kF92t6Xl2V" crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"></script>
    <title><?php echo $title ? $title : 'もんしょーITラボ'; ?></title>
    <meta name="description" content="<?php echo $description ? $description : '説明欄'; ?>">
    <link rel="icon" href="<?php echo $_SERVER['SERVER_NAME'] ?>/images/favicon.ico">

src/common/log.php

<?php
function logToFile($var,$filePath = null,$lineNum = null)
{
    $res = date("Y-m-d H:i:s") .'-' . $filePath .':'. $lineNum ."\n". print_r($var, true);
    error_log($res, "3", "/var/www/src/log/debug.log");
}

?>

第一引数に表示したい値、第二引数にファイルパス(__FILE__でOK)、第三引数に現在のライン(__LINE__でOK)を入れてます。

多分書き方はもっといいのがあるかも。。

logToFile($hoge, __FILE__, __LINE__); という形でデバッグできるようにしてます。

controller

ここが処理

src/controller/auth_edit_controller.php

<?php
session_start();
$path = '../';
require_once($path . 'common/database.php');
require_once($path . 'common/session.php');
require_once($path . 'common/log.php');

//Auth Page
if (!isset($_SESSION['name'])) {
    // ログイン済みの場合、ホームページへリダイレクト
    header("Location:{$path}views/auth/registerOrLogin.php");
    exit;
}

// update
if (isset($_POST['name']) && isset($_POST['email']) && isset($_POST['password'])) {
    if (!empty($_POST['is_login'])) {
        $_SESSION['message'] = "エラーが発生しました";
        header("Location:{$path}views/auth/registerOrLogin.php");
    }
    $res = userUpdate($_POST);
    //set Session
    setSessionUser($res);

    header("Location:{$path}views/mypage/index.php");
    exit;
}
?>

src/controller/auth_login_controller.php

<?php
session_start();
$path = '../';
require_once($path . 'common/database.php');
require_once($path . 'common/session.php');
require_once($path . 'common/log.php');

//Auth Page
if (isset($_SESSION['name'])) {
    // ログイン済みの場合、ホームページへリダイレクト
    header("Location:{$path}views/mypage/index.php");
    exit;
}

// Login
if (isset($_POST['email']) && isset($_POST['password']) && isset($_POST['is_login'])) {
    logToFile($_POST, __FILE__, __LINE__);

    $res = userGetInfo($_POST);
    if(empty($res)){
        $_SESSION['message'] = "データがありません";
        header("Location:{$path}views/auth/registerOrLogin.php");
    }
    //set Session
    setSessionUser($res);
    logToFile($res, __FILE__, __LINE__);
    header("Location:{$path}views/mypage/index.php");
    exit;
}else{
    header("Location:{$path}views/auth/registerOrLogin.php");
}

?>

src/controller/auth_logout_controller.php

<?php
$path = '../';
require_once($path . 'common/session.php');
require_once($path . 'common/log.php');

//Auth Page
if (isset($_SESSION['is_login'])) {
    header("Location:{$path}.'index.php'");
}
//Logout
deleteAllSession();

header("Location:{$path}index.php", true, 301);
exit;

?>

src/controller/auth_register_controller.php

<?php
session_start();
$path = '../';
require_once($path . 'common/database.php');
require_once($path . 'common/session.php');
require_once($path . 'common/log.php');

//Auth Page
if (isset($_SESSION['name'])) {
    // ログイン済みの場合、ホームページへリダイレクト
    header("Location:{$path}views/mypage/index.php");
    exit;
}

// Register
if (isset($_POST['name']) && isset($_POST['email']) && isset($_POST['password'])) {
    if (!empty($_POST['is_login'])) {
        $_SESSION['message'] = "エラーが発生しました";
        header("Location:{$path}views/auth/registerOrLogin.php");
    }
    $res = userInsert($_POST);
    //set Session
    setSessionUser($res);

    header("Location:{$path}views/mypage/index.php");
    exit;
}
?>

src/controller/post_create_controller.php

<?php
session_start();
$path = '../';
require_once($path . 'common/database.php');
require_once($path . 'common/session.php');

//Auth Page
if (!isset($_SESSION['name'])) {
    // ログイン済みの場合、ホームページへリダイレクト
    header("Location:{$path}views/auth/registerOrLogin.php");
    exit;
}

// Create
if (isset($_POST['title']) && isset($_POST['body'])) {
    postInsert($_POST);

    header("Location:{$path}index.php");
    exit;
}

src/controller/post_delete_controller.php

<?php
session_start();
$path = '../';
require_once($path . 'common/database.php');
require_once($path . 'common/session.php');
require_once($path . 'common/log.php');

//Auth Page
if (!isset($_SESSION['name'])) {
    // ログイン済みの場合、ホームページへリダイレクト
    header("Location:{$path}views/auth/registerOrLogin.php");
    exit;
}

// Delete
if ($_SESSION['id'] == $_POST['uid']) {
    $response=postDelete($_POST);
    logToFile($response, __FILE__, __LINE__);
    header("Location:{$path}index.php");
    exit;
}else{
    $_SESSION['message'] = '削除できませんでした';
    header("Location:{$path}index.php",true,301);
    exit;
}

layout

src/layout/header.php

<?php
$res = [];
?>

<header>
    <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
        <!-- PC -->
        <div class="container-fluid d-none d-md-flex">
            <ul class="d-flex flex-row py-2">
                <li class="mx-2 list-unstyled"><a class="text-white text-decoration-none" href="<?php echo $path . 'index.php' ?>"><button type="button" class="btn btn-primary">TOP</button></a></li>
            </ul>

            <?php
            if (isset($_SESSION['is_login'])) {
            ?>
                <!-- Member -->
                <ul class="d-flex flex-row py-2">
                    <li class="mx-2 list-unstyled"><a class="text-white text-decoration-none" href="<?php echo $path . 'views/post/create.php' ?>"><button type="button" class="btn btn-success">投稿する</button></a></li>
                    <li class="mx-2 list-unstyled"><a class="text-white text-decoration-none" href="<?php echo $path . 'views/mypage/index.php' ?>"><button type="button" class="btn btn-danger">マイページ</button></a></li>
                    <li class="mx-2 list-unstyled"><input type="button" value="ログアウト" class="btn btn-dark" onclick="logout('<?php echo $path . 'controller/auth_logout_controller.php' ?>')">
                    </li>
                </ul>
            <?php
            } else {
            ?>
                <!-- Guest -->
                <ul class="d-flex flex-row py-2">
                    <li class="mx-2 list-unstyled"><a class="text-white text-decoration-none" href="<?php echo $path . 'views/auth/registerOrLogin.php' ?>"><button type="button" class="btn btn-danger">ログイン/新規登録</button></a></li>
                </ul>
            <?php
            }
            ?>

        </div>
        <!-- SP -->
        <div class="container-fluid d-flex d-md-none">
            <div class="dropdown">
                <button class="btn btn-primary dropdown-toggle" type="button" id="dropdownMenuButton1" data-bs-toggle="dropdown" aria-expanded="false">
                    MENU
                </button>
                <?php
                if (isset($_SESSION['is_login'])) {
                ?>
                    <ul class="dropdown-menu" aria-labelledby="dropdownMenuButton1">
                        <li><a class="dropdown-item" href="<?php echo $path . 'index.php' ?>">TOP</a></li>
                        <li><a class="dropdown-item" href="<?php echo $path . 'views/post/create.php' ?>">投稿する</a></li>
                        <li><a class="dropdown-item" href="<?php echo $path . 'views/mypage/index.php' ?>">マイページ</a></li>
                        <li><input type="button" value="ログアウト" class="btn btn-dark" onclick="logout('<?php echo $path . 'controller/auth_logout_controller.php' ?>')"></li>
                    </ul>
                <?php
                } else {
                ?>
                    <!-- Guest -->
                    <ul class="dropdown-menu" aria-labelledby="dropdownMenuButton1">
                        <li><a class="dropdown-item" href="<?php echo $path . 'index.php' ?>">TOP</a></li>
                        <li><a class="text-white text-decoration-none dropdown-item" href="<?php echo $path . 'views/auth/registerOrLogin.php' ?>"><button type="button" class="btn btn-danger">ログイン/新規登録</button></a></li>
                    </ul>
                <?php
                }
                ?>
            </div>
        </div>
    </nav>
</header>

<script>
    function logout(path) {
        if (confirm("ログアウト")) {
            window.location.href = path;
        }
    }
</script>

src/layout/footer.php

<footer class="bg-dark text-center text-lg-start text-white fixed-bottom">
    <div class="container">
        <div class="row">
            <div class="col-12">
                <p class="text-center p-3 pb-0">Copyright © 2023- monsho_youtube</p>
            </div>
        </div>
    </div>
</footer>

views

src/views/auth/registerOrLogin.php

<?php
session_start();
$title = 'ユーザー登録-PHP8掲示板';
$description = '新規登録';
$path = '../../';

// Path List //
$header_path = $path . 'layout/header.php';
$footer_path = $path . 'layout/footer.php';
$head_path = $path . 'common/head.php';

?>

<!DOCTYPE html>
<html lang="ja">
<?php
//head //
include $head_path;
?>
</head>

<style>
    .tab-item {
        flex-grow: 1;
        padding: 5px;
        list-style: none;
        border: solid 1px #CCC;
        border-radius: 20px 20px 0 0;
        text-align: center;
        cursor: pointer;
    }

    .t-area ul {
        padding: 0;
    }

    .area {
        display: none;
    }

    .tab-item.is-active {
        background: #7c7c7c;
        color: #FFF;
        transition: all 0.3s ease-out;
    }
</style>

<body>

    <!-- ヘッダー -->
    <?php include($header_path); ?>
    <div class="container">
        <div class="row justify-content-center mt-3">
            <!-- メイン -->
            <div class="col-10 col-md-6">
                <?php
                if (isset($_SESSION['message'])) {
                ?>
                    <div class="alert alert-warning alert-dismissible fade show" role="alert">
                        <strong>Error!</strong> <?php echo $_SESSION['message'] ?>
                        <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
                    </div>
                <?php } ?>
                <div class="t-area">
                    <ul class="d-flex">
                        <li class="tab-item tab-01 is-active">ログイン</li>
                        <li class="tab-item tab-02">ユーザー登録</li>
                    </ul>
                    <div class="">
                        <div class="area tab-01 d-block">
                            <h2 class="text-center">ログイン</h2>
                            <form action="<?php echo $path ?>controller/auth_login_controller.php" method="post">
                                <input type="hidden" name="_token" value="{{csrf_token()}}">
                                <input type="hidden" name="is_login" value="1">
                                <div class="my-3 row">
                                    <label for="exampleFormControlInput1" class="col-sm-2 form-label">E-mail</label>
                                    <div class="col-sm-10">
                                        <input type="email" name="email" class="form-control" id="exampleFormControlInput1" placeholder="name@example.com" required />
                                    </div>
                                </div>
                                <div class="mb-3 row">
                                    <label for="inputPassword" class="col-sm-2 col-form-label">パスワード</label>
                                    <div class="col-sm-10">
                                        <input type="password" name="password" class="form-control" id="inputPassword" required />
                                    </div>
                                </div>
                                <div class="form-group row">
                                    <div class="col-sm-12">
                                        <div class="mt-2 text-center">
                                            <a href="#" class="d-inline-block"><input type="submit" class="form-control btn btn-outline-primary" value="ログイン"></a>
                                        </div>
                                    </div>
                                </div>
                            </form>
                        </div>
                        <div class="area tab-02">
                            <h2 class="text-center">ユーザー登録</h2>
                            <form action="<?php echo $path ?>controller/auth_register_controller.php" method="post" onsubmit="return confirm_dialog()">
                                <input type="hidden" name="_token" value="{{csrf_token()}}">
                                <div class="my-3 row">
                                    <label for="exampleFormControlInput1" class="col-sm-2 form-label">E-mail</label>
                                    <div class="col-sm-10">
                                        <input type="email" name="email" class="form-control" id="exampleFormControlInput1" placeholder="name@example.com" required />
                                    </div>
                                </div>
                                <div class="mb-3 row">
                                    <label for="inputPassword" class="col-sm-2 col-form-label">パスワード</label>
                                    <div class="col-sm-10">
                                        <input type="password" name="password" class="form-control" id="inputPassword" required />
                                    </div>
                                </div>
                                <div class="mb-3 row">
                                    <label for="inputUserName" class="col-sm-2 col-form-label">ユーザー名</label>
                                    <div class="col-sm-10">
                                        <input type="text" name="name" class="form-control" id="inputUserName" required />
                                    </div>
                                </div>
                                <div class="form-group row">
                                    <div class="col-sm-12">
                                        <div class="mt-2 text-center">
                                            <a href="#" class="d-inline-block"><input type="submit" class="form-control btn btn-outline-primary" value="登録する"></a>
                                        </div>
                                    </div>
                                </div>
                            </form>
                        </div>
                    </div>
                </div>

            </div>
        </div>
    </div>
    <!-- フッター -->
    <?php include $footer_path; ?>
</body>

<script>
    function confirm_dialog() {
        return window.confirm('送信しますか?');
    }

    ///タブ切り替え///
    document.addEventListener('DOMContentLoaded', function() {

        const tabs = document.getElementsByClassName('tab-item');
        for (let i = 0; i < tabs.length; i++) {
            tabs[i].addEventListener('click', tabSwitch);
        }

        function tabSwitch() {
            const ancestorEle = this.closest('.t-area');
            ancestorEle.getElementsByClassName('is-active')[0].classList.remove('is-active');
            this.classList.add('is-active');

            ancestorEle.getElementsByClassName('d-block')[0].classList.remove('d-block');
            const groupTabs = ancestorEle.getElementsByClassName('tab-item');
            const arrayTabs = Array.prototype.slice.call(groupTabs);
            const index = arrayTabs.indexOf(this);
            ancestorEle.getElementsByClassName('area')[index].classList.add('d-block');
        };
    });
</script>

</html>

src/views/auth/logout.php

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

<?php

$title = 'ログアウト-PHP8掲示板';
$description = 'ログアウト';
$path = '../../';

// Path List //
$header_path = $path . 'layout/header.php';
$footer_path = $path . 'layout/footer.php';
$head_path = $path . 'common/head.php';

?>
</head>

<body>

    <!-- ヘッダー -->
    <?php include($header_path); ?>
    <div class="container">
        <div class="row justify-content-center mt-3">
            <!-- メイン -->
            <div class="col-10 col-md-6">
                <p>ログアウトしました</p>
            </div>
        </div>
    </div>
    <!-- フッター -->
    <?php include $footer_path; ?>
</body>

</html>

こちらは現在のコード上使ってないものになりますが、「ログアウトメニューを表示したい」という時にsrc/controller/auth_logout_controller.php のindex.phpの部分をこちらに変えてみてください。

src/views/mypage/index.php

<?php
session_start();
$title = 'マイページ-PHP8掲示板';
$description = 'マイページ';
$path = '../../';

// Path List //
$header_path = $path . 'layout/header.php';
$footer_path = $path . 'layout/footer.php';
$head_path = $path . 'common/head.php';

?>

<!DOCTYPE html>
<html lang="ja">
<?php
//head //
include $head_path;
?>
</head>

<body>

    <!-- ヘッダー -->
    <?php include($header_path); ?>
    <div class="container">
        <div class="row justify-content-center mt-3">
            <!-- メイン -->

            <div class="col-10 col-md-6">
                <form action="<?php echo $path ?>controller/auth_edit_controller.php" method="post" onsubmit="return confirm_dialog()">
                    <input type="hidden" class="" name="uid" value="<?php echo isset($_SESSION['id']) ? $_SESSION['id'] : ''; ?>" >
                    <div class="d-flex">
                        <table class="table">
                            <tr>
                                <th>ユーザー名</th>
                                <td>
                                    <div class="edit_input"><?php echo isset($_SESSION['name']) ? $_SESSION['name'] : ''; ?></div>
                                    <input type="text" class="edit_input d-none" name="name" value="<?php echo $_SESSION['name'] ?>">
                                </td>
                            </tr>
                            <tr>
                                <th>メールアドレス</th>
                                <td>
                                    <div class="edit_input"><?php echo isset($_SESSION['email']) ? $_SESSION['email'] : ''; ?></div>
                                    <input type="text" class="edit_input d-none" name="email" value="<?php echo $_SESSION['email'] ?>">
                                </td>
                            </tr>
                            <tr>
                                <th>パスワード</th>
                                <td>
                                    <div class="edit_input">************</div>
                                    <input type="text" class="edit_input d-none" name="password" value="">
                                </td>
                            </tr>
                        </table>
                    </div>
                    <div class="d-block text-center">
                        <button onclick="updateUser()" class="btn btn-lg btn-success edit_input d-none">変更</button>
                    </div>
                </form>
                <div class="d-block">
                    <button onclick="editMode()" class="btn btn-outline-success edit_input">編集モード</button>
                    <span onclick="editMode()" class="text-danger edit_input d-none" style="cursor: pointer;">取り消し</span>
                </div>
            </div>


        </div>
    </div>
    <!-- フッター -->
    <?php include $footer_path; ?>
</body>

<script>
    function confirm_dialog() {
        return window.confirm('送信しますか?');
    }

    function editMode() {
        const el = document.getElementsByClassName('edit_input');
        for (let i = 0; i < el.length; i++) {
            const element = el[i];
            isCheck = element.classList.contains('d-none');
            isCheck ? element.classList.remove('d-none') : element.classList.add('d-none');
        }
    }

    function updateUser() {
        const el = document.getElementsByClassName('edit_input');
        for (let i = 0; i < el.length; i++) {
            const element = el[i];
            isCheck = element.classList.contains('d-none');
            isCheck ? element.classList.remove('d-none') : element.classList.add('d-none');
        }
    }
</script>

src/views/post/create.php

<?php
session_start();
$title = '新規投稿-PHP8掲示板';
$description = '投稿機能';
$path = '../../';
$header_path = $path . 'layout/header.php';
$footer_path = $path . 'layout/footer.php';
$head_path = $path . 'common/head.php';
?>

<!DOCTYPE html>
<html lang="ja">
<?php
//head //
include $head_path;
?>
</head>

<body>
    <!-- ヘッダー -->
    <?php include($header_path); ?>
    <div class="container">
        <div class="row justify-content-center mt-3">
            <!-- メイン -->
            <div class="col-10 col-md-6">
                <div class="text-center">
                    <h2>新規投稿</h2>
                </div>
                <form action="<?php echo $path ?>controller/post_create_controller.php" method="post" onsubmit="return confirm_dialog()">
                    <input type="hidden" name="_token" value="{{csrf_token()}}">
                    <input type="hidden" name="user_id" value="<?php echo $_SESSION['id']; ?>">
                    <div class="form-group row">
                        <div class="mt-2">
                            <label for="titleLabel" class="col-sm-2 col-form-label col-form-label-sm fw-bold">タイトル</label>
                        </div>
                        <div class="col-sm-12">
                            <input type="text" class="form-control" name="title" id="titleLabel">
                        </div>
                    </div>
                    <div class="form-group row">
                        <div class="mt-2">
                            <label for="bodyLabel" class="col-sm-2 col-form-label col-form-label-sm fw-bold">内容</label>
                        </div>
                        <div class="col-sm-12">
                            <textarea name="body" class="form-control" id="bodyLabel" cols="30" rows="10"></textarea>
                        </div>
                    </div>
                    <div class="form-group row">
                        <div class="col-sm-12">
                            <div class="mt-4 text-center">
                                <a href="#" class="d-inline-block"><input type="submit" class="form-control btn btn-outline-primary" value="投稿する"></a>
                            </div>
                        </div>
                    </div>
                </form>
            </div>
        </div>
    </div>
    <!-- フッター -->
    <?php include($footer_path); ?>
</body>
<script>
    function confirm_dialog() {
        return window.confirm('送信しますか?');
    }
</script>

</html>

src/views/post/detail.php

<?php
session_start();
$title = '投稿詳細-PHP8掲示板';
$description = '投稿詳細';
$path = '../../';
$header_path = $path . 'layout/header.php';
$footer_path = $path . 'layout/footer.php';


require_once($path . 'common/database.php');
$pid = $_GET['id'];
$item = postDetail($pid);
$item_user = userGetInfoById($item['user_id']);
?>

<!DOCTYPE html>
<html lang="ja">
<?php
//head
include $path . './common/head.php';
?>
</head>


<style lang="scss">
    img {
        margin: auto;
        width: 80%;
    }

    .item {
        padding: 0 5px;
    }

    .card-text {
        height: auto;
    }

    .card::before,
    .card::after {
        position: absolute;
        top: 0;
        right: 0;
        bottom: 0;
        left: 0;
        transform: scale3d(0, 0, 1);
        transition: transform .3s ease-out 0s;
        background: rgba(255, 255, 255, 0.1);
        content: '';
        pointer-events: none;
    }

    .card::before {
        transform-origin: left top;
    }

    .card::after {
        transform-origin: right bottom;
    }
</style>

<body>
    <!-- ヘッダー -->
    <?php include($header_path); ?>
    <div class="container">
        <div class="row justify-content-center mt-3">
            <!-- メイン -->
            <div class="col-10 col-md-6">
                <div class="text-center">
                    <h2><?php echo $item['title'] ?></h2>
                </div>
                <div class="col-12 item mb-3">
                    <div class="card item-card card-block p-3">
                        <div class="card-text"><?php echo $item['body'] ?></div>
                        <div class="d-flex justify-content-between mt-2">
                            <div>by <strong> <?php echo $item_user['name'] ?></strong></div>
                            <div class="card-text text-black-50"><?php echo $item['created_at'] ?></div>
                        </div>
                    </div>
                </div>
                <!-- back BTN -->
                <div class="d-flex justify-content-between">
                    <button id="backBtn" class="btn btn-primary">戻る</button>
                    <?php if (isset($_SESSION['id'])) { ?>
                        <?php if ($item['user_id'] === $_SESSION['id']) { ?>
                            <form action="<?php echo $path ?>controller/post_delete_controller.php" method="post" onsubmit="return delete_dialog()">
                                <input type="hidden" name="pid" value="<?php echo $item['id']; ?>">
                                <input type="hidden" name="uid" value="<?php echo $_SESSION['id']; ?>">

                                <button type="submit" id="deleteBtn" class="btn btn-sm btn-outline-danger">削除する</button>
                            </form>
                        <?php } ?>
                    <?php } ?>
                </div>

            </div>
        </div>
    </div>
    <!-- フッター -->
    <?php include($footer_path); ?>
</body>
<script>
    function delete_dialog() {
        return window.confirm('削除しますか?');
    }

    let backBtn = document.getElementById('backBtn');
    backBtn.addEventListener('click', function() {
        history.back();
    });
</script>

</html>

トラブルシューティング

2002 connection refused

MySQLのテーブルが出来上がってない可能性があります。

SQLのテーブル作成は、docker-config/mysql/import_data/defaullt.sqlをインポートしましょう。

最後に

今回の実装では、文字での投稿機能のみを使ってます。

ここに画像の投稿ができるようにしたり、いいねボタンをつけられるとさらにリッチなコンテンツになってきます。

ぜひこれをベースにカスタマイズしてみてください。

-PROGRAMMING
-,