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リクエストしか処理しません。

流れ図。ブラウザか curl が GET /form.php?q=... または POST 本文を 127.0.0.1:8099 の php -S に送る。サーバーは $_GET と $_POST を埋めてから form.php を実行する。スクリプトは $_GET['q'] ?? '' で入力を読むのでキーがなくても警告は出ず、検証して htmlspecialchars で出力をエスケープするため、HTML の応答には実行されるスクリプトではなく &lt;script&gt; が入る。点線の経路は、入力をそのまま echo すると XSS になることを示す。
ビルトインサーバーを通る1回のリクエスト。PHP が $_GET と $_POST を埋め、スクリプトは ?? で読み、すべての値は htmlspecialchars を通してから HTML に出す。

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, 山田 &lt;b&gt;!</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: &lt;script&gt;alert(1)&lt;/script&gt;</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="&quot; autofocus onfocus=&quot;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。ファイルがないと Warning の後に Fatal error(Uncaught Error: Failed opening required)が出てスクリプトが止まり、display_errors オフでは HTTP 500、オンでは 200 のままだった。はいなら include。ファイルがなければ Warning が2つ出てスクリプトは続く。
include か require か。ファイルがないと include は Warning を出して続行し、require は Fatal error で停止する。display_errors を切ると require の場合は HTTP 500 が返った。
状況 使うもの
ないとページが動かない設定・関数・クラス 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 での往復時間の中央値を示す棒グラフ。静的ファイル ping.txt が142マイクロ秒、pong を返す ping.php が134マイクロ秒、クエリ付きの form.php が152マイクロ秒。
127.0.0.1 の php -S へ、ウォームアップ20回の後に500回順番にリクエストした往復時間の中央値(1回目)。PHP 8.5.6、curl 拡張のクライアント、Apple M1 Pro。php hello.php をゼロから起動すると約 56 ms かかった。

ここから分かること:

  • 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-server SAPI)で。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 フラグを付けると何が変わりますか。

解答

既定:&lt;a href=&quot;x&quot;&gt;it&#039;s&lt;/a&gt;。ENT_NOQUOTES 付き:&lt;a href="x"&gt;it's&lt;/a&gt;。引用符が両方とも残るので、属性の中では安全ではありません。

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))。

次回へ

よくある質問

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') を通すと &lt;script&gt;... になり、属性を壊そうとする値の引用符も &quot; になりました。

  • #PHP
  • #初級
  • #関数
  • #クロージャ
  • #フォーム
  • #htmlspecialchars
  • #ビルトインサーバー

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