CodeIgniter をまだ触っていない読者、または CodeIgniter 3 で止まっている読者向けの導入記事です。Docker だけで CodeIgniter 4 の新規プロジェクトを作り、開発サーバーの起動から Route / Controller / View をつないだ一覧ページ・詳細ページの表示までを扱います。データベース接続、Model、認証は範囲外です。
補助導線として、先に読んでおくと入りやすい記事:
- Windows 11で始めるPHPローカル開発環境(WSL2 + Docker + PostgreSQL)
- Laravel入門(Route / Controller / View / Model 最小構成)
どちらも未読のまま、以下の手順だけで進められる構成にしています。
1. CodeIgniter 4 の現在地
CodeIgniter とは
CodeIgniter は、PHP で Web アプリケーションや API を作るためのフレームワークです。1 つのリクエストを、経路を決める Route・処理を担う Controller・画面を組み立てる View の 3 つで分担する MVC 構成をとります。この記事で 5 章まで作るのが、その Route → Controller → View をつなぐ最小の 1 往復です。
CodeIgniter 3 とは別物として入り直す
CodeIgniter 4 は 3 系の延長線上にはありません。3 系のコードをそのまま動かす互換レイヤーは同梱されておらず、入り口の書き方から変わっています。
この記事の手順でインストールした vendor/codeigniter4/framework を読むと、断絶の具体が確認できます。
- コントローラの基底クラスは
CodeIgniter\Controller。3 系のCI_Controllerというクラスは存在しない loadプロパティが無い。$this->load->view(...)はview()関数へ、$this->load->model(...)はnewでのインスタンス化かmodel()ヘルパでの取得に変わった(useは完全修飾名を短く書くためのインポートで、ロードそのものではない)- クラスは PSR-4 オートロードに載り、アプリ側のコードは
App\名前空間へ入る
3 系の記憶のまま $this->load->view() と書くと、そのプロパティが無いため実行時に落ちます。移行の続きではなく、再入門として読むほうが近道です。
Laravel / Slim との距離感
同梱範囲で並べると、CodeIgniter 4 は Laravel と Slim の中間に位置します。
| フレームワーク | 同梱している範囲 | 自分で決める量 |
|---|---|---|
| Laravel | 認証、ORM、キュー、テンプレート、スケジューラまで広く同梱 | 少ない(規約に乗る) |
| CodeIgniter 4 | ルーティング、Query Builder と Model、バリデーション、セッションを同梱。認証は別途 | 中くらい |
| Slim | HTTP とルーティングのみ。DI もテンプレートも自分で選ぶ | 多い |
依存パッケージの量にも差が出ます。codeigniter4/framework の composer.json の require のうち、第三者パッケージは laminas/laminas-escaper と psr/log の 2 つだけ(ほかは php 本体と ext-intl などの拡張)。フレームワーク本体が持ち込む第三者コードが少ないため、vendor/ の中を追いかけるときに読む量が抑えられます。
選ばれる場面
- CodeIgniter 3 の資産を抱えた現場で、同じ系列の現行バージョンへ載せ替えたい
- 認証やテンプレートの選定は自分で決めたいが、ルーティングや DB 層まで自作はしたくない
- フレームワーク本体の依存を薄く保ち、
vendor/を読める規模に留めたい
認証やスターターキットを最初から使いたいなら Laravel のほうが早く、HTTP 層だけ足したいなら Slim のほうが素直に収まります。
2. 作業ディレクトリと Docker を用意する
Windows のターミナルから WSL2 に入ります。
wsl
作業ディレクトリを作り、VS Code で開きます。CodeIgniter 本体を置く src と、コンテナ定義を置く docker/php を先に作ります。
mkdir -p ~/projects/codeigniter4-intro-guide-demo/src
mkdir -p ~/projects/codeigniter4-intro-guide-demo/docker/php
cd ~/projects/codeigniter4-intro-guide-demo
code .
docker/php/Dockerfile を作成します。CodeIgniter 4 の本体は ext-intl と ext-mbstring を要求するため、intl をここで追加します(mbstring は php:8.2-cli に同梱済み)。
FROM php:8.2-cli
RUN apt-get update && apt-get install -y --no-install-recommends \
git unzip libicu-dev libzip-dev \
&& docker-php-ext-install intl zip \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /workspace
compose.yml を作成します。
services:
app:
build: ./docker/php
user: "1000:1000"
working_dir: /workspace
volumes:
- ./src:/workspace
environment:
COMPOSER_HOME: /tmp/composer
ports:
- "8080:8080"
command: ["sleep", "infinity"]
コードのポイント
① user を指定してホスト側のファイル所有者をそろえる
user: "1000:1000"
この指定が無いと、コンテナ内の Composer は root として動き、バインドマウント越しに作られる src/ 配下が root 所有になります。所有者がずれると、WSL 側のユーザーからは編集も削除もできません(Permission denied)。VS Code で開いても保存が通らないので、create-project の前に決めておく設定です。WSL2 で最初に作ったユーザーは 1000:1000。id -u と id -g の出力が違う場合は、その値へ置き換えてください。
② マウント先を ./src にして create-project の空ディレクトリ要件を満たす
volumes:
- ./src:/workspace
composer create-project は、対象ディレクトリが空でないと停止します(Project directory "/workspace/." is not empty.)。./:/workspace としてプロジェクト直下をマウントすると compose.yml と docker/ が見えるため、この条件を満たせません。コンテナ定義を src の外へ置けば、/workspace は空のままです。
③ COMPOSER_HOME を書き込める場所へ逃がす
environment:
COMPOSER_HOME: /tmp/composer
user を指定すると、コンテナ内のホームディレクトリは書き込み不可になります。Composer はキャッシュの置き場を失うため、/tmp 配下を明示して回避します。
コンテナをビルドして起動します。
docker compose up -d --build
起動を確認します。
docker compose exec app php -v
docker compose exec app composer --version
docker compose exec app php -m | grep -E '^(intl|mbstring)$'
- 期待結果: PHP 8.2 系と Composer 2 系のバージョンが表示され、
intlとmbstringの両方が並ぶ。
PHP・Composer・spark を動かすコマンドは docker compose exec app ... の形で実行します。一方、ls や cp、ファイルの編集などのファイル操作は、コンテナではなく WSL 側のプロジェクトルートで実行します。コンテナ内の /workspace はホストの src/ を指すため、src/... というパスはホスト側で解釈してください(docker compose exec app を付けて src/... を触ると、コンテナ内には src/ が無く失敗します)。
3. CodeIgniter 4 をインストールする
空の /workspace へ、スターターアプリを展開します。
docker compose exec app composer create-project codeigniter4/appstarter . --no-interaction
インストールされた構成を確認します。
ls src/
主なディレクトリの役割:
| パス | 役割 |
|---|---|
app/Controllers/ | コントローラ。App\Controllers 名前空間に載る |
app/Views/ | ビューテンプレートの置き場 |
app/Config/ | 設定クラス群。Routes.php もここに入る |
public/ | 公開ディレクトリ。index.php が置かれる |
writable/ | ログとキャッシュの書き込み先 |
spark | コマンドラインツールの実行ファイル |
src/composer.json を開くと、要求バージョンが読み取れます。php は ^8.2、codeigniter4/framework は ^4.7。App Starter の 4.7 系が php: ^8.2 を要求するため、この記事は PHP 8.2 のコンテナで最新の 4.7.4 を対象にしています。PHP 8.1 で同じ create-project を実行すると、composer は 4.7 系を選べず、8.1 を許容する旧系(App Starter 4.6 系、framework 4.6 系)を代わりに入れます。入るバージョンを固定したいときは docker compose exec app composer create-project codeigniter4/appstarter:4.7.4 . のようにバージョンを明示します。
4. .env を作って開発サーバーを起動する
スターターアプリが同梱しているのは .env ではなく env というファイル名です。コピーして使います。
cp src/env src/.env
src/.env を開き、CI_ENVIRONMENT の行のコメントを外して development へ変更します。
CI_ENVIRONMENT = development
開発サーバーを起動します。
docker compose exec app php spark serve --host 0.0.0.0 --port 8080
このコマンドはターミナルを占有したまま動き続けます。停止は Ctrl+C。次章の docker compose exec app php spark routes やブラウザでの確認は、このサーバーを止めずに、VS Code で 2 つ目の WSL ターミナルを開いて実行します。
--host 0.0.0.0 を省略するとブラウザから届かない
php spark serve の既定の待ち受けアドレスは localhost です。コンテナ内の localhost はコンテナ自身を指すため、ports で 8080 を公開していてもホスト側からの接続は拒否されます。
手元で確認した挙動:
# 既定(--host なし)
コンテナ内から -> 応答あり
ホスト側から curl -> 接続拒否(status=000)
# --host 0.0.0.0 を付けた場合
ホスト側から curl -> status=200
ブラウザで http://localhost:8080/ を開くと、スターターアプリの初期ページが表示されます。インストールされたバージョンがそのまま見出しに出るため、4.7.4 のようなパッチバージョンまで確認できます。
画面右下の炎のアイコンがデバッグツールバーで、CI_ENVIRONMENT が development のときだけ出ます。production との差は 6 章で確認します。
5. Route / Controller / View をつなぐ
初期ページは、app/Config/Routes.php の $routes->get('/', 'Home::index'); が App\Controllers\Home::index() を呼び、その中の view('welcome_message') が app/Views/welcome_message.php を描画した結果です。同じ流れで、レポートの一覧ページと詳細ページを足します。
リクエストが通る経路:
flowchart LR
B[ブラウザ] -->|GET /reports| R[app/Config/Routes.php]
R -->|Reports::index| C[app/Controllers/Reports.php]
C -->|view reports/index| V[app/Views/reports/index.php]
V -->|HTML| B
ルートを登録する
src/app/Config/Routes.php を、次の内容へ書き換えます。
<?php
use CodeIgniter\Router\RouteCollection;
/** @var RouteCollection $routes */
$routes->get('/', 'Home::index');
$routes->get('reports', 'Reports::index');
$routes->get('reports/(:num)', 'Reports::show/$1');
コードのポイント
① (:num) で数字だけを受け取り、$1 で引数へ渡す
$routes->get('reports/(:num)', 'Reports::show/$1');
(:num) は数字にのみ一致するプレースホルダで、内部では ([0-9]+) の正規表現へ展開されます。一致した部分が $1 の位置に入り、Reports::show() の第 1 引数として渡ります。/reports/abc はこのルート自体に一致しないため、コントローラへ到達せず 404 になります。
コントローラを作る
src/app/Controllers/Reports.php を作成します。データベースは使わず、定数の配列を表示元にします。
<?php
declare(strict_types=1);
namespace App\Controllers;
use CodeIgniter\Exceptions\PageNotFoundException;
class Reports extends BaseController
{
private const REPORTS = [
1 => ['title' => 'レポートA', 'owner' => 'ユーザー1', 'status' => '公開'],
2 => ['title' => 'レポートB', 'owner' => 'ユーザー2', 'status' => '下書き'],
3 => ['title' => 'レポートC', 'owner' => 'ユーザー1', 'status' => '公開'],
];
public function index(): string
{
return view('reports/index', ['reports' => self::REPORTS]);
}
public function show(int $id): string
{
if (! isset(self::REPORTS[$id])) {
throw PageNotFoundException::forPageNotFound('レポート ' . $id . ' は存在しません');
}
return view('reports/show', [
'id' => $id,
'report' => self::REPORTS[$id],
]);
}
}
コードのポイント
① BaseController を継承する
class Reports extends BaseController
app/Controllers/BaseController.php はスターターアプリが用意する抽象クラスで、CodeIgniter\Controller を継承しています。全コントローラ共通の初期化を置く場所として用意されているため、新規のコントローラもこちらを継承します。
② view() の第 2 引数がビューの変数になる
return view('reports/index', ['reports' => self::REPORTS]);
第 1 引数は app/Views/ からの相対パスで、拡張子 .php は省きます。第 2 引数の連想配列はキーがそのまま変数名へ展開されるので、ビュー側では $reports として参照できます。
③ 存在しない ID は PageNotFoundException で 404 にする
throw PageNotFoundException::forPageNotFound('レポート ' . $id . ' は存在しません');
この例外はフレームワークが 404 応答へ変換するため、ステータスコードを自分で組み立てる必要はありません。渡したメッセージが画面に出るかどうかは CI_ENVIRONMENT で変わります(6 章)。
ビューを作る
src/app/Views/reports/ ディレクトリを作り、その中に index.php を作成します。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>レポート一覧</title>
</head>
<body>
<h1>レポート一覧</h1>
<ul>
<?php foreach ($reports as $id => $report): ?>
<li>
<a href="/reports/<?= $id ?>"><?= esc($report['title']) ?></a>
(<?= esc($report['status']) ?>)
</li>
<?php endforeach; ?>
</ul>
</body>
</html>
続けて src/app/Views/reports/show.php を作成します。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title><?= esc($report['title']) ?></title>
</head>
<body>
<h1><?= esc($report['title']) ?></h1>
<dl>
<dt>ID</dt><dd><?= esc((string) $id) ?></dd>
<dt>担当</dt><dd><?= esc($report['owner']) ?></dd>
<dt>状態</dt><dd><?= esc($report['status']) ?></dd>
</dl>
<p><a href="/reports">一覧へ戻る</a></p>
</body>
</html>
コードのポイント
① 出力は esc() を通す
<a href="/reports/<?= $id ?>"><?= esc($report['title']) ?></a>
esc() はフレームワークが用意するグローバル関数で、内部で laminas/laminas-escaper を呼びます。既定は HTML 文脈のエスケープ。<?= ?> へ生の値を渡した場合、値に含まれるタグは描画時に解釈されます。この記事の表示元は定数配列なので実害はありませんが、値が利用者の入力や DB 由来へ変わった時点で、この一手間が境界線です。掲載行で href の $id を素で出しているのは、ルートを (:num) に限定しており数字しか渡ってこないためです。文字列を出す title などは必ず esc() を通します。属性・JavaScript・CSS・URL のように HTML テキスト以外へ出すときは、esc($value, 'attr') のように第 2 引数で出力文脈を指定します。
登録されたルートを確認する
docker compose exec app php spark routes
+--------+------------------+------+-----------------------------------+
| Method | Route | Name | Handler |
+--------+------------------+------+-----------------------------------+
| GET | / | » | \App\Controllers\Home::index |
| GET | reports | » | \App\Controllers\Reports::index |
| GET | reports/([0-9]+) | » | \App\Controllers\Reports::show/$1 |
+--------+------------------+------+-----------------------------------+
(:num) が ([0-9]+) へ展開された結果と、show/$1 の引数渡しがこの表から読み取れます。
ここまでの動作確認
ブラウザで http://localhost:8080/reports を開きます。
- 期待結果: 3 件のレポートがリンク付きで並ぶ。
- リンクをたどると
http://localhost:8080/reports/2で詳細が表示される。 http://localhost:8080/reports/99は 404 になる。
リンク先の URL は /reports/2 の形になり、(:num) に一致した 2 が Reports::show() の引数として渡ります。
6. CI_ENVIRONMENT で表示がどう変わるか
.env の CI_ENVIRONMENT は、エラー表示とデバッグツールバーの出方を切り替えます。src/.env を production へ変更して比べます。
CI_ENVIRONMENT = production
変更したら、spark serve を実行中のターミナルで Ctrl+C を押して停止し、同じコマンドで起動し直します。.env は起動時に読み込まれるため、走らせたままでは反映されません。
同じ URL を開いたときの差:
| 確認した項目 | development | production |
|---|---|---|
| デバッグツールバー(通常のページ) | 画面右下にアイコンが出る | 出ない |
/reports/99 の 404 画面 | PageNotFoundException::forPageNotFound() へ渡したメッセージがそのまま出る | 定型の文言に差し替わる |
development では、5 章のコードで渡した「レポート 99 は存在しません」が画面に出ます。
production では同じ URL が定型の文言(Sorry! Cannot seem to find the page you were looking for.)へ差し替わり、アプリ側が例外へ渡した文字列は画面から消えます。存在しない ID を突いた利用者に、内部の事情を説明した文言が出ることはありません。
確認が済んだら development へ戻し、production のときと同じように Ctrl+C で停止してから開発サーバーを起動し直します。
CI_ENVIRONMENT = development
.env はバージョン管理へ入れません。スターターアプリの .gitignore は .env を除外済みで、リポジトリに残るのは雛形の env だけです。
まとめ
この記事で組み立てた構成:
intlを追加した PHP 8.2 コンテナと、user指定でホスト側の所有者をそろえた Docker 環境(2 章)composer create-projectによるスターターアプリの展開(3 章).envの用意と、spark serve --host 0.0.0.0での起動(4 章)- Route から Controller、View への導線と、
(:num)を使った ID 付き詳細ページ(5 章) CI_ENVIRONMENTによるエラー表示の切り替え(6 章)
次の一歩は、app/Config/Database.php と .env の database.default.* を使った DB 接続、そして Model と Query Builder による一覧の置き換えになります。
同じ題材を別のフレームワークで比べる場合は、次の 2 本が対応します。