🙌

【ハンズオン】PHPでシンプルなフレームワークを実装する

2022/06/24に公開

初めに

今までフレームワークに頼りきりで開発してきたため、ふとMVCフレームワークってどうやって書いているんだろう、と思ったことが今回の記事を書くきっかけです。
今回は簡単なMVCフレームワークを作成してみました。

  • 実装の仕方は色々あると思います。こんな感じかな、という妄想で作成したので正直ツッコミどころ満載かと思いますが、ご了承いただければと思います。
  • .htaccessについてはすみません、apacheをあまり理解しておらず割と適当です。理解を深めたら追記します!

環境

  • MAMPを使用しています
  • PHP8.0を使用

参考

オリジナルのフレームワークを作ってみよう、というUdemyの講座ですが、この記事を書くにあたり非常に役立ちました。
https://www.udemy.com/course/object-oriented-php-mvc/

実装の流れ

  1. http://my_framework/tweet/indexへアクセス
    • MAMPで動かしているので、私の環境ではhttp://localhost:8888/my_framework/tweet/indexです
    • my_framework/tweet/show/{id}のパターンも作成します
  2. /.htaccessにて/public/.htaccessを呼び出す
  3. /public/.htaccessではルートURL以降のパラメータを/public/index.php?url=tweet/indexのようにクエリ文字列とし、/public/index.phpにて下記処理を実行
    • /App/bootstrap.phpを読み込み、リクエストの都度必要な処理を実行
      • helperファイルやconfigファイルを読み込む
      • autoloaderを定義
    • /App/Core.phpを呼び出し、URLに対応するTweetControllerのIndexメソッドを呼び出す(※1)
  4. Tweetモデルを定義し、tweetsテーブルから投稿を取得する処理を実装
  5. ※1へ戻り、tweet一覧を取得し、継承元であるController.phpのviewメソッドを呼び出す
  6. URLに対応するviewを読み込む
    • まずは共通templateファイルを読み込み、その中でtweet/index.php用のviewファイルを読み込む
    • tweet一覧を格納した変数やJS・CSSファイルが処理され、ブラウザに正しく表示される

ファイル構成

my_framework
├─ App
│    ├─ Controllers
│    │     └─ User
│    │          └─TweetController.php
│    ├─ Models
│    │     └─ Tweet.php
│    ├─ Libraries
│    │     ├─ Controller.php
│    │     ├─ Model.php
│    │     └─ Core.php
│    ├─ bootstrap.php
│    └─ config.php
│
├─ public
│    ├─ css
│    │   └─ user
│    │       └─ tweet
│    │             ├─ index.php
│    │             └─ show.php
│    ├─ js
│    │   └─ user
│    │       └─ tweet
│    │             ├─ index.php
│    │             └─ show.php
│    ├─ views
│    │   └─ user
│    │       └─ tweet
│    │       │    ├─ index.php
│    │       │    └─ show.php
│    │       └─ template.php
│    ├─ .htaccess
│    └─ inex.php
│
└─ .htaccess

1. my_framework/tweet/indexへアクセス

.htaccessを読みに行き、RewriteRuleによりpublicディレクトリへ処理が移ります。

/.htaccess
<IfModule mod_rewrite.c>
    RewriteEngine on
    RewriteRule ^$ public/ [L]
    RewriteRule (.*) public/$1 [L]
</IfModule>

2. /.htaccessにて/public/.htaccessを呼び出す

下記RewriteRuleにより、/public/index.php?url=tweet/indexへ処理が移ります。

/public/.htaccess
<IfModule mod_rewrite.c>
    Options -Multiviews
    RewriteEngine On
    RewriteBase /my_framework/public
    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteRule  ^(.+)$ index.php?url=$1 [QSA,L]
</IfModule>

3. /public/index.phpを実行し、初期化処理を実行→対応するコントローラメソッドを実行

bootstrap.phpとCore.phpを呼び出します。

/public/index.php
<?php
// file読み込み系の処理
require_once '../app/bootstrap.php';

// session_startやcsrfの処理、URLに対応したコントローラ呼び出し
new App\Libraries\Core();

