不要になった投稿メタのクリーンアップや、期限切れのtransientsの削除を行うPHPスクリプトが、すでにいくつも存在しているケースは少なくありません。時が経つとともにそれらは増え続け、テーマファイルやプラグインフォルダ、あるいは目立たないディレクトリの奥に散在していきます。Acornは、LaravelのArtisan ConsoleをWordPressに導入することで、こうした無秩序な状態を整理する手助けをします。

つまり、メンテナンスロジックを一元化された構造化クラスとして管理し、カスタムWP-CLIコマンドを構築できるようになります。これらのコマンドは、進捗インジケーターや整形されたテーブル出力、適切なエラーハンドリングを備え、開発・ステージング・本番環境で一貫して実行可能です。さらに、SSH経由で手動実行したり、cronでスケジュールしたり、デプロイ時に自動実行したりすることもできます。

Acornのインストールとコマンドの実行方法

まずは、必要な依存関係をインストールします。Acornを利用するには、PHP 8.2以上、依存関係管理ツールのComposer、そしてサーバー上で動作するWP-CLIが必要です。Kinstaでは、すべてのホスティングプランにWP-CLIが含まれているため、すぐにコマンドの作成を始められます。

次に、プロジェクトルートでcomposer require roots/acornコマンドを実行し、Composer経由でAcornをインストールします。インストール後、テーマの functions.php またはメインのプラグインファイルにブートストラップコードを追加します。

<?php
use RootsAcornApplication;
if (! class_exists(RootsAcornApplication::class)) {
    wp_die(
        __('You need to install Acorn to use this site.', 'domain'),
        '',
        [
            'link_url' => 'https://roots.io/acorn/docs/installation/',
            'link_text' => __('Acorn Docs: Installation', 'domain'),
        ]
    );
}

add_action('after_setup_theme', function () {
    Application::configure()
        ->withProviders([
            AppProvidersThemeServiceProvider::class,
        ])
        ->boot();
}, 0);

ゼロコンフィグセットアップでは、アプリケーションのキャッシュやログは WordPress のキャッシュディレクトリ[wp-content]/cache/acorn/)に保存され、コマンドはテーマ内のapp/ディレクトリに配置されます。

一方、従来型の構成ではLaravelの慣例に従い、プロジェクトルート直下に app/config/storage/resources/といった専用ディレクトリを用意します。この構成は、以下の1行で有効化できます。

wp acorn acorn:init storage && wp acorn vendor:publish --tag=acorn

wp acorn listを実行すると、利用可能なAcornコマンドが一覧表示され、インストールが正しく完了していることを確認できます。

この時点以降、作成したカスタムコマンドはすべてapp/Console/Commands/ディレクトリに配置します。Acornはこのディレクトリ内のコマンドクラスを自動的に検出し、WP-CLIへ登録します。

最初のArtisanコマンドの作成

wp acorn make:command CleanupCommandを実行すると、必要なひな形ファイルが自動生成されます。

生成されるArtisanコマンドには、次の3つの重要な要素が含まれています。

  • コマンド名やオプションを定義する $signature プロパティ

  • ヘルプテキストを指定する $description プロパティ

  • 実際の処理ロジックを記述する handle() メソッド

このコマンドを実行すると、app/Console/Commands/CleanupCommand.php に基本的なコマンド構造が作成されます。

<?php
namespace AppConsoleCommands;
use IlluminateConsoleCommand;
class CleanupCommand extends Command
{
    protected $signature = 'app:cleanup';
    protected $description = 'Command description';
    public function handle()
    {
        //
    }
}

