dbout/wp-orm
A lightweight ORM for WordPress that brings an Eloquent-style experience to custom tables and posts. Define models, query with a fluent builder, and handle relationships without raw SQL, while staying fully compatible with the WordPress ecosystem.
Install through Composer inside your theme or plugin (not the WordPress core directory):
composer require dbout/wp-orm
The package wraps illuminate/database in a Capsule manager, but it needs WordPress's $wpdb constants (DB_HOST, DB_NAME, DB_USER, DB_PASSWORD, $table_prefix) to already be defined. In practice that means you boot it after wp-settings.php has run — from a plugin's plugins_loaded hook, from functions.php, or from a mu-plugin:
use Dbout\WpOrm\Orm;
add_action('plugins_loaded', function () {
Orm::init();
});
Look first at the src/Models directory once installed — the bundled models map one-to-one onto core tables and are the fastest way to understand the API surface:
Post / Page / Attachment (and PostMeta)User / UserMetaTerm / TermMeta / TermRelationshipComment / CommentMetaOptionYour first use case is almost always "give me posts of a custom type without touching WP_Query":
use Dbout\WpOrm\Models\Post;
$posts = Post::query()
->where('post_type', 'post')
->where('post_status', 'publish')
->orderByDesc('post_date')
->limit(10)
->get();
foreach ($posts as $post) {
echo $post->post_title;
}
If that returns rows, the connection is wired correctly. If it throws a "could not find driver" or "unknown database" error, the constants were read too early.
One model per custom post type. The package is designed around subclassing the base Post model and declaring the type, so the type filter and the table name are handled for you:
use Dbout\WpOrm\Models\Post;
class Movie extends Post
{
protected $postType = 'movie';
}
Then Movie::find($id) behaves like a scoped query — you get the Eloquent surface (where, first, paginate, chunk, exists) while the post type constraint stays implicit. This is the single biggest win: you stop hand-writing WP_Query arg arrays and get a fluent, testable builder.
Custom post types and taxonomies as first-class citizens. Register the CPT normally with register_post_type, then model it. If the package exposes the CustomPostType / CustomTaxonomy traits in your version, use them to expose the registration config in PHP alongside the model so taxonomy and model live in one file rather than drifting apart.
Relationships instead of manual meta queries. Post meta is the place where hand-rolled WordPress code is ugliest. Model it:
$movie = Movie::with('meta')->find($id);
$trailer = $movie->meta
->firstWhere('meta_key', '_trailer_url')
?->meta_value;
For repeated access, define typed accessors on your model rather than reading raw meta keys at every call site. The meta relationship plus Eloquent eager loading (with()) is what removes the classic N+1 you get when you loop get_post_meta().
Repositories and services, not fat controllers. Because models are plain Eloquent classes, they're injectable and mockable. Put queries behind a small repository class so your theme templates receive arrays of models rather than reaching for the ORM directly:
class MovieRepository
{
public function latest(int $n = 5): Collection
{
return Movie::query()
->where('post_status', 'publish')
->orderByDesc('post_date')
->limit($n)
->get();
}
}
Mixing with WP_Query is fine, but pick a side per screen. Where you need WordPress-specific behaviour — pre_get_posts filters, archive templates, the_loop, plugins that hook WP_Query — stay with WP_Query. Use the ORM for reads and writes in REST endpoints, admin screens, cron jobs, and imports, where the loop machinery is irrelevant overhead.
Bulk writes and imports. chunkById plus insert() is dramatically faster than wp_insert_post() for seeding thousands of rows, and you avoid a hook storm. This is the package's strongest second use case: ETL-style jobs against the posts table.
Testing. Because the models don't require a full WordPress bootstrap if you supply the DB config, you can point the Capsule at a test schema and unit-test your query logic without spinning up a WP install.
Writes bypass WordPress hooks. Saving through Eloquent issues raw SQL. save_post, wp_insert_post, transition_post_status, and wp_after_insert_post will not fire. Anything downstream — sitemap regeneration, cache purges, search index updates, revision creation, guid population — silently doesn't happen. For any write that a human triggers in wp-admin, prefer wp_insert_post/wp_update_post and reserve the ORM for reads or for controlled bulk operations where you re-fire the relevant hooks yourself.
Caches will go stale. WordPress's object cache (and page caches) are invalidated by core functions, not by direct SQL. After an ORM write you usually need clean_post_cache($id), wp_cache_delete(...), or a manual purge. This is the number-one production surprise with any ORM over wp_posts.
Timestamps and date columns are not created_at. Core uses post_date, post_date_gmt, post_modified, and friends, stored as MySQL strings in Y-m-d H:i:s in the site's timezone. Eloquent's default $timestamps = true will try to write created_at/updated_at columns that don't exist on wp_posts. Set public $timestamps = false; on your models unless the base class already handles it, and remember post_date is local time while post_date_gmt is UTC.
Meta values are strings, always. wp_postmeta.meta_value is a LONGTEXT. Integers, booleans, and arrays (serialized PHP) all come back as strings. Cast explicitly in your accessors ((int), maybe_unserialize()) — don't expect Eloquent $casts to do the right thing on an aliased column.
The post_type global scope can't be removed by accident — but it can surprise you. If you subclass Post without setting $postType, or if you query the base Post model expecting all posts, check whether a scope is constraining you. When a query returns zero rows and the SQL looks right, dump toSql() and inspect the bindings.
Multisite is not handled for you. Orm::init() establishes a connection using the current site's table prefix at boot. On switch_to_blog() the Capsule keeps pointing at the original blog's tables. Either re-init after switching, or explicitly set the prefix on the connection, or avoid the ORM in multisite network-wide loops.
Initialization order is the fragile part. The single most common failure is calling Orm::init() from a Composer autoload file or a theme's functions.php before $wpdb exists. Symptoms are Undefined constant DB_NAME or connecting to the wrong DB. Guard it behind plugins_loaded or check function_exists('wp_load_alloptions') first.
Custom tables work, but you own the schema. A model pointing at a plugin table gets you the query builder but no migrations and no schema awareness. Keep DDL in dbDelta() and treat the model as read/write sugar only.
Escape on output, not on query. Eloquent parameterises queries, so SQL injection is not your concern — but post_content is still raw HTML from the database, and bypassing the_content filters means shortcodes, wpautop, and block rendering don't run. Call apply_filters('the_content', $post->post_content) in templates, and esc_html/wp_kses_post as usual.
Debugging: log the SQL. Attach a listener to the Capsule connection's query event (or DB::listen equivalent) during development. WordPress's SAVEQUERIES won't see these queries, so without an explicit listener the ORM is invisible in $wpdb->queries.
Pin the version. With a small dependent count and a fast-moving release cadence, a ^x.y constraint is safer than dev-main, and run a smoke test of your custom models after upgrading — base model behaviour (scopes, casts, meta relations) is exactly the kind of thing that shifts between minor versions.
Extension point worth knowing: because each model is a normal Eloquent class, global scopes, accessors, mutators, observers, and custom Builder subclasses all work. A custom builder that encapsulates "published, of this type, ordered by menu_order" is a clean way to express the domain once and reuse it everywhere.
How can I help you explore Laravel packages today?