bootstrapではconfigやhelerといった、全ページで使用する想定の各種ファイルを読み込み、
またautoloaderを定義しています。

/app/bootstrap.php
<?php

$files = [
    // 設定ファイル
    'config.php',
    // helperがあればここで読み込む
];

foreach ($files as $file) {
    require_once $file;
}

// autoloader
// 未定義のclassが呼ばれた時に引数のcallbackが実行される。$classNameには呼ばれたclass名が入る
spl_autoload_register(function ($className) {
    $className = str_replace('\\', '/', $className);

    require_once BASE_PATH . "{$className}.php";
});

config.phpはこちら
/app/config.php
<?php
// DB params
const DB_HOST = 'localhost';
const DB_USER = 'root';
const DB_PASS = 'root';
const DB_NAME = 'my_framework';

// URL root
const BASE_URL = 'http://localhost:8888/my_framework/';

// application root
define('BASE_PATH', dirname(dirname(__FILE__)) . '/');

// Site Name
const SITE_NAME = 'My Framework';

Core.phpは名前の通り中心となる処理です。
public/index.phpのクエリ文字列として渡されたパラメータから、対応するコントローラのメソッドを呼び出します。

/app/libraries/Core.php
<?php
namespace App\Libraries;

class Core {
    public function __construct()
    {
        // sessionを使用する場合は、ここでsession_startを宣言する

        // POSTリクエストであっても、クエリ文字列はGETで取れる
        $url = filter_input(INPUT_GET, 'url');

        // POSTリクエストならここでcsrfチェックをしたい

        // $urlをサニタイズした上で配列にする
        $url = $this->formatAndSanitizeUrl(url:$url);

        // 定義済みルート一覧を取得
        $routes = $this->getRoutes();

        // URLによって呼び出すコントローラメソッドを特定
        $funcWithParams = $this->getControllerFromUrl(url:$url, routes:$routes);

        // controllerをインスタンス化し、methodに(あれば)paramsを渡して呼び出す
        call_user_func_array(
            [
                new($funcWithParams['controller']),
                $funcWithParams['method']
            ],
            $funcWithParams['params']
        );
    }

    /**
     * 1.URL末尾の/を削除
     * 2.値をサニタイズ(例えば日本語など、無効な文字を取り除く)
     * 3.配列に分割
     *
     * @param string|null $url
     * @return array
     */
    private function formatAndSanitizeUrl(string|null $url): array
    {
        // root(http://my_framework/)へのアクセスの場合は$urlがnullなので、空配列を返す
        if (!$url) return [];

        $url = rtrim($url, '/');
        $url = filter_var($url, FILTER_SANITIZE_URL);
        $url = explode('/', $url);

        return $url;
    }

    /**
     * 定義済みルート一覧を定義
     *
     * tweet/indexへのgetリクエストであれば、User/TweetController/indexを取得することを想定
     *
     * @param array $url
     * @param array $routes
     * @return array
     */
    private function getRoutes(): array
    {
        $routes = [
            'user' => [
                'tweet' => [
                    // http method (get or post)
                    'get' => [
                        'index' => 'User/TweetController/index',
                        'show' => 'User/TweetController/show',
                    ],
                    // ここより下は一例として記載(今回は処理は未定義)
                    'post' => [
                        'save' => 'User/TweetController/save',
                    ]
                ],
                'home' => [
                    'get' => [
                        'top' => 'User/HomeController/index' // URLと名前が異なるコントローラメソッドでもOK
                    ]
                ]
            ],
            // 管理側画面など、異なるnamespaceで実装可能
            'admin' => [
                'article' => [
                    'get' => [
                        'index' => 'Admin/ArticleController/index.'
                    ]
                ]
            ]
        ];

        return $routes;
    }

