解説コードを読んでみよう
このサンプルでわかること
- 同じ名前の入力欄を何個でも増やせるフォームの作り方(
name="text[]") - 複数の画像を受け取ったときの
$_FILESの中身と、安全なチェックのしかた - PHP の GD 機能で、画像を中サイズ・サムネイルに縮小して保存する方法
- PDO のプリペアドステートメントで、データベースに登録・更新・削除する方法
- 編集画面で「既存の行」と「新しく追加した行」を見分けて処理する方法
上のデモは、追加画面 → 表示画面 → 編集画面の3つの画面でできています。まずは追加画面で画像を2〜3枚登録し、表示画面と編集画面も触ってみてから解説を読むと、コードの役割がつかみやすくなります。
使っているファイル
- index.php追加画面。「行を追加」で入力欄を増やせるフォーム
- upload.php追加画面から送られた内容をチェックし、画像を保存してデータベースに登録
- view.php表示画面。登録された画像とテキストの一覧
- edit.php編集画面。テキストの書き換え、画像の差し替え・削除、行の削除
- update.php編集画面から送られた内容でデータベースを更新
- del_img.php / del_line.php編集画面のボタンから ajax で呼ばれ、画像だけ/行ごと削除
- del_all.php「全て削除する」ボタンの処理
- common.php全ファイル共通の設定と関数(画像のチェック・保存・縮小など)
- db.phpデータベースに接続する関数
- img_full/ img_med/ img_thu/画像の保存先(原寸・中サイズ・サムネイル)
処理の流れ
| 画面・処理 | やること | 次に進む先 |
|---|---|---|
| index.php(画面) | テキストと画像を入力して送信 | upload.php |
| upload.php(処理) | チェック → 画像を保存・縮小 → INSERT | view.php へ自動で移動 |
| view.php(画面) | SELECT して一覧表示 | — |
| edit.php(画面) | SELECT した内容をフォームに入れて表示。画像・行の削除は ajax | update.php |
| update.php(処理) | 既存の行は UPDATE、新しい行は INSERT | view.php へ自動で移動 |
「処理」のファイルは画面を持たず、仕事が終わったら header('Location: ...') で次の画面へ移動させます。こうしておくと、表示画面で再読み込み(F5)をしても、同じデータが二重に登録されません。
1. データベースのテーブルを用意する
1行が「テキスト1つ+画像1枚」です。画像そのものはファイルとしてフォルダに保存し、データベースにはファイル名だけを記録します。
CREATE TABLE sp_img_01 (
ID INT AUTO_INCREMENT PRIMARY KEY, -- 行の番号(自動で増える)
user_id INT NOT NULL, -- 誰のデータか(サンプルでは 1 固定)
post_id INT NOT NULL, -- どの記事のデータか(サンプルでは 1 固定)
text VARCHAR(255) NOT NULL DEFAULT '',
img VARCHAR(255) NOT NULL DEFAULT '', -- 画像のファイル名(画像なしなら空)
date DATETIME DEFAULT CURRENT_TIMESTAMP -- 登録日時(画像の保存フォルダにも使う)
) DEFAULT CHARSET=utf8mb4;
2. 行を増やせるフォームを作る(index.php)
ポイントは入力欄の名前です。name="text[]" のように最後に [] を付けると、同じ名前の入力欄がいくつあっても、PHP 側では配列としてまとめて受け取れます。
<form action="upload.php" method="post" enctype="multipart/form-data">
<input type="hidden" name="MAX_FILE_SIZE" value="1048576">
<table class="rows">
<thead>
<tr><th>テキスト</th><th>画像(1MBまで)</th></tr>
</thead>
<tbody>
<tr>
<td><input type="text" name="text[]" maxlength="100"></td>
<td>
<div class="preview"></div>
<input type="file" name="img[]" accept="image/*">
</td>
</tr>
</tbody>
</table>
<p>
<button type="button" id="add">+ 行を追加</button>
<span id="message" class="msg"></span>
</p>
<hr>
<input type="submit" value="送信して保存する">
</form>
enctype="multipart/form-data"… ファイルを送るフォームには必ず付けます。これがないと画像が届きません。MAX_FILE_SIZE… ファイルサイズの上限(バイト)。超えると PHP がエラー番号 2 を返します(ただし最終的なチェックは PHP 側で行います)。accept="image/*"… ファイル選択の画面で画像だけを表示するためのヒントです。
「行を追加」ボタンを押すと、jQuery で同じ形の行を表の最後に足します。画像を選んだときは FileReader で、送信前にプレビューを表示しています。
<script>
$(function () {
var MAX_ROWS = 5; // 一度に送信できる行数
// 画像を選んだら、送信前にプレビューを表示する
$(document).on('change', 'input[type=file]', function () {
var $preview = $(this).siblings('.preview');
$preview.empty();
if (!this.files[0]) return;
var reader = new FileReader();
reader.onload = function (e) {
$preview.html('<img src="' + e.target.result + '" alt="">');
};
reader.readAsDataURL(this.files[0]);
});
// 「行を追加」ボタン
$('#add').on('click', function () {
if ($('.rows tbody tr').length >= MAX_ROWS) {
$('#message').text('※ 一度に追加できるのは' + MAX_ROWS + '行までです。');
return;
}
var row = '<tr>' +
'<td><input type="text" name="text[]" maxlength="100"></td>' +
'<td><div class="preview"></div><input type="file" name="img[]" accept="image/*"></td>' +
'</tr>';
$('.rows tbody').append(row);
});
});
</script>
あとから追加した行にもプレビューが効くように、$('input[type=file]').on(...) ではなく $(document).on('change', 'input[type=file]', ...) と書いています。これを「イベントの委譲」といい、後から作られた要素にもイベントが届く書き方です。
3. 送られてきたデータの形を知る($_FILES)
2行分(1行目は画像あり、2行目は画像なし)を送信したとき、PHP には次のような形で届きます。$_FILES['img'] は「名前の一覧」「エラー番号の一覧」…のように、項目ごとに配列になっているのが特徴です。
// print_r($_POST);
Array
(
[MAX_FILE_SIZE] => 1048576
[text] => Array ( [0] => 夏の空 [1] => 画像なしの行 )
)
// print_r($_FILES);
Array
(
[img] => Array
(
[name] => Array ( [0] => sky.jpg [1] => )
[type] => Array ( [0] => image/jpeg [1] => )
[tmp_name] => Array ( [0] => /tmp/phpA1b2C3 [1] => )
[error] => Array ( [0] => 0 [1] => 4 )
[size] => Array ( [0] => 183205 [1] => 0 )
)
)
つまり「$i 番目の行」の画像は、$_FILES['img']['tmp_name'][$i]、そのエラー番号は $_FILES['img']['error'][$i] のように取り出します。テキストの $_POST['text'][$i] と番号がそろっているので、同じ $i で1行分をまとめて扱えます。
4. 画像をチェックする(common.php)
受け取ったファイルが本当に画像か、サイズは大丈夫かを確認します。エラー番号 4(ファイルが選ばれていない)は「テキストだけの行」なので、エラーにはしません。
/**
* 送信された画像 1 枚をチェックする
* 問題がなければ null、問題があればエラーメッセージを返す
*/
function check_image(array $files, int $i): ?string
{
$error = $files['error'][$i] ?? UPLOAD_ERR_NO_FILE;
$name = $files['name'][$i] ?? '';
switch ($error) {
case UPLOAD_ERR_OK: // 0: 正常
break;
case UPLOAD_ERR_NO_FILE: // 4: 画像が選ばれていない行(テキストだけの行はOK)
return null;
case UPLOAD_ERR_INI_SIZE: // 1: php.ini の上限を超えた
case UPLOAD_ERR_FORM_SIZE: // 2: フォームの MAX_FILE_SIZE を超えた
return "{$name} のファイルサイズが大きすぎます(1MBまで)。";
default:
return "{$name} のアップロードに失敗しました(エラー番号 {$error})。";
}
if ($files['size'][$i] > MAX_SIZE) {
return "{$name} のファイルサイズが大きすぎます(1MBまで)。";
}
// 拡張子ではなく、ファイルの中身から画像の種類を調べる
$info = @getimagesize($files['tmp_name'][$i]);
if ($info === false || image_ext($info['mime']) === null) {
return "{$name} は画像ファイル(GIF・JPEG・PNG)ではありません。";
}
return null;
}
| エラー番号 | 定数 | 意味 |
|---|---|---|
| 0 | UPLOAD_ERR_OK | 正常に届いた |
| 1 | UPLOAD_ERR_INI_SIZE | サーバー(php.ini)の上限を超えた |
| 2 | UPLOAD_ERR_FORM_SIZE | フォームの MAX_FILE_SIZE を超えた |
| 3 | UPLOAD_ERR_PARTIAL | 途中までしか届かなかった |
| 4 | UPLOAD_ERR_NO_FILE | ファイルが選ばれていない |
ファイル名の拡張子(.jpg など)は、送る人が自由に変えられます。そのため、getimagesize() でファイルの中身から画像の種類(MIMEタイプ)を調べ、GIF・JPEG・PNG のときだけ受け付けています。
upload.php では、まずすべての行をチェックしてから保存を始めます。途中の1枚がエラーのときに、前の数枚だけ保存されてしまうのを防ぐためです。
$texts = $_POST['text'] ?? []; // name="text[]" → 配列で届く
$files = $_FILES['img'] ?? []; // name="img[]" → 配列で届く
// ---- 1. チェック ---------------------------------------------
if (!is_array($texts) || count($texts) === 0) {
show_error_page(['送信内容がありません。'], 'index.php');
}
if (count($texts) > MAX_ROWS) {
show_error_page(['一度に送信できるのは' . MAX_ROWS . '行までです。'], 'index.php');
}
$errors = [];
foreach (array_keys($texts) as $i) {
$error = check_image($files, $i);
if ($error !== null) {
$errors[] = $error;
}
}
if ($errors) {
show_error_page($errors, 'index.php'); // 1枚でも問題があれば何も保存しない
}
5. 画像を保存する(ファイル名と日付フォルダ)
保存するときは、送られてきたファイル名を使わず、sha1(random_bytes(16)) で作ったランダムな40文字を名前にします。同じ名前のファイルが上書きされたり、日本語や記号の入ったファイル名でトラブルが起きたりするのを防げます。
/**
* アップロードされた画像を保存し、中サイズとサムネイルも作る
* @return string 保存したファイル名(ランダムな40文字+拡張子)
*/
function save_image(string $tmp, string $now): string
{
$info = getimagesize($tmp);
$name = sha1(random_bytes(16)) . image_ext($info['mime']); // 他と重ならない名前
$dir = date_dir($now);
// 日付ごとのフォルダを作る(例: img_full/2026/10/27)
foreach (['img_full', 'img_med', 'img_thu'] as $d) {
if (!is_dir(__DIR__ . "/{$d}/{$dir}")) {
mkdir(__DIR__ . "/{$d}/{$dir}", 0755, true);
}
}
$full = __DIR__ . "/img_full/{$dir}/f{$name}";
if (!move_uploaded_file($tmp, $full)) {
throw new RuntimeException('画像を保存できませんでした。');
}
resize_image($full, $info, MIDDLE_WIDTH, __DIR__ . "/img_med/{$dir}/m{$name}");
resize_image($full, $info, THUMBNAIL_WIDTH, __DIR__ . "/img_thu/{$dir}/t{$name}");
return $name;
}
- 保存先は
img_full/2026/10/27/のような日付ごとのフォルダです。1つのフォルダにファイルが増えすぎるのを防ぎます。mkdir()の3つ目の引数trueで、途中のフォルダもまとめて作れます。 - アップロードされたファイルは、いったんサーバーの一時フォルダに置かれます。
move_uploaded_file()で正式な保存先へ移動します。 - サイズごとに先頭の1文字を変えています(原寸
f、中m、サムネイルt)。
6. GD で画像を縮小する
一覧画面で原寸の大きな画像を並べると表示が重くなるので、横幅200pxの中サイズと100pxのサムネイルを作っておきます。
/** 画像を指定の横幅に縮小して保存する(GD を使用) */
function resize_image(string $src, array $info, int $width, string $dest): void
{
[$w, $h] = $info; // 元の幅と高さ
if ($w <= $width) {
copy($src, $dest); // もともと小さい画像はそのままコピー
return;
}
$height = (int)round($h * $width / $w); // 縦横の比率を保った高さ
$srcImage = match ($info['mime']) {
'image/gif' => imagecreatefromgif($src),
'image/jpeg' => imagecreatefromjpeg($src),
'image/png' => imagecreatefrompng($src),
};
$newImage = imagecreatetruecolor($width, $height); // 空の画像を作る
imagealphablending($newImage, false); // PNG の透明部分を保つ
imagesavealpha($newImage, true);
imagecopyresampled($newImage, $srcImage, 0, 0, 0, 0, $width, $height, $w, $h);
match ($info['mime']) {
'image/gif' => imagegif($newImage, $dest),
'image/jpeg' => imagejpeg($newImage, $dest, 85),
'image/png' => imagepng($newImage, $dest),
};
}
- 縦横の比率が変わらないように、新しい高さを
元の高さ × 新しい幅 ÷ 元の幅で計算します。 imagecreatefromjpeg()などで元の画像を読み込み、imagecreatetruecolor()で新しいサイズの空の画像を作ります。imagecopyresampled()で、元の画像を縮めながら空の画像に描き写します。imagejpeg()などで、ファイルとして保存します(JPEG の85は画質)。
match は PHP 8 から使える書き方で、switch を短く書けます。画像の種類ごとに使う関数を切り替えています。
7. データベースに登録する(PDO のプリペアドステートメント)
// ---- 2・3. 保存と登録 ----------------------------------------
$dbh = db_connect();
$now = date('Y-m-d H:i:s'); // 画像フォルダの日付と、データベースの日付を同じにする
try {
$sql = 'INSERT INTO ' . TABLE . ' (user_id, post_id, text, img, date)
VALUES (:user_id, :post_id, :text, :img, :date)';
$stmt = $dbh->prepare($sql);
foreach ($texts as $i => $text) {
$text = mb_substr(trim((string)$text), 0, 100);
$img = '';
if (($files['error'][$i] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK) {
$img = save_image($files['tmp_name'][$i], $now);
}
if ($text === '' && $img === '') {
continue; // 何も入力されていない行は登録しない
}
$stmt->execute([
':user_id' => USER_ID,
':post_id' => POST_ID,
':text' => $text,
':img' => $img,
':date' => $now,
]);
}
// 公開デモなので、古いデータは自動で削除(最新 DEMO_MAX_ROWS 件だけ残す)
demo_prune_images($dbh, TABLE, __DIR__);
} catch (Throwable $e) {
show_error_page(['保存中にエラーが発生しました。時間をおいて、もう一度お試しください。'], 'index.php');
}
// ---- 4. 表示画面へ -------------------------------------------
header('Location: view.php');
exit;
SQL の中の :text や :img は「あとで値を入れる場所(プレースホルダ)」です。prepare() で SQL を準備し、execute() に値を渡します。値は PDO が安全な形に変換してくれるので、入力に ' などの記号が含まれていても SQL が壊れません(SQLインジェクション対策)。
$now を1回だけ作り、画像を置くフォルダの日付とデータベースの date の両方に使っています。画像の場所はあとで date から組み立てるので、2つが必ず一致するようにしているのです。
8. 一覧を表示する(view.php)
データベースから取り出した行を foreach で並べます。画像の URL は common.php の image_url() で、日付とファイル名から組み立てます。
<div class="gallery">
<?php foreach ($data as $row): ?>
<figure>
<?php if ($row['img'] !== ''): ?>
<a href="<?= h(image_url($row, 'full')) ?>" target="_blank"><img src="<?= h(image_url($row, 'med')) ?>" alt=""></a>
<?php else: ?>
<div class="noimg">画像なし</div>
<?php endif; ?>
<figcaption><?= h($row['text']) ?></figcaption>
</figure>
データベースの中身を画面に出すときは、必ず h()(htmlspecialchars())を通します。入力に <script> などが含まれていても、ただの文字として表示されます(XSS対策)。
9. 編集画面と、既存の行・新しい行の見分け方
編集画面では、各行に name="ID[]" の隠し項目で行の ID を入れておきます。「行を追加」で増やした行は ID を空にしておきます。
<?php foreach ($data as $row): ?>
<tr data-id="<?= h($row['ID']) ?>">
<td>
<input type="text" name="text[]" value="<?= h($row['text']) ?>" maxlength="100">
<input type="hidden" name="ID[]" value="<?= h($row['ID']) ?>">
</td>
<td>
<div class="preview"></div>
<?php if ($row['img'] !== ''): ?>
<div class="current">
<a href="<?= h(image_url($row, 'full')) ?>" target="_blank"><img src="<?= h(image_url($row, 'med')) ?>" alt=""></a>
<button type="button" class="del-img">画像を削除</button>
</div>
<?php endif; ?>
<input type="file" name="img[]" accept="image/*"<?= $row['img'] !== '' ? ' style="display:none"' : '' ?>>
</td>
画像がある行では、ファイル選択欄を display:none で隠しているだけで、消してはいません。どの行にも text・ID・img が1つずつあるので、送信したときに3つの配列の番号($i)がずれないのです。
update.php では、ID があれば UPDATE、空なら INSERT、と分けて処理します。
try {
foreach ($texts as $i => $text) {
$text = mb_substr(trim((string)$text), 0, 100);
$id = (int)($ids[$i] ?? 0);
$hasFile = ($files['error'][$i] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK;
if ($id > 0) {
// ===== 既存の行 =====
$row = find_row($dbh, $id);
if ($row === null) {
continue; // 別の人が先に削除していた
}
$stmt = $dbh->prepare('UPDATE ' . TABLE . ' SET text = :text WHERE ID = :id');
$stmt->execute([':text' => $text, ':id' => $id]);
if ($hasFile) {
// 新しい画像を保存し、古い画像ファイルは消す
$img = save_image($files['tmp_name'][$i], $now);
delete_image_files($row);
// 画像の置き場所は日付で決まるので、date も今日の日付に更新する
$stmt = $dbh->prepare('UPDATE ' . TABLE . ' SET img = :img, date = :date WHERE ID = :id');
$stmt->execute([':img' => $img, ':date' => $now, ':id' => $id]);
}
} else {
// ===== 新しく追加した行 =====
$img = $hasFile ? save_image($files['tmp_name'][$i], $now) : '';
if ($text === '' && $img === '') {
continue;
}
$stmt = $dbh->prepare('INSERT INTO ' . TABLE . ' (user_id, post_id, text, img, date)
VALUES (:user_id, :post_id, :text, :img, :date)');
$stmt->execute([
':user_id' => USER_ID,
':post_id' => POST_ID,
':text' => $text,
':img' => $img,
':date' => $now,
]);
}
}
画像を差し替えたときは、古い画像ファイルを削除し、date も今日の日付に更新しています。画像の保存フォルダは日付で決まるので、こうしないと新しい画像の場所がわからなくなります。
10. ajax で画像・行を削除する
「画像を削除」「行を削除」ボタンは、ページを移動せずに $.post() で削除用の PHP を呼びます。送るのは行の ID だけです。
// 画像を削除(ajax)。送るのは行の ID だけ
$(document).on('click', '.del-img', function () {
var $tr = $(this).closest('tr');
$.post('del_img.php', { ID: $tr.data('id') }).done(function () {
$tr.find('.current').remove(); // 表示中の画像を消す
$tr.find('input[type=file]').show(); // 新しい画像を選べるようにする
});
});
// 行を削除(ajax)
$(document).on('click', '.del-line', function () {
var $tr = $(this).closest('tr');
if (!$tr.data('id')) { // まだ保存していない行は、画面から消すだけ
$tr.remove(); added--; return;
}
$.post('del_line.php', { ID: $tr.data('id') }).done(function () {
$tr.remove();
});
});
header('Content-Type: application/json; charset=utf-8');
$id = (int)($_POST['ID'] ?? 0);
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || $id <= 0) {
http_response_code(400);
echo json_encode(['ok' => false]);
exit;
}
$dbh = db_connect();
$row = find_row($dbh, $id);
if ($row !== null) {
delete_image_files($row); // 画像ファイル(3サイズ)を削除
$stmt = $dbh->prepare('UPDATE ' . TABLE . " SET img = '' WHERE ID = :id");
$stmt->execute([':id' => $id]);
}
echo json_encode(['ok' => true]);
以前のバージョンでは、ブラウザから「年/月/日/ファイル名」を送り、そのまま unlink() していました。これだと ../ を混ぜた値を送られると、サーバー上の別のファイルまで消せてしまいます。今のバージョンでは ID だけを受け取り、消すファイルの場所はサーバー側でデータベースから調べるので、外から場所を指定できません。
「全て削除する」は、リンクではなく POST で送るフォームにしています。GET のリンクだと、検索エンジンのロボットがリンクをたどっただけでデータが消えてしまうからです。
// 削除のような「データを変える処理」は POST のときだけ行う
// (GET のリンクだと、検索エンジンのロボットが巡回しただけで消えてしまうため)
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$dbh = db_connect();
foreach (all_rows($dbh) as $row) {
delete_image_files($row);
}
$stmt = $dbh->prepare('DELETE FROM ' . TABLE . ' WHERE user_id = :user_id AND post_id = :post_id');
$stmt->execute([':user_id' => USER_ID, ':post_id' => POST_ID]);
}
// 戻り先は決まった2つのどちらかだけ(外部のURLに飛ばされないように)
$back = ($_POST['back'] ?? '') === 'edit' ? 'edit.php' : 'index.php';
header('Location: ' . $back);
exit;
PHP 8.3 で書き直したポイント
- 定数名のタイプミスで止まっていた … 旧版には
MIDEEL_WIDTH(正しくは MIDDLE_WIDTH)という誤字がありました。PHP 7 までは警告だけで動きましたが、PHP 8 では存在しない定数がエラーになり、画像のアップロードがすべて失敗していました。 - PDO が例外を投げるようになった … PHP 8 から PDO の初期設定が「エラー時は例外」に変わりました。旧版の更新処理には「画像のない既存の行を、毎回新しい行として INSERT しようとする」不具合があり、以前は黙って失敗していたものが、エラーで止まるようになっていました。ID の有無で UPDATE と INSERT を分けるように直しています。
count()の扱いが厳しくなった … 配列でない値をcount()に渡すとエラーになるため、$_POST['text'] ?? []のように必ず配列にしてから数えています。- 小さい画像の中サイズが作られていなかった … 元の画像が200pxより小さいと中サイズが作られず、一覧で画像が表示されませんでした。小さい画像はそのままコピーするようにしています。
- 削除処理の安全対策 … 上で説明したとおり、ファイルの場所をブラウザから受け取らないようにしました。
common.php の MIDDLE_WIDTH を 300 に変えたり、imagejpeg() の画質 85 を 30 にしたりして、保存される画像がどう変わるか確かめてみましょう。