$signatureプロパティでは、所定の構文に従ってコマンドを定義します。

  • 基本的なコマンドは、cleanup:run のように名前のみを指定

  • 必須引数は中括弧で囲んで追加(例:cleanup:run {days}

  • オプション引数にはクエスチョンマークを付ける(例:cleanup:run {days?}

  • オプションは二重ダッシュで定義(例:cleanup:run {--force} {--limit=100}

続いて、コマンドの説明と基本的な処理ロジックを実装します。

protected $signature = 'cleanup:test {--dry-run : Preview changes without executing}';
protected $description = 'Test cleanup command';
public function handle()
{
    $dryRun = $this->option('dry-run');

    if ($dryRun) {
        $this->components->info('Running in dry-run mode');
    }

    $this->components->info('Cleanup command executed');

    return 0;
}

wp acorn cleanup:test --dry-run を実行することで、コマンドの動作をテストできます。このコマンドは、Artisanのコンポーネントシステムを利用して整形されたメッセージを出力します。$this->components->info() は成功メッセージを緑色で表示します。そのほか、エラーメッセージには $this->components->error()、警告には $this->components->warn()、プレーンテキストの出力には $this->line() を使用できます。

3つの実践的なメンテナンスコマンドの作り方

ここでは、多くのWordPressサイトで発生するデータベース関連のメンテナンスタスクに対応するための実用的な例をご紹介します。

それぞれのコマンドにはエラーハンドリングを実装しており、WordPressのコーディング標準にも概ね準拠しています。ただし、そのままコピー&ペーストして使用するのではなく、自身のプロジェクトに合わせて調整するためのベースとして活用してください。

1. 紐付けのない投稿メタデータのクリーンアップ

投稿を削除しても、関連する投稿メタがデータベースに残ることがあります。これは、プラグインがWordPressの削除フックを正しく処理せず、すでに存在しない投稿を参照するメタデータが残ってしまう場合に発生します。こうした不要なデータが蓄積すると、データベースが肥大化し、クエリのパフォーマンス低下につながります。

まずは wp acorn make:command CleanupOrphanedMeta を実行してコマンドを生成します。その後、コマンドクラスにシグネチャと基本構造を定義します。

<?php
namespace AppConsoleCommands;
use IlluminateConsoleCommand;
class CleanupOrphanedMeta extends Command
{
    protected $signature = 'maintenance:cleanup-orphaned-meta 
                            {--dry-run : Preview orphans without deleting}';
    
    protected $description = 'Remove orphaned post metadata from wp_postmeta';
    public function handle()
    {
        global $wpdb;

        $dryRun = $this->option('dry-run');

        $this->components->info('Scanning for orphaned post metadata...');

このコマンドでは、LEFT JOINクエリを使用して、紐付けのないレコードを検出します。具体的には、postsテーブルと結合し、結合結果がNULLとなるレコードを抽出します。JOIN先がNULLの場合、そのメタデータは対応する投稿が存在しないことを意味します。

        // Find orphaned metadata
        $orphans = $wpdb->get_results("
            SELECT pm.meta_id, pm.post_id, pm.meta_key 
            FROM {$wpdb->postmeta} pm
            LEFT JOIN {$wpdb->posts} p ON pm.post_id = p.ID
            WHERE p.ID IS NULL
            LIMIT 1000
        ");

        if (empty($orphans)) {
            $this->components->info('No orphaned metadata found');
            return 0;
        }

コマンドは、紐付けのないレコードを検出すると、その件数を表示します。ドライランモードでは、削除対象となるレコードの一部をサンプルとして出力します。実際に削除を実行する前に、どのデータが対象になっているかを必ず確認してください。

        $count = count($orphans);
        $this->components->warn("Found {$count} orphaned metadata records");
        if ($dryRun) {
            $this->newLine();
            $this->line('Sample orphaned records:');

            foreach (array_slice($orphans, 0, 5) as $orphan) {
                $this->line("  → Post ID {$orphan->post_id}: {$orphan->meta_key}");
            }

            return 0;
        }

実際に削除を行う際は、SQLインジェクションを防ぐために $wpdb->prepare() を使用します。また、このコマンドは1回につき1,000件ずつ処理する設計になっているため、数百万件規模のメタデータを持つサイトでもメモリ使用量の増大を防ぐことができます。

        // Delete orphaned records
        $metaIds = array_map(function($orphan) {
            return $orphan->meta_id;
        }, $orphans);

        $placeholders = implode(',', array_fill(0, count($metaIds), '%d'));

        $deleted = $wpdb->query(
            $wpdb->prepare(
                "DELETE FROM {$wpdb->postmeta} WHERE meta_id IN ({$placeholders})",
                ...$metaIds
            )
        );

        if ($deleted === false) {
            $this->components->error('Failed to delete orphaned metadata');
            return 1;
        }

        $this->components->info("Deleted {$deleted} orphaned metadata records");
        return 0;
    }
}

以下のコマンドを実行して動作を確認します。

wp acorn maintenance:cleanup-orphaned-meta --dry-run

2. 期限切れtransientsの削除

WordPressでは、有効期限付きのtransientwp_options テーブルに保存されます。通常は日次のcronジョブによって自動的にクリーンアップされますが、メンテナンス作業時やtransientの肥大化が問題になった場合には、手動で削除したいケースもあります。

まずは wp acorn make:command CleanupTransients を実行してコマンドを生成します。その後、コマンドの基本構造を定義します。

<?php
namespace AppConsoleCommands;
use IlluminateConsoleCommand;
class CleanupTransients extends Command
{
    protected $signature = 'maintenance:cleanup-transients';
    protected $description = 'Delete expired transients from wp_options';
    public function handle()
    {
        global $wpdb;

        $this->components->info('Deleting expired transients...');

この削除クエリでは、マルチテーブルDELETE構文を使用し、transient本体と対応するタイムアウトオプションを同時に削除します。具体的には、有効期限のタイムスタンプを過ぎたタイムアウトレコードを対象に抽出します。

        // Delete expired regular transients
        $deleted = $wpdb->query(
            $wpdb->prepare(
                "DELETE a, b FROM {$wpdb->options} a, {$wpdb->options} b
                WHERE a.option_name LIKE %s
                AND a.option_name NOT LIKE %s
                AND b.option_name = CONCAT('_transient_timeout_', SUBSTRING(a.option_name, 12))
                AND b.option_value < %d",
                $wpdb->esc_like('_transient_') . '%',
                $wpdb->esc_like('_transient_timeout_') . '%',
                time()
            )
        );

あわせて、エラーの有無を確認し、削除件数も記録します。

        if ($deleted === false) {
            $this->components->error('Failed to delete transients');
            return 1;
        }

        $transientCount = $deleted;

マルチサイト環境では、このコマンドはサイトごとのtransientを対象に追加のクエリを実行します。使用するテーブル接頭辞は異なりますが、削除のロジック自体は同じパターンに基づいています。

        // Delete expired site transients (multisite)
        if (is_multisite()) {
            $siteDeleted = $wpdb->query(
                $wpdb->prepare(
                    "DELETE a, b FROM {$wpdb->options} a, {$wpdb->options} b
                    WHERE a.option_name LIKE %s
                    AND a.option_name NOT LIKE %s
                    AND b.option_name = CONCAT('_site_transient_timeout_', SUBSTRING(a.option_name, 17))
                    AND b.option_value < %d",
                    $wpdb->esc_like('_site_transient_') . '%',
                    $wpdb->esc_like('_site_transient_timeout_') . '%',
                    time()
                )
            );

            if ($siteDeleted !== false) {
                $transientCount += $siteDeleted;
            }
        }

        $this->components->info("Deleted {$transientCount} expired transients");

        return 0;
    }
}

以下のコマンドを実行して動作を確認します。

wp acorn maintenance:cleanup-transients

3. オートロードオプションの監査

オートロードオプションは、WordPressのすべてのリクエストで読み込まれます。このデータ量が1MBを超えると、パフォーマンスの低下やメモリ使用量の増加につながる可能性があります。

このコマンドでは、サイズの大きいオートロードオプションを特定し、肥大化の原因となっているプラグインの特定を支援します。

まずは wp acorn make:command AuditAutoload を実行して監査コマンドを生成します。続いて、しきい値を指定できるようにコマンドシグネチャを定義します。

<?php
namespace AppConsoleCommands;
use IlluminateConsoleCommand;
class AuditAutoload extends Command
{
    protected $signature = 'maintenance:audit-autoload 
                            {--threshold=1000000 : Size threshold in bytes}';

    protected $description = 'Audit autoloaded options size';
    public function handle()
    {
        global $wpdb;

        $threshold = (int) $this->option('threshold');

        $this->components->info('Calculating autoloaded options size...');

ここでは、オートロードされたオプションの合計サイズを算出します。

        // Get total autoload size
        $result = $wpdb->get_row(
            "SELECT 
                SUM(LENGTH(option_value)) as total_bytes,
                COUNT(*) as total_count
            FROM {$wpdb->options}
            WHERE autoload = 'yes'"
        );

        $totalBytes = (int) $result->total_bytes;
        $totalCount = (int) $result->total_count;
        $totalMb = round($totalBytes / 1024 / 1024, 2);

        $this->newLine();
        $this->line("Total autoloaded: {$totalMb} MB ({$totalCount} options)");

このコマンドでは、指定したしきい値を超えるオプションを抽出し、サイズ順に並べ替えたうえで、上位20件までを表示します。

        // Get largest autoloaded options
        $largeOptions = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT option_name, LENGTH(option_value) as size_bytes
                FROM {$wpdb->options}
                WHERE autoload = 'yes'
                AND LENGTH(option_value) > %d
                ORDER BY size_bytes DESC
                LIMIT 20",
                $threshold
            )
        );

        if (empty($largeOptions)) {
            $this->components->info('No options exceed the threshold');
            return 0;
        }

Artisanの $this->table() メソッドを使用すると、結果をASCII形式のテーブルとして整形できます。ターミナル上で表形式に整えられたデータは、生のクエリ結果をそのまま確認するよりも、はるかに読みやすくなります。

        $this->newLine();
        $this->components->warn('Large autoloaded options:');
        $this->newLine();

        $tableData = [];

        foreach ($largeOptions as $option) {
            $sizeKb = round($option->size_bytes / 1024, 2);
            $tableData[] = [
                $option->option_name,
                $sizeKb . ' KB'
            ];
        }

        $this->table(
            ['Option Name', 'Size'],
            $tableData
        );

このコマンドは、オートロードの合計が3MBを超えると警告を出します。

        if ($totalBytes > 3000000) {
            $this->newLine();
            $this->components->error('Warning: Total autoload exceeds 3MB');
        }

        return 0;
    }
}

監査を実行するには、wp acorn maintenance:audit-autoload --threshold=500000を実行します。

コマンド内でWordPressのデータにアクセスする方法

AcornはWordPressのライフサイクルの中で起動するため、コマンド内からWordPressの関数をそのまま利用できます。つまり、get_posts()get_option() といった関数を、特別な設定を行うことなく呼び出すことが可能です。

public function handle()
{
    $posts = get_posts([
        'post_type' => 'post',
        'post_status' => 'publish',
        'numberposts' => 10,
    ]);

    foreach ($posts as $post) {
        $this->line($post->post_title);
    }

    return 0;
}

データベースに直接アクセスする場合は、メソッドの冒頭で$wpdbをグローバル宣言します。

public function handle()
{
    global $wpdb;   

    $count = $wpdb->get_var(
        "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = 'publish'"
    );

    $this->line("Published posts: {$count}");

    return 0;
}

クエリに変数やユーザー入力を含む場合は、$wpdb->prepare()を使用するのがベストです。SQLインジェクションの防止に役立ちます。

$status = 'publish';
$postType = 'post';
$results = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT ID, post_title FROM {$wpdb->posts} 
        WHERE post_status = %s AND post_type = %s",
        $status,
        $postType
    )
);

