WordPress.org に公開していない自作プラグインも、専用の 更新サーバー を立てれば「ダッシュボードからワンクリック更新」が可能になります。
ここでは、ライセンス認証を含まない更新配信サーバーとクライアントの参考コードを紹介します。ZIPの中身を誰にでも公開してよい場合を想定した構成です。社内限定・顧客限定の配布には、このままでは使えません。
本文にはソースコードを掲載していますが、完成済みZIPのダウンロードリンクはありません。後述のファイル構成で組み立て、既知の制約を修正してから検証環境で試してください。
なぜ自前の更新サーバーが必要なのか
自作プラグインを WordPress.org に公開すれば、更新は WordPress 側が面倒を見てくれます。ですが、
- WordPress.org には公開したくないけど、自分のサイト群には配布したい
- クライアント案件で、複数のサイトに同じプラグインを入れて一括更新したい
- 内部ツール的なプラグインを、社内の複数サイトで使いたい
といったケースでは、WordPress.org以外の配信元を用意する方法があります。自前サーバーはその選択肢の一つです。アクセスを限定する場合は、配信APIと保存ファイルの両方を保護します。
WordPress には pre_set_site_transient_update_plugins などのフィルタが用意されていて、ここに「最新バージョン情報」と「ZIP のダウンロード URL」を差し込むだけで、ダッシュボードに更新通知を出せます。
つまり配信側は、
- 「最新バージョンはいくつ?」に答える API
- 「ZIP をダウンロードさせる API」
の2つを REST API で提供すればいい、というのが基本設計です。
システム全体の流れ
ざっくりと処理の流れを書くと、こんな構成になります。
[クライアントサイト(プラグイン利用者)]
│
│ ① 更新チェック(slug + 現在バージョン)
▼
[更新サーバー(本プラグイン)]
│
│ ② 最新リリース情報 + ZIPダウンロードURL を返す
▼
[クライアントサイト]
│
│ ③ ダウンロードURLにアクセスしてZIP取得
▼
[更新サーバー]
│
│ ④ 登録されたZIPをそのまま配信
▼
[クライアントサイトが ZIP を解凍してプラグイン更新]ポイントは 「バージョン情報の取得」と「ZIP のダウンロード」を分離 していることです。
WordPress 側の更新フローは、まず「新しいバージョンあるよ」という情報を取得してから、ユーザーが「更新」ボタンを押した時に改めて ZIP をダウンロードする、という2段階になっています。
サーバー側もこの構造に合わせて2つのエンドポイントを用意します。
プラグインのファイル構成
実際のファイル構成はこうなっています。
your-update-server/
├── your-update-server.php # メインファイル(定数・require・有効化フック)
└── inc/
├── db.php # テーブル定義 + ヘルパー関数
├── rest.php # REST APIルート(update / download)
└── admin.php # 管理画面(リリース登録・一覧)
掲載するのは配信の流れを学ぶための最小例です。ZIPの検査、DB処理の失敗判定、バージョン固定のダウンロード、更新応答の検証などを備えた本番用の完成版ではありません。
データベース設計
テーブルは1つだけ。wp_yus_releases というシンプルな構成です(wp_の部分はインストールごとに変わるので、実装では$wpdb->prefixから組み立てます)。
| カラム | 用途 |
|---|---|
product_slug | プラグインのスラッグ(例: my-plugin) |
version | バージョン番号(例: 0.1.3) |
zip_path | サーバー上のZIPファイルパス |
changelog | 更新履歴 |
requires_wp / tested_wp / requires_php | 動作要件 |
homepage_url | ホームページURL |
is_published | 公開フラグ |
product_slugとversionの組み合わせは一意ですが、同じ組み合わせを再登録すると既存のZIPとレコードを上書きします。重複行を防ぐ制約であり、同一版の差し替えを拒否する仕組みではありません。配布済みのZIPは原則として変更せず、変更時は版を上げます。
複数のプラグインはproduct_slugで区別します。ただし掲載コードの「最新」は、公開中の行をIDの降順で選んだものです。バージョン比較で最大の版を探す処理ではないため、古い版を後から登録すると配信対象が変わります。登録順を管理するか、版の選択処理を改修してください。
REST APIの設計
inc/rest.php で2つのエンドポイントを登録しています。
1. 更新チェック API: GET /wp-json/your-update/v1/update
クライアント側の更新フィルタから呼ぶエンドポイントです。サーバーだけを導入しても、各サイトへの更新通知や一括更新は実行されません。
受け取るパラメータ
| パラメータ | 内容 |
|---|---|
slug | プラグインスラッグ(必須) |
version | 現在のバージョン |
action | plugin_information の場合は詳細情報を返す |
処理の流れ
function yus_rest_update(\WP_REST_Request $request): \WP_REST_Response
{
$action = (string) $request->get_param('action');
$product_slug = sanitize_key((string) $request->get_param('slug'));
$current_version = (string) $request->get_param('version');
// 1. パラメータチェック
if ($product_slug === '') {
return new \WP_REST_Response(['ok' => false, 'message' => 'Missing slug parameter.'], 400);
}
// 2. 最新リリース取得
$release = yus_get_latest_release($product_slug);
if (! $release) {
return new \WP_REST_Response(['ok' => false, 'message' => 'No published release found.'], 404);
}
// 3. ダウンロードURLを組み立て
$package = add_query_arg([
'rest_route' => '/your-update/v1/download',
'slug' => $product_slug,
], untrailingslashit(home_url('/')) . '/index.php');
// 4. plugin_information アクションの場合は詳細情報を返す
if ($action === 'plugin_information') {
// ...詳細情報のレスポンス
}
// 5. 同じ版以上なら空配列を返す(JSONでは [])
if ($current_version !== '' && version_compare($release['version'], $current_version, '<=')) {
return new \WP_REST_Response([], 200);
}
// 6. 更新情報を返す
return new \WP_REST_Response([
'new_version' => $release['version'],
'package' => $package,
'tested' => $release['tested_wp'],
'requires' => $release['requires_wp'],
'homepage' => $release['homepage_url'] ?: home_url('/'),
], 200);
}
plugin_information アクション
プラグイン詳細画面(「詳細を表示」リンク)で呼ばれるアクションです。
if ($action === 'plugin_information') {
return new \WP_REST_Response([
'name' => $product_slug,
'slug' => $product_slug,
'version' => $release['version'],
'requires' => $release['requires_wp'],
'tested' => $release['tested_wp'],
'requires_php' => $release['requires_php'],
'last_updated' => $release['updated_at'],
'download_link' => $package,
'package' => $package,
'sections' => [
'description' => '<p>' . esc_html($product_slug) . '</p>',
'changelog' => '<pre>' . esc_html($release['changelog']) . '</pre>',
],
], 200);
}
クライアントのplugins_apiフィルタがこの応答を受け取り、プラグイン詳細画面へ渡します。掲載例はHTTP状態とJSONの内容の検証が不足しているため、失敗時は元の結果を返す処理を追加してください。
2. ダウンロード API: GET /wp-json/your-update/v1/download
function yus_rest_download(\WP_REST_Request $request)
{
$product_slug = sanitize_key((string) $request->get_param('slug'));
if ($product_slug === '') {
return new \WP_REST_Response('Missing slug.', 400);
}
$release = yus_get_latest_release($product_slug);
if (! $release) {
return new \WP_REST_Response('No published release found.', 404);
}
$zip_path = $release['zip_path'];
if ($zip_path === '' || ! file_exists($zip_path)) {
return new \WP_REST_Response('Package file not found.', 404);
}
// ZIPをそのまま配信
nocache_headers();
header('Content-Type: application/zip');
header('Content-Length: ' . filesize($zip_path));
header('Content-Disposition: attachment; filename="' . basename($zip_path) . '"');
readfile($zip_path);
exit;
}
readfile()でZIPを出力して終了します。ただし、既に出たPHP警告・BOM・他プラグインの出力などは除去できません。ダウンロードしたZIPのサイズと展開結果を確認します。またURLに版番号がなく、取得時点の最新行を配信するため、更新情報の取得後に別版を登録すると通知とZIPが一致しない場合があります。リリースIDや版を固定する設計が必要です。
ルート登録と権限の指定
2つのルートは、どちらもログインしていないクライアントサイトから呼ばれるので公開エンドポイントです。WordPress 5.5以降はpermission_callbackを省略すると_doing_it_wrongの通知が出るため、公開でよい場合も__return_trueを明示します。
add_action('rest_api_init', function () {
register_rest_route('your-update/v1', '/update', [
'methods' => 'GET',
'callback' => 'yus_rest_update',
'permission_callback' => '__return_true',
]);
register_rest_route('your-update/v1', '/download', [
'methods' => 'GET',
'callback' => 'yus_rest_download',
'permission_callback' => '__return_true',
]);
});逆に、管理画面のリリース登録フォームは権限と改ざん対策が必要です。フォームにwp_nonce_field('yus_save_release')を置き、受け取り側でcheck_admin_referer('yus_save_release')とcurrent_user_can('manage_options')を通してから保存します。ここを省くと、ログイン中の管理者に細工したページを踏ませるだけで、配信ZIPを差し替えられる状態になります。
管理画面の作り方
inc/admin.php で「Update Server」というメニューを追加して、ZIPアップロードフォームとリリース一覧を表示しています。
リリース登録フォームでは以下を入力します。
- Product Slug(プラグインのフォルダ名と一致させる)
- Version(
0.1.3のような数字+ドット形式) - ZIPファイル
- Requires WP / Tested WP / Requires PHP(配布プラグインの動作要件・検証済み版。配信サーバーの版をそのまま使わない)
- Changelog(更新履歴)
- 公開フラグ
バージョン文字列のバリデーション
ここはハマりやすいので強調しておきます。
if (! preg_match('/^\d+(?:\.\d+)*(?:-[0-9A-Za-z.-]+)?$/', $version)) {
yus_set_notice('error', 'Version must look like 0.1.3 or 1.0.0-beta.');
...
}
0.1.3 や 1.0.0-beta は通すけど、v0.1.3 や 0.1.3 (release) は弾く、という正規表現です。
version_compare()はPHPのバージョン比較関数です。SemVerのすべての規則を保証するものではないため、許可する書式と比較順を決めて検証します。プラグインヘッダー、クライアントの定数、登録する版番号も一致させてください。
ZIPファイルの保存先
function yus_upload_dir(): array
{
$uploads = wp_upload_dir();
$base_dir = trailingslashit($uploads['basedir']) . 'yus-releases';
wp_mkdir_p($base_dir);
...
}
wp-content/uploads/yus-releases/ に {slug}-{version}.zip というファイル名で保存します。
直接URLから取得させたくない場合は、Webから参照できない場所へ保存するか、Webサーバー側で対象ディレクトリへのアクセスを拒否します。uploadsの外へ移すだけでは、移動先がWeb公開領域なら保護になりません。
ただしwp-content/uploads/配下はWebサーバーがそのまま配信するため、https://example.com/wp-content/uploads/yus-releases/{slug}-{version}.zip というURLは推測できます。slugとversionは、この更新チェックAPI自体が誰にでも返す値です。
直接ファイルへのアクセスを遮断しても、公開のダウンロードAPIからは取得できます。非公開配布には、保存場所の保護に加えてAPI側の認証・認可が必要です。またis_publishedを外しても、uploads内のZIPへ直接アクセスできる設定ならファイルは取得できます。
クライアントプラグイン側の実装
ここまでがサーバー側。ここからは、実際の利用者にインストールされるほうのプラグイン で何をやるかです。
次は更新トランジェントとプラグイン詳細APIへ応答を渡す参考コードです。HTTP状態・JSONの型・必須項目・ダウンロード先HTTPSとホストを検証し、通信失敗時は正常な既存情報を壊さない処理を追加してから使用します。現状はrequires_phpも更新通知へ渡していません。
<?php
/**
* Plugin Name: My Plugin
* Version: 0.1.0
*/
define('MY_PLUGIN_VERSION', '0.1.0');
define('MY_PLUGIN_SLUG', 'my-plugin');
define('MY_PLUGIN_FILE', plugin_basename(__FILE__));
define('MY_PLUGIN_UPDATE_API', 'https://example.com/wp-json/your-update/v1/update');
// 更新チェック
add_filter('pre_set_site_transient_update_plugins', function ($transient) {
if (! is_object($transient)) {
return $transient;
}
$response = wp_remote_get(add_query_arg([
'slug' => MY_PLUGIN_SLUG,
'version' => MY_PLUGIN_VERSION,
], MY_PLUGIN_UPDATE_API));
if (is_wp_error($response)) {
return $transient;
}
$body = json_decode(wp_remote_retrieve_body($response), true);
if (! empty($body['new_version']) && version_compare($body['new_version'], MY_PLUGIN_VERSION, '>')) {
$transient->response[MY_PLUGIN_FILE] = (object) [
'slug' => MY_PLUGIN_SLUG,
'plugin' => MY_PLUGIN_FILE,
'new_version' => $body['new_version'],
'package' => $body['package'],
'tested' => $body['tested'] ?? '',
'requires' => $body['requires'] ?? '',
];
}
return $transient;
});
// プラグイン詳細画面用
add_filter('plugins_api', function ($result, $action, $args) {
if ($action !== 'plugin_information' || ($args->slug ?? '') !== MY_PLUGIN_SLUG) {
return $result;
}
$response = wp_remote_get(add_query_arg([
'slug' => MY_PLUGIN_SLUG,
'action' => 'plugin_information',
], MY_PLUGIN_UPDATE_API));
if (is_wp_error($response)) {
return $result;
}
$body = json_decode(wp_remote_retrieve_body($response), true);
return (object) $body;
}, 10, 3);更新通知を出すには、API URL、スラッグ、版、メインファイルのパスが一致している必要があります。ZIPは元と同じプラグインフォルダーとメインファイルを含む構造にします。掲載コードを貼り付けただけで、あらゆるプラグインの更新が成功するわけではありません。
MY_PLUGIN_UPDATE_APIは配信元の正しいREST URLに置き換えます。また、WordPress.orgの同名プラグインによる上書きを避けるため、自作プラグイン固有のUpdate URIヘッダーを設定します。ヘッダーを追加するだけで独自配信処理が実装されるわけではありません。公式:プラグインヘッダー
使い方の流れ
次は検証環境で組み立てる手順です。本番へ配布する前に、旧版のZIP・DB・設定を保存して復旧手順を用意します。
サーバー側(更新を配信するサイト)
- 掲載コードを指定の4ファイルに保存し、検証用WordPressへ設置して有効化
- ダッシュボード左メニュー「Update Server」を開く
- 配信したいプラグインのZIPファイルをアップロード
- Product Slug: プラグインのフォルダ名(例:
my-plugin) - Version:
0.1.3のような形式 - 配布対象で実測した動作要件・Changelogを入力。フォームの初期値は検証結果ではない
- Product Slug: プラグインのフォルダ名(例:
- 「Save Release」で保存し、DB登録・ZIPの存在・公開状態を確認。掲載コードはDB失敗でも成功通知を出すため、通知だけで完了と判断しない
クライアント側(プラグインを使うサイト)
- 自作プラグインのメインファイルに、上記のクライアント側コードを追加
MY_PLUGIN_UPDATE_APIをサーバー側のURLに書き換える- プラグインをインストール
次回の更新チェックで新しい版の情報を取得できれば、更新通知の対象になります。全サイトの即時更新や自動更新の有効化を行うコードではありません。
動作確認
更新チェックの間隔は実行箇所で変わり、一律12時間ではありません。WP-CLIでは対象サイトを確認したうえで、次の例で更新情報を消去し、利用可能な更新を一覧表示できます。コマンド自体はプラグインを更新しません。公式:更新チェック処理、公式:wp plugin list
wp transient delete update_plugins --network
wp plugin list --update=available --fields=name,version,update_version管理画面の「ダッシュボード → 更新」でも更新確認を行えます。失敗時はAPIのHTTP応答と通信ログを調べます。掲載フィルタは独自キャッシュを持たず、トランジェント保存のたびに外部へ問い合わせるため、呼び出し頻度とタイムアウトも検証してください。
まとめ
ここまでの内容をまとめると、自前の更新サーバーは以下の要素で成り立っています。
- 2つのREST API:バージョン情報を返すAPIと、ZIPを配信するAPI
- 1つのテーブル:リリース管理(
wp_yus_releases) - 管理画面:ZIPアップロードでリリースを登録、一覧から削除も可能
- クライアント側のフィルタ2つ:
pre_set_site_transient_update_pluginsとplugins_api
複数サイトで共通プラグインを配信する基礎として使えますが、運用では管理者権限の保護、配布物の整合性、失敗時の復旧を別途設計します。
更新後に障害が起きたときは、旧版を配信サーバーへ再登録するだけでは戻りません。クライアントは新しい版だけを更新対象にするため、保存しておいた正常版を手動で復元し、必要に応じてDBも復旧します。
確認した環境と範囲
2026年10月23日に本文と全掲載コードを読み、WordPressとWP-CLIの公式資料に照合しました。公式のwp plugin check-updateのページには安定版未収録の注記があるため、本記事の例はwp plugin listへ変更しました。公式:check-updateの提供条件
2サイト間の更新通知・ZIP取得・更新後の版表示、管理画面、同時登録、通信失敗、PHP/WP別の実行は未検証です。掲載サーバー・クライアントの本体には既知の未対応箇所が残ります。実行可能な完成版として公開前検証に合格したものではありません。
この最小構成にライセンス認証を足す場合はWordPress│自作プラグインにGumroadライセンス認証を実装する方法、REST APIの基本から確認したい場合はWordPress REST APIとは?できること・エンドポイント一覧・構築方法をご覧ください。 リンク先のGumroad例にも未実装部分があり、組み合わせるだけで非公開配布が完成するものではありません。
【ソースコード全公開】Your Update Server
以下は参考実装の全体です。ZIPの拡張子指定はブラウザーの入力補助にとどまり、保存時の内容検査はありません。ZIP構造・容量・プラグイン識別子の検証、ファイルの原子的な保存、DBエラー時の復旧を追加してください。管理画面のAPI表示もURLを文字列連結しており、基本パーマリンクで正しいURLにならないため、操作別のrest_urlとadd_query_argで組み立てます。
この記事だけ読みたい方は単品購入、ほかの有料記事も読みたい方は購読プランをご確認ください。
購読対象の記事・料金は、下のプラン案内で確認できます。
役に立った記事を、ブログやSNSで紹介しませんか?
紹介制度の対象記事・購読プランを購入した方は、codocで専用リンクと報酬条件を確認できます。
専用リンク経由で購入されると、設定された紹介報酬が発生します。
購読プランの紹介報酬は初回課金のみが対象です。
紹介時は、広告・PRであることが読者に分かるように明記してください。



