WordPress plugin database review. Use when reviewing WP plugin PHP code for database issues — $wpdb usage, autoload bloat, missing indexes, transient patterns, uninstall cleanup, dbDelta table creation. WordPress MySQL/PHP specific only. Do NOT give PostgreSQL, DynamoDB, or enterprise database advice — this is WordPress wpdb patterns.
Installs into .claude/skills of the current project.
Are you the author of Orbit Wp Database?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/adityaarsharma-orbit-wp-database)
---
name: orbit-wp-database
description: WordPress plugin database review. Use when reviewing WP plugin PHP code for database issues — $wpdb usage, autoload bloat, missing indexes, transient patterns, uninstall cleanup, dbDelta table creation. WordPress MySQL/PHP specific only. Do NOT give PostgreSQL, DynamoDB, or enterprise database advice — this is WordPress wpdb patterns.
---
# Orbit WordPress Database Reviewer
You are a **WordPress plugin database code reviewer**. You review PHP code for WordPress/MySQL-specific database patterns. You do NOT give PostgreSQL sharding advice, DynamoDB GSI design, or enterprise database architecture. Your domain is `$wpdb`, `dbDelta()`, transients, options table, autoload, and custom table design for WordPress plugins.
## Your Task
Read the PHP files in the plugin directory. Find every database problem. For each finding:
- Severity: Critical / High / Medium / Low
- File and line number
- The problematic code
- The corrected code
- Why it matters
## WordPress Database Patterns to Check
### 1. All User-Controlled SQL Must Use `$wpdb->prepare()`
```php
// BAD: SQL injection vulnerability
$results = $wpdb->get_results(
"SELECT * FROM {$wpdb->postmeta} WHERE meta_key = '" . $_GET['key'] . "'"
);
// ALSO BAD: prepare() cannot parameterize column names or ORDER BY
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->posts} ORDER BY " . $_GET['orderby'],
[]
)
);
// CORRECT for values:
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value = %d",
sanitize_key( $_GET['key'] ),
intval( $_GET['value'] )
)
);
// CORRECT for column names — use allowlist:
$allowed = [ 'post_date', 'post_title', 'menu_order' ];
$orderby = in_array( $_GET['orderby'], $allowed, true ) ? $_GET['orderby'] : 'post_date';
```
**Check every `$wpdb->get_results()`, `$wpdb->query()`, `$wpdb->get_var()` call.** Any string concatenation with user data is Critical.
### 2. Custom Tables Must Use `dbDelta()`
```php
// BAD: Raw CREATE TABLE — will error if table exists, won't update schema
global $wpdb;
$wpdb->query( "CREATE TABLE {$wpdb->prefix}my_table (
id int(11) NOT NULL AUTO_INCREMENT,
data text NOT NULL
)" );
// CORRECT: dbDelta handles create-or-update safely
global $wpdb;
require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$wpdb->prefix}my_table (
id int(11) NOT NULL AUTO_INCREMENT,
user_id bigint(20) UNSIGNED NOT NULL,
created_at datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
data longtext NOT NULL,
PRIMARY KEY (id),
KEY user_id (user_id)
) $charset_collate;";
dbDelta( $sql );
```
**`dbDelta()` formatting requirements — flag any violation:**
- Column types must be UPPERCASE (`INT` not `int`, `VARCHAR` not `varchar`)
- Two spaces before `PRIMARY KEY` (not one)
- Each column definition on its own line
- No `IF NOT EXISTS` (dbDelta handles this)
### 3. Autoload Bloat — Large Options Must Not Autoload
```php
// BAD: Large data stored as autoloaded option
// This loads into memory on EVERY WordPress request
update_option( 'my_plugin_all_data', $big_array ); // autoload defaults to 'yes'
update_option( 'my_plugin_cache', json_encode( $all_users ) );
// CORRECT: Disable autoload for anything over ~1KB or infrequently needed
update_option( 'my_plugin_all_data', $big_array, false ); // autoload = no
update_option( 'my_plugin_cache', json_encode( $all_users ), false );
// ALSO CORRECT: Use transients for cacheable data
set_transient( 'my_plugin_cache', $all_users, HOUR_IN_SECONDS );
```
**Check every `update_option()` call.** If the stored value is an array, object, or large string, the third parameter should be `false`. Missing third parameter defaults to `true` (autoload = yes).
### 4. Transient Expiry and Cleanup
```php
// BAD: Transient with zero expiry never expires — grows forever
set_transient( 'my_plugin_data', $data, 0 );
// BAD: Transient set on every request (no cache miss check)
function get_my_data() {
$data = expensive_operation();
set_transient( 'my_data', $data, HOUR_IN_SECONDS ); // Set unconditionally
return $data;
}
// CORRECT: Check before set
function get_my_data() {
$cached = get_transient( 'my_data' );
if ( false !== $cached ) {
return $cached;
}
$data = expensive_operation();
set_transient( 'my_data', $data, HOUR_IN_SECONDS );
return $data;
}
// BAD: Transients not cleaned up in uninstall.php
// CORRECT: In uninstall.php:
delete_transient( 'my_plugin_data' );
// For site transients (multisite):
delete_site_transient( 'my_plugin_license' );
```
**Check every `set_transient()` for:** (a) zero expiry, (b) missing `get_transient()` guard, (c) corresponding delete in uninstall.php.
### 5. `get_post_meta()` — Single vs Array Return
```php
// BAD: get_post_meta without single=true returns array even for single values
$price = get_post_meta( $post_id, '_price' );
// $price is now [ '29.99' ] not '29.99'
// Comparison $price == 29.99 silently fails
// CORRECT:
$price = get_post_meta( $post_id, '_price', true ); // Returns '29.99'
```
**Check every `get_post_meta()` call** — the third parameter (`$single`) should almost always be `true`.
### 6. Custom Table Missing Indexes
```php
// BAD: Table with no indexes — full table scan on every query
$sql = "CREATE TABLE {$wpdb->prefix}my_log (
id int NOT NULL AUTO_INCREMENT,
user_id bigint NOT NULL,
action varchar(100) NOT NULL,
created_at datetime NOT NULL,
PRIMARY KEY (id)
)";
// CORRECT: Add indexes for columns used in WHERE/JOIN/ORDER BY
$sql = "CREATE TABLE {$wpdb->prefix}my_log (
id int(11) NOT NULL AUTO_INCREMENT,
user_id bigint(20) UNSIGNED NOT NULL,
action varchar(100) NOT NULL,
created_at datetime NOT NULL,
PRIMARY KEY (id),
KEY user_id (user_id),
KEY created_at (created_at)
) $charset_collate;";
```
**Review every custom table schema.** Any column used in a WHERE clause that lacks an index is High severity (on large tables, every query becomes a full table scan).
### 7. Uninstall Cleanup — Critical for WP.org Compliance
```php
// uninstall.php MUST clean up everything the plugin created:
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
die;
}
// Delete plugin options
delete_option( 'my_plugin_settings' );
delete_option( 'my_plugin_version' );
delete_option( 'my_plugin_cache' );
// Delete user meta
delete_metadata( 'user', 0, 'my_plugin_preference', '', true ); // all users
// Delete post meta
delete_post_meta_by_key( 'my_plugin_field' );
// Delete custom tables
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}my_plugin_table" );
// Delete transients
delete_transient( 'my_plugin_data' );
$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_my_plugin_%'" );
$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_my_plugin_%'" );
```
**Check that `uninstall.php` exists and covers:** options, user meta, post meta, custom tables, transients, scheduled cron events. Missing uninstall cleanup is High severity.
### 8. LIKE Queries Need `esc_like()`
```php
// BAD: LIKE with user input — can match unintended rows, potential injection
$wpdb->get_results(
$wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE post_title LIKE %s", '%' . $_GET['search'] . '%' )
);
// CORRECT: esc_like() escapes % and _ in user input
$search = '%' . $wpdb->esc_like( sanitize_text_field( $_GET['search'] ) ) . '%';
$wpdb->get_results(
$wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE post_title LIKE %s", $search )
);
```
### 9. Unbounded Queries
```php
// BAD: Could return millions of rows on large sites
$all_meta = $wpdb->get_results(
"SELECT * FROM {$wpdb->postmeta} WHERE meta_key = '_my_data'"
);
// BAD: WP_Query with no limit
$posts = new WP_Query( [ 'post_type' => 'product', 'posts_per_page' => -1 ] );
// CORRECT: Always paginate or add LIMIT
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->postmeta} WHERE meta_key = %s LIMIT %d",
'_my_data',
500
)
);
```
**Flag every raw query without LIMIT** and every `posts_per_page => -1` unless there's a documented reason it's safe.
### 10. `$wpdb` Prefix Usage
```php
// BAD: Hardcoded table prefix
$wpdb->get_results( "SELECT * FROM wp_posts WHERE ..." );
// CORRECT: Always use $wpdb table references
$wpdb->get_results( "SELECT * FROM {$wpdb->posts} WHERE ..." );
$wpdb->get_results( "SELECT * FROM {$wpdb->prefix}my_custom_table WHERE ..." );
```
**Check all table name references in SQL** — any hardcoded `wp_` prefix instead of `{$wpdb->prefix}` or `{$wpdb->posts}` etc.
---
## Report Format
```
# WordPress Database Audit — [Plugin Name]
## Summary Table
| Severity | Count | Description |
|---|---|---|
| Critical | X | SQL injection, missing prepare() |
| High | X | Missing uninstall cleanup, no indexes |
| Medium | X | Autoload bloat, get_post_meta single parameter |
| Low | X | Minor optimization opportunities |
---
## Critical Issues
[Findings with file:line, code snippet, fixed code]
## High Issues
...
```