データベース操作の後にfalseをチェックすることで、エラーを検出できます。

$updated = $wpdb->update(
    $wpdb->posts,
    ['post_status' => 'draft'],
    ['ID' => 123],
    ['%s'],
    ['%d']
);

if ($updated === false) {
    $this->components->error('Database update failed');
    return 1;
}

カスタム投稿タイプやタクソノミは、WordPressの標準関数で動作します。

$terms = get_terms([
    'taxonomy' => 'category',
    'hide_empty' => false,
]);

foreach ($terms as $term) {
    wp_update_term($term->term_id, 'category', [
        'description' => 'Updated via command',
    ]);
}

カスタムWP-CLIコマンドは、AcornとArtisan Consoleで簡単に作成可能

Acornを活用すれば、WordPressの関数やデータにフルアクセスしながら、Laravelの構造化されたコマンドクラス、整形された出力コンポーネント、堅牢なエラーハンドリングといった機能を利用できます。

Kinstaでは、SSHアクセスやcronによるスケジュール実行を通じて、これらのコマンドを運用環境に組み込むことが可能です。さらに、GitHub Actionsなどのワークフローに統合し、デプロイスクリプトの一部としてメンテナンス処理を自動化することも。

WordPressのメンテナンスタスクをカスタムWP-CLIコマンドで一元管理したい場合は、KinstaのWordPress専用マネージドクラウドサーバーをぜひお試しください。SSHアクセスとWP-CLIは、全プランでサポートされています。

Joel Olawanle Kinsta

Kinstaでテクニカルエディターとして働くフロントエンド開発者。オープンソースをこよなく愛する講師でもあり、JavaScriptとそのフレームワークを中心に200件以上の技術記事を執筆している。