Install
$ agentstack add skill-mralaminahamed-wp-dev-skills-wp-database Open-source listing — not yet scanned by AgentStack. Follow the source repository for install instructions.
Security review
⚠ Flagged1 finding(s); flagged for manual review. · v0.1.0 How review works →
- • Prompt-injection patterns
- • Secret / credential exfiltration
- • Dangerous shell & filesystem operations
- • Untrusted network calls
- • Known-malicious package signatures
- high Destructive filesystem operation.
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
WordPress Custom Database Tables
> Model note: dbDelta schema and CRUD patterns are mechanical (haiku). Query optimisation and multi-version data migrations require cross-file reasoning — use sonnet for those sub-tasks.
Create and manage custom database tables in WordPress plugins: dbDelta() for schema definition, versioned upgrade routines, $wpdb CRUD with prepared statements, and data migration strategies.
When to use
- "Create a custom DB table for my plugin", "set up plugin schema with dbDelta".
- "Write a migration for plugin upgrade", "run schema changes on update".
- "Query a custom table", "insert/update/delete with $wpdb".
- "Optimise a slow custom query", "add an index to a plugin table".
- "Migrate data from post meta to a custom table".
Not for: WooCommerce order table operations — use wp-woocommerce. General $wpdb query optimisation in core WP tables — use wp-performance (official skill).
Method
1. Create table with dbDelta
dbDelta() is the only WP-safe way to create and alter tables — it diffs the current schema against the SQL and applies only the necessary changes.
function my_plugin_create_tables() {
global $wpdb;
$charset_collate = $wpdb->get_charset_collate(); // e.g. DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci
// $wpdb->prefix respects multisite site prefix automatically
$table_log = $wpdb->prefix . 'my_plugin_log';
$table_meta = $wpdb->prefix . 'my_plugin_item_meta';
// IMPORTANT: two spaces before PRIMARY KEY, one space before each KEY
// IMPORTANT: no trailing comma on last field before closing paren
$sql = "CREATE TABLE {$table_log} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
item_id bigint(20) unsigned NOT NULL,
action varchar(100) NOT NULL DEFAULT '',
message longtext NOT NULL,
user_id bigint(20) unsigned NOT NULL DEFAULT 0,
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY item_id (item_id),
KEY created_at (created_at)
) {$charset_collate};
CREATE TABLE {$table_meta} (
meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
item_id bigint(20) unsigned NOT NULL,
meta_key varchar(255) NOT NULL DEFAULT '',
meta_value longtext,
PRIMARY KEY (meta_id),
KEY item_id (item_id),
KEY meta_key (meta_key(191))
) {$charset_collate};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
}
Critical dbDelta formatting rules (violations cause silent failures):
- Two spaces after
PRIMARY KEY(e.g.PRIMARY KEY (id)) - Field definitions must end with a comma except the last field before the closing paren
KEYlines go after all field definitions, before the closing paren- Only
CREATE TABLEstatements — noALTER TABLE(dbDelta handles column additions, not removals) - Always include
{$charset_collate}at the end
2. Versioned upgrade routine
Track schema version in an option; only re-run dbDelta when the version changes:
define( 'MY_PLUGIN_DB_VERSION', '1.3.0' );
function my_plugin_maybe_upgrade_db() {
$installed = get_option( 'my_plugin_db_version', '0' );
if ( version_compare( $installed, MY_PLUGIN_DB_VERSION, '>=' ) ) {
return; // already up to date
}
my_plugin_create_tables(); // always safe to re-run dbDelta
// Version-specific data migrations
if ( version_compare( $installed, '1.2.0', 'prepare()` for any value from user input or untrusted source.
**Insert:**
```php
$result = $wpdb->insert(
$wpdb->prefix . 'my_plugin_log',
[
'item_id' => $item_id,
'action' => 'view',
'message' => $message,
'user_id' => get_current_user_id(),
'created_at' => current_time( 'mysql' ),
],
[ '%d', '%s', '%s', '%d', '%s' ] // format for each value: %d int, %s string, %f float
);
$inserted_id = $wpdb->insert_id;
Update:
$wpdb->update(
$wpdb->prefix . 'my_plugin_log',
[ 'message' => $new_message ], // data
[ 'id' => $log_id ], // where
[ '%s' ], // data format
[ '%d' ] // where format
);
Delete:
$wpdb->delete(
$wpdb->prefix . 'my_plugin_log',
[ 'item_id' => $item_id ],
[ '%d' ]
);
Select — single row:
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}my_plugin_log WHERE id = %d",
$log_id
)
); // returns stdClass or null
Select — multiple rows:
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}my_plugin_log WHERE item_id = %d ORDER BY created_at DESC LIMIT %d",
$item_id,
50
)
); // returns array of stdClass
Select — single value:
$count = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->prefix}my_plugin_log WHERE action = %s",
'view'
)
);
Raw query (DDL / no-result):
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->prefix}my_plugin_log WHERE created_at insert( ... );
if ( false === $result ) {
// $wpdb->last_error contains the MySQL error
error_log( 'my-plugin DB error: ' . $wpdb->last_error );
return new WP_Error( 'db_insert_error', $wpdb->last_error );
}
Enable query logging during development:
define( 'SAVEQUERIES', true );
// Then: print_r( $wpdb->queries )
5. Schema migrations (data migrations)
For migrating existing data (not just schema changes):
function my_plugin_migrate_to_1_2_0() {
global $wpdb;
// Example: move post meta to custom table
$meta_rows = $wpdb->get_results(
"SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = '_my_plugin_data'"
);
if ( ! $meta_rows ) return;
$table = $wpdb->prefix . 'my_plugin_log';
foreach ( $meta_rows as $row ) {
$wpdb->insert( $table, [
'item_id' => $row->post_id,
'message' => $row->meta_value,
'action' => 'migrated',
], [ '%d', '%s', '%s' ] );
}
// Remove the old meta after successful migration
$wpdb->delete( $wpdb->postmeta, [ 'meta_key' => '_my_plugin_data' ], [ '%s' ] );
}
For large datasets, use batches (via wp-background-processing):
function my_plugin_migrate_batch( $offset = 0 ) {
global $wpdb;
$batch = $wpdb->get_results( $wpdb->prepare(
"SELECT * FROM {$wpdb->postmeta} WHERE meta_key = '_old_key' LIMIT 100 OFFSET %d",
$offset
) );
// ... process batch ...
if ( count( $batch ) === 100 ) {
// More to process — schedule next batch
as_enqueue_async_action( 'my_plugin_migrate_batch', [ 'offset' => $offset + 100 ], 'my-plugin' );
} else {
update_option( 'my_plugin_migration_complete', true );
}
}
6. Table removal on uninstall
Use register_uninstall_hook (not deactivation_hook) for destructive cleanup:
// uninstall.php (registered via register_uninstall_hook(__FILE__, ...) or placed at plugin root)
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) exit;
global $wpdb;
// Drop per-site tables on multisite
if ( is_multisite() ) {
$sites = get_sites( [ 'number' => 0, 'fields' => 'ids' ] );
foreach ( $sites as $site_id ) {
switch_to_blog( $site_id );
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}my_plugin_log" );
delete_option( 'my_plugin_db_version' );
restore_current_blog();
}
} else {
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}my_plugin_log" );
delete_option( 'my_plugin_db_version' );
}
7. Seeding sample / preview data (dev only)
To populate custom tables with realistic data for local preview, write a standalone script run via WP-CLI's wp eval-file — never auto-loaded by the plugin. Keep it in a tools/ dir and document it in tools/README.md.
\n" ); }
global $wpdb;
$table = $wpdb->prefix . 'my_plugin_log';
// --fresh truncates first. TRUNCATE is destructive — gate it, and expect the
// agent permission classifier to block it unless tables are already empty.
if ( in_array( '--fresh', (array) ( $args ?? [] ), true ) ) {
$wpdb->query( "TRUNCATE TABLE {$table}" ); // phpcs:ignore
}
foreach ( $rows as $row ) {
$wpdb->insert( $table, $row ); // hardcoded columns only
}
WP_CLI::success( 'Seeded.' );
Conventions that keep seeders safe and re-runnable:
- Idempotent where it matters. A seeder that creates linked records (WP users, EDD payments) should skip rows already linked — e.g.
if ( ! empty( $row->payment_id ) ) continue;— so reruns don't duplicate. A pure log-filler can be additive; say so in the script header and accept a count arg ((int) ( $args[0] ?? 0 ) ?: 20). - Link to real WP objects, not fakes. Create real users with
wp_insert_user()and reuse by email (get_user_by); mint EDD orders through the plugin's own purchase wrapper (e.g. anEDDintegration class) rather than raw inserts, so the seeded data exercises the real code path. Write the resultinguser_id/payment_idback onto the custom-table row. - Generate via WP-CLI, verify via
wp db query. Confirm row counts/links after seeding. - Dev-only. Never ship
tools/; never run against production. Usecurrent_time('mysql')/gmdate()for timestamps, and seedextra/JSON columns withwp_json_encode().
Note on randomness: scripts run by wp eval-file may warn on large int math ($x * 2654435761 overflows to float) — keep PRNG seeds inside & 0x7fffffff.
Common Mistakes
| Mistake | Fix | |---------|-----| | Wrapper methods that discard $wpdb->insert() return value cause silent failures | Always check the return value and propagate $wpdb->last_error upstream — callers cannot diagnose a failure the wrapper swallowed. Return null/WP_Error on failure, never silently return as if the insert succeeded |
Notes
dbDelta()can ADD columns but cannot remove them. Removing columns requiresALTER TABLE DROP COLUMNin a manual migration step.- Never use
$wpdb->prepare()with%sfor integers — always%d. Using%son an integer is not a security issue but causes type coercion surprises. - Column names in
$wpdb->insert()/update()/delete()are NOT escaped — they must be hardcoded, never user-supplied. - Cache expensive custom queries:
$results = wp_cache_get( $cache_key, 'my_plugin' ); if ( false === $results ) { $results = $wpdb->get_results(...); wp_cache_set( $cache_key, $results, 'my_plugin', 300 ); } - Always include
bigint(20) unsigned NOT NULL AUTO_INCREMENTas the primary key — matches WP core table conventions. - Use
gmdate()notdate()for DB timestamps; WP'scurrent_time('mysql')returns local time — use it only when you need WP's configured timezone.
References
references/dbdelta-rules.md—dbDelta()rules: strict SQL formatting requirements, column change limitations, and safe schema diff patternsreferences/migration-strategies.md— Versioned upgrade routines:db_versionpattern, migration function registration, and idempotent migration checklistreferences/wpdb-patterns.md—$wpdbprepared statement patterns, insert/update/delete helpers, and custom-table query conventions
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: mralaminahamed
- Source: mralaminahamed/wp-dev-skills
- License: MIT
- Homepage: https://github.com/mralaminahamed/wp-dev-skills/blob/trunk/README.md
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.