If your WordPress workflow still depends on manual preview checks, you are probably shipping avoidable mistakes. Broken links, missing alt text, oversized images, weak meta descriptions, and inconsistent heading structures can quietly hurt SEO and user trust. The good news is that you can automate most of this before a post goes live.
In this practical guide, we will build a pre-publish content QA pipeline using a custom WP-CLI command. The command will scan draft or pending posts, report issues, and optionally fail your CI job if quality thresholds are not met. This is especially useful when multiple authors contribute and editorial standards need to stay consistent.
What we are building
- A custom WP-CLI command:
wp content:qa - Checks for: broken links, missing image alt text, heading hierarchy, thin content, and meta quality
- Structured JSON output for CI pipelines
- An optional “strict” mode that exits non-zero on critical failures
This approach keeps quality checks close to your WordPress codebase, version-controlled, and reproducible.
Project structure
Create a small plugin so the command is portable between environments.
wp-content/plugins/content-qa-cli/
├── content-qa-cli.php
└── src/
└── Command.php
Plugin bootstrap
<?php
/**
* Plugin Name: Content QA CLI
* Description: Pre-publish QA checks for WordPress content.
* Version: 1.0.0
*/
if (!defined('ABSPATH')) {
exit;
}
require_once __DIR__ . '/src/Command.php';
if (defined('WP_CLI') && WP_CLI) {
WP_CLI::add_command('content:qa', \SevenTech\ContentQA\Command::class);
}
Command implementation
<?php
namespace SevenTech\ContentQA;
use WP_CLI;
use WP_Query;
class Command {
/**
* Run content QA checks.
*
* ## OPTIONS
*
* [--status=<status>]
* : Post status to scan. Default: draft,pending
*
* [--limit=<number>]
* : Max posts to scan. Default: 20
*
* [--strict]
* : Exit non-zero if critical issues found.
*
* [--format=<format>]
* : table|json. Default: table
*/
public function __invoke($args, $assoc_args) {
$statuses = isset($assoc_args['status']) ? explode(',', $assoc_args['status']) : ['draft', 'pending'];
$limit = isset($assoc_args['limit']) ? (int) $assoc_args['limit'] : 20;
$strict = isset($assoc_args['strict']);
$format = $assoc_args['format'] ?? 'table';
$query = new WP_Query([
'post_type' => 'post',
'post_status' => $statuses,
'posts_per_page' => $limit,
'orderby' => 'modified',
'order' => 'DESC',
]);
$rows = [];
$criticalCount = 0;
foreach ($query->posts as $post) {
$issues = $this->inspect_post($post->ID);
if (!empty($issues['critical'])) {
$criticalCount += count($issues['critical']);
}
$rows[] = [
'id' => $post->ID,
'title' => get_the_title($post),
'critical' => implode('; ', $issues['critical']),
'warnings' => implode('; ', $issues['warnings']),
'score' => $issues['score'],
];
}
if ($format === 'json') {
WP_CLI::line(wp_json_encode($rows));
} else {
WP_CLI\Utils\format_items('table', $rows, ['id', 'title', 'critical', 'warnings', 'score']);
}
if ($strict && $criticalCount > 0) {
WP_CLI::error("Critical issues found: {$criticalCount}");
}
WP_CLI::success('QA scan complete.');
}
private function inspect_post(int $post_id): array {
$content = get_post_field('post_content', $post_id);
$title = get_the_title($post_id);
$critical = [];
$warnings = [];
$score = 100;
// Thin content
$wordCount = str_word_count(wp_strip_all_tags($content));
if ($wordCount < 500) {
$critical[] = 'Content under 500 words';
$score -= 25;
}
// Missing meta description
$metaDesc = get_post_meta($post_id, '_yoast_wpseo_metadesc', true);
if (empty($metaDesc) || strlen($metaDesc) < 120) {
$warnings[] = 'Meta description missing or too short';
$score -= 10;
}
// Heading hierarchy quick check
if (preg_match('/<h1[^>]*>/i', $content)) {
$warnings[] = 'H1 found inside content (theme should own H1)';
$score -= 8;
}
// Image alt text check
preg_match_all('/<img[^>]*>/i', $content, $imgs);
foreach ($imgs[0] as $img) {
if (!preg_match('/alt="[^"]+"/i', $img)) {
$warnings[] = 'Image without alt text';
$score -= 5;
}
}
// External links check placeholder (can be expanded with HTTP calls + caching)
preg_match_all('/href="(https?:\/\/[^\"]+)"/i', $content, $links);
if (count($links[1]) > 20) {
$warnings[] = 'High external link count, validate relevance';
$score -= 5;
}
return [
'critical' => array_values(array_unique($critical)),
'warnings' => array_values(array_unique($warnings)),
'score' => max(0, $score),
];
}
}
Run the command locally
wp content:qa --status=draft,pending --limit=30
wp content:qa --status=pending --format=json
wp content:qa --strict --format=json
The JSON output is useful when you want to gate publishing in CI. For example, if your editorial team uses pull requests for content changes, your pipeline can fail when critical issues are detected.
CI integration example (GitHub Actions)
name: Content QA
on:
workflow_dispatch:
pull_request:
paths:
- "wp-content/**"
jobs:
qa:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- name: Install WP-CLI
run: |
curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
chmod +x wp-cli.phar
sudo mv wp-cli.phar /usr/local/bin/wp
- name: Run content QA
run: wp content:qa --strict --format=json
Practical enhancements for production teams
- Broken-link verifier with caching: Resolve outbound URLs asynchronously and cache status codes in transients to avoid slow repeated checks.
- Readability thresholds: Add sentence-length and paragraph-size heuristics for better scanability.
- Policy packs: Enforce internal standards, such as at least one code block for technical posts or mandatory FAQ sections for tutorials.
- Author feedback loop: Push QA results into admin notices so writers can fix issues before submitting for review.
Why this workflow helps in 2026
AI-assisted drafting has increased publishing speed, but it also raises consistency risk. Automated QA is the missing layer between writing fast and publishing responsibly. By moving checks into WP-CLI, you get:
- Consistent standards independent of individual editors
- Fast feedback before publish
- Clear auditability in logs and CI history
- Lower SEO regressions over time
Start simple, then evolve your rules based on real editorial mistakes you see repeatedly. The most effective QA pipeline is not the strictest one, it is the one your team actually runs every day.
Final checklist
- Package your command as a plugin and keep it versioned
- Run QA on draft and pending statuses
- Use
--strictonly for critical errors at first - Export JSON for CI and reporting dashboards
- Review and tune scoring every month
Once this is in place, your WordPress publishing workflow becomes safer, faster, and easier to scale, especially when your content volume grows.

Leave a Reply