as247/wp-eloquent
Wp-Eloquent brings Laravel's Eloquent ORM to WordPress, letting you define models and query posts, users, terms, and custom tables with fluent syntax and relationships. It reduces SQL boilerplate and makes WordPress data access feel like modern PHP.
Install
composer require as247/wp-eloquent
No illuminate/database dependency is required — it ships vendored. PHP 7.0+ and a WordPress bootstrap with an available $wpdb are the only hard requirements.
Bootstrap once
Eloquent must be initialized after WordPress has loaded $wpdb (i.e. on plugins_loaded, init, or later — never inside wp-config.php):
use WPEloquent\Core;
Core::init(); // wires the capsule to $wpdb, applies $wpdb->prefix
Wrap the call in a "have we run yet?" guard if you're in a plugin that might be included twice.
First use case: read published posts
use WPEloquent\Model\Post;
$recent = Post::query()
->where('post_type', 'post')
->where('post_status', 'publish')
->orderByDesc('post_date')
->limit(5)
->with(['author', 'meta'])
->get();
foreach ($recent as $p) {
echo $p->post_title;
echo $p->author->display_name;
echo $p->meta->firstWhere('meta_key', '_thumbnail_id')->meta_value ?? '';
}
Where to look first
src/WPEloquent/Model/ — Post, Term, User, Comment, Attachment, plus their *Meta companions. These are your ready-made Eloquent entry points into core WP tables.src/WPEloquent/Core.php — bootstrap, connection, and query-log handles.Illuminate/Database/ — the version you actually get. Note it: many modern Eloquent features aren't there.meta, terms, author.1. Custom post types via inheritance
namespace App\Models;
use WPEloquent\Model\Post;
class Event extends Post
{
protected static function boot()
{
parent::boot();
static::addGlobalScope('post_type', function ($q) {
$q->where('post_type', 'event');
});
}
}
Event::where('post_status', 'publish')->get() now silently scopes to event.
2. Filter by post meta
Post::whereHas('meta', function ($q) {
$q->where('meta_key', 'featured')
->where('meta_value', '1');
})->get();
For multi-key meta sets, chain whereHas for each meta_key — it's cleaner than a raw join and still composes with scopes.
3. Filter by taxonomy term
Post::whereHas('terms', function ($q) {
$q->where('slug', 'news')
->where('taxonomy', 'category');
})->get();
4. Eager loading is non-optional for lists
Post::with(['author', 'meta', 'terms'])
->where('post_status', 'publish')
->paginate(20);
WordPress's post/term tables are indexed for wp_query, not for Eloquent's lazy loading. Without with() you will trigger one query per term/meta row.
5. Write path: read with Eloquent, write through WP APIs
The strongest pattern in practice is hybrid:
wp_posts + wp_postmeta + wp_term_relationships — Eloquent.wp_insert_post(), wp_update_post(), wp_set_object_terms(), update_post_meta() — because those fire save_post / transition_post_status, populate guid and the _gmt columns correctly, handle revisions and autosaves, and invalidate the object cache.If you must write via Eloquent, be prepared to populate post_date_gmt, post_modified_gmt, and guid yourself, and to manually do_action('save_post', ...).
6. Custom plugin tables
class Order extends \Illuminate\Database\Eloquent\Model
{
protected $table = 'shop_orders';
public $timestamps = false;
// Add `protected $connection = 'wp';` only if Core::init() did not
// register the capsule connection as default.
}
Create the table with dbDelta() — Eloquent's schema builder isn't wired to the WP connection.
7. Transactions for multi-step writes
Grab the capsule's connection and wrap:
Core::getConnection()->transaction(function () {
// multiple inserts/updates
});
8. REST / admin integration
Boot once, reuse. Models serialize to JSON with ->toArray(); add protected $hidden for anything you don't want leaked (meta serialized payloads, user hashes).
Core::init() ran before $wpdb existed. Hook it on plugins_loaded or later.Core::init() uses $wpdb->prefix (current blog). A "get all sites' posts" query should use $wpdb->base_prefix or a second, explicitly-configured connection — otherwise you'll silently read only the current site.Post model is unfiltered. It will happily return revisions, nav menu items, attachments, and drafts. Every query needs post_type and (usually) post_status clauses, or a global scope. This is the most common source of "why did my query return 47 rows?".post_date / post_modified, not created_at / updated_at. If you see "unknown column created_at", set public $timestamps = false; on the model (or map them explicitly).save(). Eloquent writes bypass save_post, transition_post_status, and wp_cache_* invalidation. Any plugin listening for those events or relying on cached WP_Post objects will see stale or missing data. Prefer WP write APIs.get_posts() already warmed wp_cache_posts, mixing read paths duplicates work. In performance-sensitive loops, use one or the other.LONGTEXT value untouched. Cast via accessors with maybe_unserialize(), json_decode(), or (bool).booted(), withAggregate, whereRelation, HasOneThrough, typed relation return values — may not be present. Test before you adopt a newer pattern. Also confirm whether the vendored namespace is Illuminate\Database\... (collides with any other Composer package requiring illuminate/database) or a prefixed one. Don't run this alongside tareq1988/wp-eloquent.term_taxonomy_id vs term_id. wp_term_relationships links to term_taxonomy_id, not term_id. The provided terms relation handles the extra hop through wp_term_taxonomy; if you write raw joins, don't forget it. The classic symptom: your "in category 5" filter returns nothing.?page=. Eloquent's paginate() doesn't read get_query_var('paged'). In a theme context, either resolve the current page yourself or use ->forPage($page, $perPage).Core::getConnection()->enableQueryLog()) and dump getQueryLog(), or ->toSql() + getBindings() on a builder. DB::listen may or may not be wired depending on the bundled Illuminate version.parent. The meta, terms, author, comments relations on WPEloquent\Model\Post encode the correct foreign keys and the term-taxonomy hop. Override them only via parent::meta(), never from scratch.Relation or a Model in src/ is usually the shortest path.How can I help you explore Laravel packages today?