Most WordPress projects start with the tools WordPress already gives us.
When we need to store a new type of content, we usually reach for Custom Post Types and post meta. That is often the right choice. WordPress gives us register_post_type(), a familiar admin UI, custom fields, and WP_Query to fetch data.
But not all data behaves like content.
When data becomes transactional, relational, or query-heavy, Custom Post Types can start feeling like a workaround. Filtering by multiple meta fields, sorting by values, joining related data, or building reporting screens can quickly lead to complex WP_Query and WP_Meta_Query usage. At some point, developers often end up writing direct SQL anyway.
That is where custom database tables start making sense.
WordPress already gives us $wpdb, the core database abstraction layer. It is powerful, flexible, and WordPress-native, but it can become repetitive as the project grows.
Most modern frameworks solve this with an ORM. Laravel has Eloquent. WordPress does not ship with an ORM in that sense, but the ecosystem has seen a few attempts to bring a more structured database layer to plugin development. One of the more interesting projects in this space is BerlinDB.
What Is BerlinDB?
BerlinDB is a collection of PHP classes and functions that provides an ORM-like interface for custom database tables in WordPress.
The idea is not to turn WordPress into Laravel. It is to give plugin developers a cleaner way to define tables, describe schemas, shape rows, and query records without repeating the same database boilerplate.
For this post, we will use the official BerlinDB WordPress example plugin as a reference and create a simple books table.
When Should You Use Custom Tables?
Custom tables should not be the default answer for every problem. Custom Post Types are still great when the data behaves like content. If it needs an editor screen, revisions, permalinks, authors, statuses, taxonomies, REST API support, and theme rendering, CPTs are usually the better fit.
Custom tables make more sense when the data behaves like application data: orders, logs, transactions, subscriptions, analytics, form entries, bookings, license activations, API sync records, or anything that needs stricter structure and faster queries.
A simple rule we follow: Use Custom Post Types when the data is content. Use custom tables when the data is operational.
What We Will Build
For this article, we will create a small custom table to store book records using BerlinDB. The We will create a small custom table to store book records using BerlinDB. A typical BerlinDB setup includes:
SchemaTableRowQuery
We will also look at table install, uninstall, upgrades, and basic CRUD operations.
Table Design
For our example, we will use a books table.
| Column | Type | Notes |
|---|---|---|
id | bigint(20) | Primary key, auto increment |
isbn | varchar(32) | ISBN value |
title | mediumtext | Book title |
author | mediumtext | Author name |
date_created | datetime | Record creation date |
date_published | datetime | Book publication date |
ISBN should not be stored as tinyint. ISBN values may include hyphens and can be longer than a small integer allows, so varchar(32) is safer.
Setting Up the Plugin
You can bring BerlinDB into your plugin in two common ways.
Manual Include
For quick experiments, copy the BerlinDB core files into your plugin and load them manually. For production plugins, be careful. Another plugin may also include BerlinDB, and loading the same classes under the same namespace can create conflicts. To avoid this, prefix or namespace the BerlinDB classes inside your plugin.
Composer
BerlinDB is also available as a Composer package:
composer require berlindb/core
Composer is cleaner for development, but distributed WordPress plugins still need to think about dependency conflicts. Tools like Mozart, Imposter, or PHP-Scoper can help prefix dependencies during the build process.
Setting Up BerlinDB
BerlinDB usually needs four main classes:
Books_SchemaBooks_TableBookBook_Query
The examples below use simple class names. In a real plugin, these should live inside your plugin namespace and autoloading structure.
Defining the Schema
The schema defines columns and indexes.
namespace Lubus\Books\Database;use BerlinDB\Database\Kern\Schema;class Books_Schema extends Schema { public $columns = [ [ 'name' => 'id', 'type' => 'bigint', 'length' => '20', 'unsigned' => true, 'extra' => 'auto_increment', 'default' => false, 'cache_key' => true, 'sortable' => true, ], [ 'name' => 'isbn', 'type' => 'varchar', 'length' => '32', 'default' => '', 'searchable' => true, 'sortable' => true, ], [ 'name' => 'title', 'type' => 'mediumtext', 'default' => '', 'searchable' => true, ], [ 'name' => 'author', 'type' => 'mediumtext', 'default' => '', 'searchable' => true, ], [ 'name' => 'date_created', 'type' => 'datetime', 'default' => '', 'created' => true, 'sortable' => true, ], [ 'name' => 'date_published', 'type' => 'datetime', 'default' => '', 'sortable' => true, ], ]; public $indexes = [ [ 'type' => 'primary', 'columns' => [ 'id' ], ], [ 'name' => 'isbn', 'type' => 'key', 'columns' => [ 'isbn' ], ], [ 'name' => 'date_published', 'type' => 'key', 'columns' => [ 'date_published' ], ], ];}
This gives BerlinDB enough information to understand the shape of our table. Notice that we are also marking a few columns as searchable, sortable, or cache_key. These flags help BerlinDB understand how the fields can be used while querying data.
Defining the Table
The table class connects the schema to the database table.
namespace Lubus\Books\Database;use BerlinDB\Database\Kern\Table;class Books_Table extends Table { protected $schema = Books_Schema::class; protected $name = 'books'; protected $version = '2026070701';}
The $name property defines the table name without the WordPress database prefix. If the table prefix is wp_, this creates wp_books. The $version property helps with future upgrade routines.
Defining the Row
The row class gives each record a predictable shape.
namespace Lubus\Books\Database;use BerlinDB\Database\Kern\Row;class Book extends Row { public $id = 0; public $isbn = ''; public $title = ''; public $author = ''; public $date_created = ''; public $date_published = '';}
Defining the Query Class
The query class handles read and write operations for the table.
namespace Lubus\Books\Database;use BerlinDB\Database\Kern\Query;class Book_Query extends Query { protected $prefix = ''; protected $table_name = 'books'; protected $table_alias = 'b'; protected $table_schema = Books_Schema::class; protected $item_name = 'book'; protected $item_name_plural = 'books'; protected $item_shape = Book::class; protected $cache_group = 'lubus-books';}
This gives us methods like add_item(), get_item(), query(), update_item(), and delete_item().
Installing the Table
Run table creation on plugin activation.
use Lubus\Books\Database\Books_Table;register_activation_hook( __FILE__, 'lubus_books_activate' );function lubus_books_activate() { $table = new Books_Table(); if ( ! $table->exists() ) { $table->install(); }}
Uninstalling the Table
BerlinDB provides an drop() method on the table object. A simple uninstall.php file could look like this:
/** * Plugin uninstall routine. */defined( 'WP_UNINSTALL_PLUGIN' ) || exit;require_once __DIR__ . '/vendor/autoload.php'; use Lubus\Books\Database\Books_Table; $table = new Books_Table(); if ( $table->exists() ) { $table->drop(); }
Be careful with uninstall routines. Users may deactivate a plugin temporarily and still expect their data to remain. In real projects, we often prefer adding a setting like “Remove data on uninstall” so site owners can decide.
Table Upgrades
Custom tables evolve. Today you may only need title and author. Tomorrow you may Custom tables evolve, so versioning matters.
A simple upgrade flow looks like this:
- Update the stored version after a successful upgrade.
- Store the current database table version.
- Compare it with the version defined in the table class.
- Run
install()or an upgrade routine when the version changes.
use Lubus\Books\Database\Books_Table;function lubus_books_maybe_upgrade_database() { $table = new Books_Table(); $current = get_option( 'lubus_books_db_version', '' ); $table_version = '2026070701'; if ( $current !== $table_version ) { $table->install(); update_option( 'lubus_books_db_version', $table_version ); }}add_action( 'admin_init', 'lubus_books_maybe_upgrade_database' );
For larger plugins, a formal migration system is usually better. Each version can have its own upgrade callback, making data changes safer over time.
Managing Data
Once the schema, table, row, and query classes are ready, the query class handles most CRUD operations.
Querying Records
use Lubus\Books\Database\Book_Query;$query = new Book_Query();$books = $query->query( [ 'number' => 10, 'orderby' => 'date_published', 'order' => 'DESC', ]);
If you only need IDs, keep the query lighter with fields.
use Lubus\Books\Database\Book_Query;$query = new Book_Query();$book_ids = $query->query( [ 'number' => 10, 'fields' => 'ids', ]);
Fetching a Single Record
use Lubus\Books\Database\Book_Query;$query = new Book_Query();$book = $query->get_item( 10 );if ( $book ) { echo esc_html( $book->title );}
Inserting Data
use Lubus\Books\Database\Book_Query;$query = new Book_Query();$book_id = $query->add_item( [ 'isbn' => '0-7475-3269-9', 'title' => 'Harry Potter and the Philosopher\'s Stone', 'author' => 'J.K. Rowling', 'date_created' => current_time( 'mysql', true ), 'date_published' => gmdate( 'Y-m-d H:i:s', strtotime( 'June 26, 1997' ) ), ]);
Inserting Multiple Records
BerlinDB does not provide a dedicated bulk insert method in the simple query interface, but you can loop through records.
use Lubus\Books\Database\Book_Query;$query = new Book_Query();$records = [ [ 'isbn' => '0-7475-3269-9', 'title' => 'Harry Potter and the Philosopher\'s Stone', 'author' => 'J.K. Rowling', 'date_created' => current_time( 'mysql', true ), 'date_published' => gmdate( 'Y-m-d H:i:s', strtotime( 'June 26, 1997' ) ), ], [ 'isbn' => '0-4390-6486-4', 'title' => 'Harry Potter and the Chamber of Secrets', 'author' => 'J.K. Rowling', 'date_created' => current_time( 'mysql', true ), 'date_published' => gmdate( 'Y-m-d H:i:s', strtotime( 'June 2, 1999' ) ), ],];foreach ( $records as $record ) { $query->add_item( $record );}
For large imports, a more optimized insert routine may be better.
Updating Data
use Lubus\Books\Database\Book_Query;$query = new Book_Query();$query->update_item( 10, [ 'title' => 'Harry Potter and the Philosopher\'s Stone', 'author' => 'J.K. Rowling', ]);
Only pass the fields that need to change.
Updating Multiple Records
use Lubus\Books\Database\Book_Query;$query = new Book_Query();$records = [ [ 'id' => 10, 'data' => [ 'isbn' => '0-7475-3269-9', 'title' => 'Harry Potter and the Philosopher\'s Stone', 'author' => 'J.K. Rowling', 'date_published' => gmdate( 'Y-m-d H:i:s', strtotime( 'June 26, 1997' ) ), ], ], [ 'id' => 11, 'data' => [ 'isbn' => '0-4390-6486-4', 'title' => 'Harry Potter and the Chamber of Secrets', 'author' => 'J.K. Rowling', 'date_published' => gmdate( 'Y-m-d H:i:s', strtotime( 'June 2, 1999' ) ), ], ],];foreach ( $records as $record ) { $query->update_item( $record['id'], $record['data'] );}
The rough version had a few small PHP syntax issues here, especially missing commas and incorrect array access. Those are fixed in this version.
Deleting Data
use Lubus\Books\Database\Book_Query;$query = new Book_Query();$query->delete_item( 10 );
For admin actions, always check permissions and nonces before deleting records.
Deleting Multiple Records
use Lubus\Books\Database\Book_Query;$query = new Book_Query();$record_ids = array( 10, 21, 31 );foreach ( $record_ids as $record_id ) { $query->delete_item( $record_id );}
For large deletions, process records in batches.
Where BerlinDB Feels Useful
BerlinDB is helpful when your plugin data has a life of its own. A few examples:
- Licensing plugins storing activations
- Booking plugins storing reservations
- CRM plugins storing contacts and activity logs
- Reporting plugins storing aggregated metrics
- Sync plugins storing external API records
- Membership plugins storing subscriptions or events
- Form plugins storing entries and submission metadata
Could these be stored in posts and post meta? Sometimes, yes. Should they always be? Probably not. Custom tables are not about being clever. They are about respecting the shape of the data.
Things to Keep in Mind
BerlinDB gives you a useful foundation, but database design still matters. You still need to decide columns carefully, add indexes for common queries, handle upgrade routines, think about data retention, and protect write/delete operations with the right permissions.
It is also worth noting that BerlinDB has historically had less beginner-facing documentation compared to many mature WordPress tools. Reading the source and example plugin helps. Some tools are best understood by building something small with them.
Final Thoughts
WordPress is flexible enough to support different kinds of projects. Sometimes posts, pages, taxonomies, and meta are exactly right. Sometimes the data deserves its own table.
BerlinDB makes that second path more approachable. It gives developers a WordPress-native way to work with custom database tables without writing everything from scratch. This post is only a practical starting point. If you want to explore BerlinDB further, go through the official core repository and the WordPress example plugin.
At Lubus, we often end up working in this space: where WordPress is still the right platform, but the project needs more thoughtful engineering under the hood.
If you are building a custom WordPress solution or plugin with complex data needs, let’s talk.