     /**
     * URLを取得し、対応するclassのmethodを呼び出す基幹処理
     *
     * ・$urlが空なら、homeページへ
     * ・$urlが定義済みルートにが存在しないurlなら、404を表示(今回は未実装)
     *
     * @return array
     */
    private function getControllerFromUrl(array $url, array $routes): array
    {
        // $urlが空ならrootなので、TOPページをリターン(今回はHomeControllerは未定義)
        if (count($url) === 0) {
            return [
                'controller' => "App\\Controllers\\User\\HomeController",
                'method' => 'index',
                'params' => [],
            ];
        }

        // 今後管理画面やapiリクエストを追加することを想定し、namespaceに対応できるようにしておく
        // namespaceがurlにない場合はuser側と判断し、user namespaceを挿入
        if (!in_array($url[0], ['api', 'admin'], true)) {
            array_unshift($url, 'user');
        }

        // getもしくはpost
        $requestMethod = strtolower($_SERVER["REQUEST_METHOD"]);

        $namespace = $url[0] ?? null;
        $controller = $url[1] ?? null;
        $method = $url[2] ?? null;

        // 定義済みルートと照合
        $route = $routes[$namespace][$controller][$requestMethod][$method] ?? null;

        // 合致したルートに紐づくコントローラ・メソッドを取得
        if ($route) {
            [$namespace, $controller, $method] = explode('/', $route);

            return [
                'controller' => "App\\Controllers\\{$namespace}\\{$controller}",
                'method' => $method,
                'params' => array_slice($url, 3),
            ];
        } else {
            // 合致するルートがない場合は404(今回はErrorControllerは未定義)
            return [
                'controller' => "App\\Controllers\\User\\ErrorController",
                'method' => 'response404',
                'params' => [],
            ];
        }
    }
}

4. Tweetモデルを定義し、tweetsテーブルから投稿を取得する処理を実装

/App/Models/Tweet.php
<?php
namespace App\Models;

use App\Libraries\Model;

class Tweet extends Model {

    // テーブル名
    private string $table = 'tweets';

    public function __construct()
    {
        // PDOへの接続処理を実行(詳細は次のファイルを参照)
        parent::__construct();
    }

    public function getTweets()
    {
        $sql = "SELECT * FROM {$this->table}";

        // $this->pdoは継承元のModel.phpのプロパティ
        $this->pdoStatement = $this->pdo->prepare($sql);

        $this->pdoStatement->execute();

        // tweet classのインスタンスとして取得する
        $this->pdoStatement->setFetchMode(\PDO::FETCH_CLASS, Tweet::class);

        return $this->pdoStatement->fetchAll();
    }
}
継承元のModelはこちら
/App/Libraries/Model.php
<?php
namespace App\Libraries;

class Model {
    // config.phpで定義済み
    private $host = DB_HOST;
    private $user = DB_USER;
    private $pass = DB_PASS;
    private $dbname = DB_NAME;

    protected $pdo;
    protected $pdoStatement;

    public function __construct()
    {
        $dsn = "mysql:host={$this->host};dbname={$this->dbname};charset=utf8mb4";
        $options = [
            \PDO::ATTR_PERSISTENT => true,
            \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
        ];

        try {
            $this->pdo = new \PDO($dsn, $this->user, $this->pass, $options);
        } catch (\PDOException $e) {
            exit($e->getMessage());
        }
    }
}
tweetsテーブルの定義・データ流し込みSQLはこちら
CREATE TABLE `tweets` (
    `id` INT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    `title` VARCHAR(100) NOT NULL,
    `content` TEXT NOT NULL
);

INSERT INTO `tweets` (`id`, `title`, `content`)
VALUES
    (1, 'php', '楽しい'),
    (2, 'js', '非同期難しい');

5. TweetControllerのindexメソッドにてtweet一覧を取得し、継承元であるController.phpのviewメソッドを呼び出す

App/Controllers/User/TweetController.php
<?php
namespace App\Controllers\User;

use App\Libraries\Controller;
use App\Models\Tweet;

class TweetController extends Controller {

    private Tweet $tweetModel;

    public function __construct()
    {
        $this->tweetModel = new Tweet();
    }

    public function index()
    {
        $title = '投稿一覧ページ';

        // 投稿一覧を取得
        $tweets = $this->tweetModel->getTweets();

        $data = [
            'css' => 'css/user/tweet/index.css',
            'js' => 'js/user/tweet/index.js',
            'title' => $title,
            'tweets' => $tweets,
        ];

        // 定義した変数と対応するviewファイル名を渡す
        $this->view(view:'user/tweet/index', data:$data);
    }
}

