PHPの関数・フォーム・ビルトインサーバー入門
PHP 8.5 で実行して確認。型付き・名前付き引数・union 型の関数、クロージャ、php -S と curl での GET/POST フォーム、htmlspecialchars なしの XSS、?? と require。
シリーズ:PHP講座 2/6(初級)。 第1回を終えて、ユーザー入力を受け取る小さなページを作る・直す必要が出てきた人向けです。読み終えると、型付きの関数とクロージャを書き、コマンド1つでローカルの Web サーバーを起動し、GET と POST のフォームを処理し、XSS の穴を開けずにユーザー入力を表示できるようになります。
新人が最初にもらう PHP の仕事は「この古い問い合わせフォームの検索欄を直して」のような小さな修正であることが多いものです。そのために必要な、不正な入力をはじく関数、ページをローカルで動かす方法、表示する値を必ずエスケープする習慣の3つを、この記事1本にまとめました。以下はすべて Apple Silicon Mac(M1 Pro)の PHP 8.5.6 で実行したものです。HTTP リクエストは 127.0.0.1 の php -S に対する実際の curl 8.7.1 の呼び出しで、ファイルパスは ~/demo/ に置き換えています。
型宣言付きの関数
関数 は名前の付いたコードのまとまりで、引数(パラメーター) を受け取り、値を 返す(return) ことができます。第1回にならって、常に declare(strict_types=1); から始めます。
<?php
declare(strict_types=1);
function greet(string $name, string $greeting = "Hello", bool $shout = false): string {
$msg = "$greeting, $name!";
return $shout ? strtoupper($msg) : $msg;
}
echo greet("Mina"), "\n";
echo greet("Mina", shout: true), "\n";
echo greet(greeting: "Hi", name: "Ken"), "\n";
Hello, Mina!
HELLO, MINA!
Hi, Ken!
string $nameは引数の 型宣言、括弧の後の: stringは 戻り値の型 です。何も返さない関数は: void。$greeting = "Hello"は デフォルト値 で、呼び出し側が省略したときに使われます。shout: trueは 名前付き引数(PHP 8.0 以降)です。途中の引数を飛ばせて、greet("Mina", "Hello", true)のような読みにくい呼び出しを減らせます。
名前付き引数には特有のエラーがあります。同じ greet() に1行ずつ呼び出しを書いたときの、8.5.6 の実際のメッセージです。
greet(shout: true);
Fatal error: Uncaught ArgumentCountError: greet(): Argument #1 ($name) not passed in ~/demo/e.php:3
greet("A", nme: "B");
Fatal error: Uncaught Error: Unknown named parameter $nme in ~/demo/e.php:5
greet(name: "A", "Hi");
Fatal error: Cannot use positional argument after named argument in ~/demo/e.php on line 5
最後のものは コンパイル時 のエラーで、php -l file.php(lint:実行せずに構文だけ調べる)でも実行前に検出できます。触ったファイルには毎回 php -l をかける習慣をつけましょう。もう1つの影響として、呼び出し側が名前付き引数を使い始めると、引数名を変えただけで呼び出しが壊れます。引数名も関数の公開仕様の一部になったということです。
null 許容型と union 型
<?php
declare(strict_types=1);
function findUser(int $id): ?array {
return $id === 1 ? ["id" => 1, "name" => "Mina"] : null;
}
var_dump(findUser(2));
echo findUser(2)["name"] ?? "guest", "\n";
function formatId(int|string $id): string {
return is_int($id) ? sprintf("#%05d", $id) : "#" . $id;
}
echo formatId(42), " ", formatId("A-7"), "\n";
echo formatId(1.5), "\n";
NULL
guest
#00042 #A-7
Fatal error: Uncaught TypeError: formatId(): Argument #1 ($id) must be of type string|int, float given, called in ~/demo/fn.php on line 14 and defined in ~/demo/fn.php:10
?arrayは null 許容型 で、配列かnull(PHP 7.1 以降)。「見つからないこともある」を正直に表す書き方です。呼び出し側はnullを処理する必要があり、ここでは??を使っています。int|stringは union 型(PHP 8.0 以降)で、列挙した型のどれか。エラーメッセージでは PHP 独自の順序でstring|intと表示される点に注意してください。メッセージの文字列でコードを grep しても見つかりません。
| 表したいこと | 書き方 |
|---|---|
| ちょうど1つの型 | int $x |
| 「または null」 | ?int $x(int|null と同じ) |
| 複数の型のどれか | int|string $x |
| 何も返さない | : void |
| 省略できる引数 | int $x = 0(デフォルト値) |
クロージャ・use・アロー関数
クロージャ(無名関数)は名前のない関数で、変数に入れたり他の関数に渡したりできます。JavaScript や Python の経験者が驚くのは、PHP のクロージャは use で取り込まない限り 外側の変数が見えない ことです。
<?php
$rate = 1.1;
$withTax = fn(int $p): float => $p * $rate; // arrow fn: captures by value
$counter = 0;
$inc = function () use (&$counter): void { $counter++; }; // by reference
$incCopy = function () use ($counter): int { return $counter + 100; }; // copied now
$inc(); $inc();
echo $withTax(1000), " ", $counter, " ", $incCopy(), "\n";
$rate = 2.0;
echo $withTax(1000), "\n";
echo implode(",", array_map(fn($x) => $x ** 2, [1, 2, 3])), "\n";
1100 2 100
1100
1,4,9
- アロー関数
fn(...) => 式(PHP 7.4 以降)は外側の変数を自動で取り込みますが、作成した時点の値のコピー です。後で$rateを変えても$withTax(1000)は変わりませんでした。 use ($counter)は作成時に値をコピーします。$incCopy()は 0 を見たままで、100 を返しました。use (&$counter)は変数を共有するので、$inc()は本当に 2 まで増やしました。
よくある間違い:合計が増えないクロージャ
<?php
$total = 0;
$add = function (int $x) { $total += $x; };
$add(5);
echo $total, "\n";
$fnAdd = fn(int $x) => $total += $x;
$fnAdd(5);
echo $total, "\n";
Warning: Undefined variable $total in ~/demo/cl.php on line 3
0
0
実際に起きていること: 1つ目のクロージャの中の $total は新しいローカル変数なので Warning が出ます。アロー関数は警告こそ出ませんが同じく間違いで、自分の コピー を書き換えただけです。修正: function (int $x) use (&$total) { $total += $x; }。さらに良いのは、値を返して外側で代入する形です。テストもしやすくなります。
コマンド1つで Web ページ:php -S
PHP には ビルトイン開発サーバー が付いています。ページを試すだけなら Apache も nginx も XAMPP も要りません。ファイルのあるフォルダで次を実行します。
$ php -S 127.0.0.1:8099 -t .
[Thu Nov 12 15:18:52 2026] PHP 8.5.6 Development Server (http://127.0.0.1:8099) started
127.0.0.1 は「このパソコンからだけ」という意味です。社内 Wi-Fi で 0.0.0.0 を指定すると同じネットワークの誰でもファイルを開けてしまうので避けます。-t . はドキュメントルートの指定です。リクエストごとにログが出ます。
[Thu Nov 12 15:18:53 2026] 127.0.0.1:64641 Accepted
[Thu Nov 12 15:18:53 2026] 127.0.0.1:64641 [200]: GET /form.php?q=php+8.5
[Thu Nov 12 15:18:53 2026] 127.0.0.1:64641 Closing
マニュアルにあるとおり、このサーバーは開発用で本番用ではありません。既定では一度に1リクエストしか処理しません。
GET と POST のフォームを処理する
$_GET にはクエリ文字列(?q=...)、$_POST には送信されたフォームの本文が入り、$_SERVER['REQUEST_METHOD'] でどちらかが分かります。これらは スーパーグローバル と呼ばれ、スクリプトが始まる前に PHP が中身を用意する配列です。form.php の全体です。
<?php
declare(strict_types=1);
function h(string $s): string {
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
$method = $_SERVER['REQUEST_METHOD'];
$q = $_GET['q'] ?? '';
$name = trim($_POST['name'] ?? '');
$errors = [];
if ($method === 'POST' && $name === '') {
$errors[] = 'Name is required.';
}
?>
<!doctype html>
<meta charset="utf-8">
<title>Form demo</title>
<form method="get"><input name="q" value="<?= h($q) ?>"><button>Search</button></form>
<?php if ($q !== ''): ?>
<p>You searched for: <?= h($q) ?></p>
<?php endif; ?>
<form method="post"><input name="name"><button>Send</button></form>
<?php foreach ($errors as $e): ?>
<p class="error"><?= h($e) ?></p>
<?php endforeach; ?>
<?php if ($method === 'POST' && !$errors): ?>
<p>Thanks, <?= h($name) ?>!</p>
<?php endif; ?>
<?= ... ?> は <?php echo ... ?> の短縮形、if (...): / endif; はテンプレート向けの制御構文です。ブラウザなしで curl で確かめます。
$ curl -s -i "http://127.0.0.1:8099/form.php?q=php+8.5"
HTTP/1.1 200 OK
Host: 127.0.0.1:8099
Connection: close
X-Powered-By: PHP/8.5.6
Content-type: text/html; charset=UTF-8
<!doctype html>
...
<form method="get"><input name="q" value="php 8.5"><button>Search</button></form>
<p>You searched for: php 8.5</p>
$ curl -s --data-urlencode "name=山田 <b>" http://127.0.0.1:8099/form.php | tail -1
<p>Thanks, 山田 <b>!</p>
$ curl -s -d "name=" http://127.0.0.1:8099/form.php | grep error
<p class="error">Name is required.</p>
-d と --data-urlencode はフォーム本文付きの POST を送ります。日本語はそのまま通り、<b> だけがエスケープされました。X-Powered-By: PHP/8.5.6 ヘッダーは訪問者全員に正確なバージョンを教えてしまうので、本番では php.ini で expose_php = Off にします。もう1点、名前が空の POST でもステータスは 200 でした。入力エラーをクライアントに伝えたいなら http_response_code(422) を自分で送ります。
出力のエスケープ:htmlspecialchars がないと起きる XSS
古いページによくある書き方です。
<?php
$q = $_GET['q'];
echo "<p>You searched for: $q</p>\n";
$ curl -s -G --data-urlencode 'q=<script>alert(1)</script>' http://127.0.0.1:8099/unsafe.php
<p>You searched for: <script>alert(1)</script></p>
入力がそのまま HTML に入りました。ブラウザではこのスクリプトがあなたのドメイン上で、利用者の Cookie と一緒に実行されます。これが クロスサイトスクリプティング(XSS) です。h() を使う form.php に同じリクエストを送ると、こうなります。
<p>You searched for: <script>alert(1)</script></p>
忘れられがちなのが属性です。引用符を閉じてイベントハンドラーを足す値を送ってみます。
$ curl -s -G --data-urlencode 'q=" autofocus onfocus="alert(1)' http://127.0.0.1:8099/form.php | grep input
<form method="get"><input name="q" value="" autofocus onfocus="alert(1)"><button>Search</button></form>
ENT_QUOTES がない場合(あるいはシングルクォートを変換しなかった PHP 7 の既定値の場合)は、引用符が残ってしまいます。PHP 8.1 から既定のフラグは ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 になりましたが、フラグと 'UTF-8' を明示しておけばどのバージョンでも安全で、レビューする人にも意図が伝わります。
原則:エスケープは 入力時ではなく出力時 に行い、自分で書いたエラーメッセージも含めて すべての値 に行います。htmlspecialchars が守れるのは HTML の本文と引用符で囲んだ属性だけで、URL・JavaScript・CSS の文脈には別の方法が必要です。それらと CSRF・セッションは第5回で扱います:PHP講座 5/6:XSS・CSRF・セッション。古い PHP コードでこの種のバグを実際に直した記録はレガシー PHP の SQLi/XSS 監査にあります。
キーがないとき:$_GET の Warning、?? と ?:
unsafe.php を q なしで開きます。
$ curl -s http://127.0.0.1:8099/unsafe.php
<br />
<b>Warning</b>: Undefined array key "q" in <b>~/demo/unsafe.php</b> on line <b>2</b><br />
<p>You searched for: </p>
問題は2つ。訪問者に警告が見えること、そして実際の出力にはこのマシン上のファイルの フルパス が入っていたこと(記事では短縮)です。サーバーの構成が漏れます。本番では display_errors=Off、log_errors=On にします。今回の実験でも、そうすると警告はサーバーログにだけ出ました。
対策は form.php で使った ?? です。よく似た ?:(エルビス演算子)とは別物なので注意します。
<?php
$x = [];
echo $x['p'] ?: 'default', "\n";
echo $x['p'] ?? 'default', "\n";
$g = ['page' => '0'];
var_dump($g['page'] ?: 1, $g['page'] ?? 1);
Warning: Undefined array key "p" in ~/demo/ex.php on line 3
default
default
int(1)
string(1) "0"
| 演算子 | キーがない | 値が null |
値が "0"・""・0 |
|---|---|---|---|
$a['k'] ?? 'd' |
'd'、警告なし |
'd' |
値をそのまま返す |
$a['k'] ?: 'd' |
'd' + Warning |
'd' |
'd'(偽とみなす) |
isset($a['k']) |
false | false | true |
フォーム入力にはほぼ常に ?? が正解です。?page=0 は「ない」ではなく、れっきとした値です。
include と require
include も require も、その場所に別の PHP ファイルを読み込みます。違いはファイルがないときに表れます。
<?php
$which = $_GET['mode'] ?? 'include';
if ($which === 'include') {
include __DIR__ . '/missing.php';
} else {
require __DIR__ . '/missing.php';
}
echo "still running after $which\n";
$ curl -s "http://127.0.0.1:8099/inc.php?mode=include"
Warning: include(~/demo/missing.php): Failed to open stream: No such file or directory in ~/demo/inc.php on line 4
Warning: include(): Failed opening '~/demo/missing.php' for inclusion (include_path='.:…/share/php/pear') in ~/demo/inc.php on line 4
still running after include
$ curl -s -i "http://127.0.0.1:8099/inc.php?mode=require"
HTTP/1.1 200 OK
...
Warning: require(~/demo/missing.php): Failed to open stream: No such file or directory in ~/demo/inc.php on line 6
Fatal error: Uncaught Error: Failed opening required '~/demo/missing.php' (include_path='.:…/share/php/pear') in ~/demo/inc.php:6
Stack trace:
#0 {main}
(読みやすさのため HTML タグを除いています。)include は2回警告して 処理を続け、require は Error を投げて止まりました。意外なのはステータス行です。display_errors がオンだと、致命的エラーでも 200 OK のままでした。警告の文字列でレスポンス本文の送信が始まっていたためです。php -d display_errors=0 -S ... で起動し直すと、同じリクエストは HTTP/1.0 500 Internal Server Error と空の本文を返しました。自分のマシン以外で display_errors を切るべき理由がもう1つ増えました。
| 状況 | 使うもの |
|---|---|
| ないとページが動かない設定・関数・クラス | require_once |
| なくても困らないテンプレート部品(バナー、サイドバー) | include |
| 2回読まれうるファイル(関数定義) | _once 版(「Cannot redeclare」を防ぐ) |
| ユーザー入力から組み立てたパス | 使わない。決まったリストから選ぶ |
パスは必ず __DIR__(今のファイルがあるディレクトリ)から組み立てます。相対パスの include 'lib.php' は作業ディレクトリと include_path に左右され、それらは CLI・ビルトインサーバー・Apache で異なります。
リクエスト往復時間を測る
同じマシン上のビルトインサーバーへのリクエスト1回は、どれくらいかかるのでしょうか。curl 拡張を使う PHP のクライアントで、数えないウォームアップを20回行い、その後 URL ごとに500回を順番に計測しました(127.0.0.1:8099、PHP 8.5.6、Apple M1 Pro、OPcache は未調整)。
<?php
$N = 500;
$ch = curl_init();
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true]);
foreach (['/ping.txt', '/ping.php', '/form.php?q=hello'] as $path) {
curl_setopt($ch, CURLOPT_URL, "http://127.0.0.1:8099$path");
for ($i = 0; $i < 20; $i++) curl_exec($ch); // warm-up
$t = [];
for ($i = 0; $i < $N; $i++) {
$s = hrtime(true); curl_exec($ch); $t[] = (hrtime(true) - $s) / 1e3;
}
sort($t);
printf("%-18s median %4.0f µs p95 %4.0f µs\n", $path, $t[$N >> 1], $t[(int) ($N * .95)]);
}
| URL | 中央値(1回目) | p95(1回目) | 中央値(2回目) | p95(2回目) |
|---|---|---|---|---|
/ping.txt(静的ファイル) |
142 µs | 267 µs | 139 µs | 157 µs |
/ping.php("pong" を echo) |
134 µs | 180 µs | 148 µs | 172 µs |
/form.php?q=hello(243 バイト) |
152 µs | 181 µs | 155 µs | 204 µs |
表の値は、各ステータスが 200 であることも確認する完全版のスクリプトによるものです。上の短縮版を、他の処理が動いている時間帯にもう一度実行すると、中央値は 159・167・193 µs でした。傾向は同じで、ばらつきが大きいだけです。
ここから分かること:
- localhost では、HTTP リクエスト全体と小さな PHP スクリプトの実行で 約 0.13〜0.15 ms でした。静的ファイルと PHP スクリプトの差は誤差の範囲です。p95(遅い方から5%)は中央値より大きく揺れたので、1回の値ではなく中央値で報告しています。
- PHP をゼロから起動する方がはるかに重い処理です。
php hello.phpを50回続けて実行すると平均 約 56 ms(2回の計測で 57.1 ms と 55.9 ms。/bin/echoの起動は約 2 ms なので、大半は PHP 自身の起動)。サーバーはプロセスが生きたままなので、リクエストごとに動くのはスクリプトだけです。 - 本物のサーバーではネットワーク遅延(数十 ms)がどちらよりも大きくなります。PHP のコードを最適化する前に、まず本番環境で測りましょう。第6回で OPcache とプロファイリングを使って測ります。
8.5 の新機能:パイプ演算子
小さな関数を書けるようになったところで、8.5 で追加された機能を1つ紹介します。|> は左の値を、右の callable のただ1つの引数として渡します。
<?php
$slug = " Hello PHP 8.5 World "
|> trim(...)
|> strtolower(...)
|> (fn($s) => preg_replace('/[^a-z0-9]+/', '-', $s));
var_dump($slug);
string(19) "hello-php-8-5-world"
trim(...) は関数をクロージャに変える 第一級 callable 構文(8.1 以降)です。|> は新しい構文なので古いバージョンではこのファイルを解析できません(古い PHP で実際のメッセージは確かめていません)。古い共有レンタルサーバーでも動かす必要があるコードでは使わないでください。
正直な限界・バージョンの注記
- 実行したもの:すべてのコードを macOS(Apple M1 Pro)上の PHP 8.5.6 CLI とビルトインサーバー(
cli-serverSAPI)で。HTTP は curl 8.7.1 で 127.0.0.1 にだけ送りました。実行していないもの:Apache/mod_php、PHP-FPM、8.5 より古い PHP。Apache 上では致命的エラー時のステータスや include_path が異なることがあります。 - マニュアルに基づき、古いビルドでは確かめていないバージョン:null 許容型 7.1、アロー関数 7.4、名前付き引数と union 型 8.0、
htmlspecialcharsの既定フラグがENT_QUOTES | ENT_SUBSTITUTEになったのが 8.1、第一級 callable 8.1、パイプ演算子 8.5。コンパイル時の Fatal error にStack trace:が付くのは 8.5 の新しいfatal_error_backtraces設定によるものです(今回のビルドでは値が 1)。 - 往復時間はループバックのみ・逐次・クライアント1つでの値で、同時接続については何も言えません。ビルトインサーバーは
PHP_CLI_SERVER_WORKERSを設定しない限り1プロセスで、その設定は試していません。 - 約 56 ms の CLI 起動時間はこの Homebrew ビルド(モジュール67個)の値です。
php -n(php.ini なし)でも同じ67個で、速さも変わりませんでした(57〜58 ms)。小さく作ったビルドは試していません。
練習問題
1. 「よくある間違い」のクロージャを直して、$add(5); $add(7); echo $total; が 12 を表示するようにしてください。
解答
<?php
$total = 0;
$add = function (int $x) use (&$total): void { $total += $x; };
$add(5); $add(7);
echo $total, "\n"; // 12
実行すると 12 でした。& がないと use ($total) は 0 をコピーするだけで、外側の $total は変わりません。
2. PHP 8.5 で htmlspecialchars('<a href="x">it\'s</a>') は何を出力しますか。ENT_NOQUOTES フラグを付けると何が変わりますか。
解答
既定:<a href="x">it's</a>。ENT_NOQUOTES 付き:<a href="x">it's</a>。引用符が両方とも残るので、属性の中では安全ではありません。
3. 一覧ページで $page = $_GET['page'] ?: 1; と書いています。/list.php と /list.php?page=0 ではそれぞれ何が起き、どう書くべきですか。
解答
page なし:Warning「Undefined array key」が出て $page = 1。page=0:"0" は偽とみなされるので、黙って $page = 1。$page = (int) ($_GET['page'] ?? 1); と書き、その後で範囲を検証します(例:max(1, $page))。
次回へ
- 前回:PHP講座 1/6:型・==と===・文字列・配列
- 次回:PHP講座 3/6:クラス・インターフェース・enum
- その先:PDO・SQLite と SQL インジェクション、XSS・CSRF・セッション、OPcache・JIT・プロファイリング
- Python の同じ回:関数・スコープ・デフォルト引数
- ファイル構成を整理しても古い PHP の API を生かしておく方法:旧バージョンのアプリを壊さずにレガシー PHP API を移す
よくある質問
XAMPP や MAMP を入れずに PHP のページをローカルで動かせますか?
はい。.php ファイルのあるフォルダで php -S 127.0.0.1:8099 を実行し、ブラウザか curl で http://127.0.0.1:8099/form.php を開きます。リクエストごとにログが1行出ます。開発専用ですが、127.0.0.1 での単純なリクエストは今回の計測で中央値 0.13〜0.15 ms ほどでした。
ファイルがないとき include と require は何が違いますか?
include は Warning を2つ(Failed to open stream と Failed opening ... for inclusion)出して処理を続けます。require は Warning の後に Fatal error(Uncaught Error: Failed opening required ...)を出して止まります。display_errors をオフにすると、require の場合ビルトインサーバーは HTTP 500 を返しました。
自分で作ったフォームの値でも htmlspecialchars は必要ですか?
必要です。フォームには誰でも任意の値を送れます。$_GET['q'] をそのまま echo すると <script>alert(1)</script> が HTML にそのまま出ました。htmlspecialchars($q, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') を通すと <script>... になり、属性を壊そうとする値の引用符も " になりました。