継承元のController

/App/Libraries/Controller.php
<?php
namespace App\Libraries;

/**
 * base controller
 * modelとviewをloadする
 */
class Controller {

    /**
     * viewを読み込む
     *
     * @param string $view
     * @param array $data
     * @return void
     */
    public function view($view, $data = []):void
    {
        // 共通テンプレート内で読み込む
        $viewFile = BASE_PATH . "public/views/{$view}.php";

        // view側で$data['title']ではなく$titleと書けるよう、変数を定義
        foreach ($data as $key => $value) {
            ${$key} = $value;
        }

        // $dataはもう不要なので、view側で参照できないよう削除
        unset($data);

        // 共通テンプレートファイルを読み込む
        require_once BASE_PATH . 'public/views/user/template.php';
    }
}

6. URLに対応するviewを読み込む

まずtemplateファイルを読み込み、$viewFile(tweet/index.php)を読み込む

/public/views/user/template.php
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title><?= SITE_NAME ?></title>
    <!-- CSS -->
    <link rel="stylesheet" href="<?= BASE_URL . 'public/' . $css; ?>">
</head>
<body>
    <!-- メインコンテンツ -->
    <main class="main">
        <?php require_once($viewFile) ?>
    </main>
    <!-- JavaScript -->
    <script defer src="<?= BASE_URL . 'public/' . $js; ?>"></script>
</body>
</html>

URLごとの個別viewファイル

public/views/user/tweet/index.php
<div>
    <h1><?= $title; ?></h1>
    <?php foreach($tweets as $tweet) : ?>
        <p><?= $tweet->title; ?></p>
        <p><?= $tweet->content; ?></p>
        <hr>
    <?php endforeach; ?>
</div>
cssとjsはこちら
/public/css/user/tweet/index.css
h1 {
  color: #1d7ad7;
}
/public/js/user/tweet/index.js
document.querySelector("h1").style.backgroundColor = "#a5c3e0";

ちゃんと投稿一覧・JS・CSSが反映されています。

my_framework/tweet/show/{id}にアクセスした場合

ルーティング定義によりTweetControllerのshowが呼び出され、{id}が引数として渡される

App/Controllers/User/TweetController.php
<?php
namespace App\Controllers\User;

use App\Libraries\Controller;
use App\Models\Tweet;

class TweetController extends Controller {

    private Tweet $tweetModel;

    public function __construct()
    {
        $this->tweetModel = new Tweet();
    }

    // public function index()は省略

    public function show($id)
    {
        $title = '投稿詳細ページ';

        // 該当の投稿を取得(無ければ404を返す)
        $tweet = $this->tweetModel->getTweetById($id);

        $data = [
            'css' => 'css/user/tweet/show.css',
            'js' => 'js/user/tweet/show.js',
            'title' => $title,
            'tweet' => $tweet,
        ];

        $this->view(view:'user/tweet/show', data:$data);
    }
}

Tweetモデルでモデルで該当の投稿を取得する処理

/App/Models/Tweet.php
<?php
namespace App\Models;

use App\Libraries\Model;

class Tweet extends Model {

    private string $table = 'tweets';

    public function __construct()
    {
        parent::__construct();
    }

    // public function getTweets()は省略

    public function getTweetById($id)
    {
        $sql = "SELECT * FROM {$this->table} WHERE `id` = :id";

        $this->pdoStatement = $this->pdo->prepare($sql);

        $this->pdoStatement->bindValue(':id', $id, \PDO::PARAM_INT);

        $this->pdoStatement->execute();

        $this->pdoStatement->setFetchMode(\PDO::FETCH_CLASS, Tweet::class);

        return $this->pdoStatement->fetch();
    }
}
public/views/user/tweet/show.php
<div>
    <h1><?= $title; ?></h1>
    <p><?= $tweet->title; ?></p>
    <p><?= $tweet->content; ?></p>
</div>

GitHubで編集を提案

Discussion