tiny-pixel/acorn-models
Acorn Models brings Laravel-style Eloquent modeling to WordPress through Roots Acorn. Define models for posts, custom post types, taxonomies, terms, and users; add relationships, scopes, and fluent queries; and build cleaner, more maintainable themes and plugins with a familiar API.
Install and boot
composer require tiny-pixel/acorn-models
Acorn's package discovery should register the service provider automatically. If you're on an older Acorn/Laravel version, add it manually to config/app.php:
'providers' => [
// ...
TinyPixel\AcornModels\Providers\AcornModelsServiceProvider::class,
],
There is no build step and no admin UI. The package's job is to give you Eloquent model classes that map onto WordPress core tables (posts, postmeta, users, terms, term_taxonomy, comments). Everything else — routing, views, CLI — is just Acorn.
Where to look first
vendor/tiny-pixel/acorn-models/src/ — read the model classes and the service provider. This package is small (36 stars, ~zero dependents) and the README is thinner than you'd like; the source is the documentation. Confirm the exact namespaces and model names for the version you pinned before writing any code.config/database.php — this is the piece that actually makes or breaks the install.$table_prefix in wp-config.php — it must match your Eloquent connection's prefix.Point Eloquent at the WordPress database
The package does not create its own connection; it relies on a standard Laravel connection pointing at the WP database. Add a dedicated wordpress connection:
// config/database.php
'connections' => [
'wordpress' => [
'driver' => 'mysql',
'host' => DB_HOST,
'database' => DB_NAME,
'username' => DB_USER,
'password' => DB_PASSWORD,
'charset' => DB_CHARSET,
'collation' => 'utf8mb4_unicode_ci',
'prefix' => $GLOBALS['table_prefix'] ?? 'wp_',
'strict' => false, // WP core tables don't satisfy Laravel's strict mode
],
],
Then set protected $connection = 'wordpress'; on your own models, or make wordpress the default connection if the whole app is WP-data-centric.
First use case
Don't start by rewriting WP_Query. Start with a read-only query that WP_Query handles badly: a report, a dashboard widget, an export, or a cross-entity join. Something like "most recent published events with their meta and terms, in one query":
$events = Event::query()
->where('post_status', 'publish')
->where('post_date', '>=', now()->subDays(30))
->with(['meta', 'terms'])
->orderByDesc('post_date')
->limit(25)
->get();
If that returns the rows you expect, the connection and prefix are correct and you can invest further.
Subclass for post types instead of filtering everywhere
Rather than scattering ->where('post_type', 'event') across the codebase, define one model per post type with a global scope:
namespace App\Models;
use TinyPixel\AcornModels\Models\Post as BasePost;
class Event extends BasePost
{
protected $table = 'posts';
protected $primaryKey = 'ID';
public $timestamps = false;
protected static function booted(): void
{
static::addGlobalScope('type', fn ($q) => $q->where('post_type', 'event'));
}
public function scopePublished($q)
{
return $q->where('post_status', 'publish');
}
public function scopeUpcoming($q)
{
return $q->where('post_date', '>=', now());
}
}
Event::published()->upcoming()->get() reads better than a WP_Query array and composes cleanly with pagination and eager loading.
Lean on relationships where WP_Query forces extra queries
The real win over core APIs is eager loading. Post meta is EAV — with get_post_meta() in a loop you get N+1. With Eloquent:
$posts = Event::with(['meta', 'terms'])->get();
$prices = $posts->map(fn ($p) => $p->meta->firstWhere('meta_key', '_price')?->meta_value);
Use the same discipline you would in Laravel: with() on the index, lazy loading only in a single-record context. Enable Model::preventLazyLoading(! app()->isProduction()) in a service provider during development.
Hybrid: WP_Query for the loop, Eloquent for the data
You rarely need to replace the main query. A very practical pattern is keeping WP_Query for the WordPress loop (so template hierarchy, the_post(), and global $post behave normally) and using Eloquent to hydrate the side data — related posts, terms across taxonomies, author meta — for the same IDs:
$ids = wp_list_pluck($query->posts, 'ID');
$related = Event::whereIn('ID', $ids)->with('meta')->get()->keyBy('ID');
This keeps plugin compatibility and gets you out of meta-query hell.
Scopes as the boundary between WP semantics and app semantics
WordPress statuses are strings, not enums: publish, draft, pending, private, trash, plus revisions (inherit) and nav menu items (nav_menu_item). Wrap those in scopes so your controllers and Blade templates never see raw status strings:
public function scopeVisible($q)
{
return $q->whereIn('post_status', ['publish', 'private']);
}
Serializing to REST or Blade
Eloquent models serialize nicely, which makes them a good fit for Acorn REST controllers and API resources. Map the WP column names to a stable public shape rather than exposing post_content and guid verbatim:
return EventResource::collection(
Event::published()->with('meta')->paginate(20)
);
For Blade views in Acorn, pass collections directly and iterate — $events->each(...) — rather than converting back to WP_Post objects.
Bulk and reporting queries
Eloquent hydration is not free. For exports, dashboards, or anything touching thousands of rows, drop to the query builder on the same connection:
DB::connection('wordpress')
->table('posts')
->where('post_type', 'event')
->select('ID', 'post_title', 'post_date')
->cursor();
WP-CLI commands
Acorn's console is the best place for backfills and audits: iterate with cursor(), write with wp_update_post() (see gotchas), and wrap the whole command in a transaction where it's safe. This is the most underrated day-to-day use of the package — data work that would otherwise be a throwaway PHP script in the WP root.
The table prefix is the #1 failure mode. If Eloquent queries posts and WordPress uses wp_posts, you'll get "table not found" or, worse, silently empty results. Confirm prefix in the connection matches $table_prefix exactly, and never hardcode wp_ — plenty of installs use a custom prefix.
$timestamps = false is mandatory. WP core tables have post_date, post_modified, user_registered — not created_at/updated_at. Leave Laravel's timestamps on and every insert/update will fail. Override getCreatedAtColumn()/getUpdatedAtColumn() if you want date casting.
The primary key is ID, uppercase. Set protected $primaryKey = 'ID'; and public $incrementing = true; on any model extending a core table. Route-model binding needs this to work.
Writes bypass WordPress hooks. This is the biggest architectural gotcha. $post->save() does not fire save_post, transition_post_status, wp_insert_post_data, or any of the cache invalidation and third-party integrations (SEO plugins, sitemaps, page builders) hooked into them. Either:
wp_insert_post()/wp_update_post(), orDB::connection('wordpress')->transaction(function () use ($data) {
$post = Event::create($data);
do_action('save_post', $post->ID, get_post($post->ID), true);
});
If you go the second route, audit every plugin on the site that hooks save actions before shipping.
Capability checks are not inherited. Eloquent knows nothing about current_user_can(). Any query exposed through a REST route or admin screen still needs the same capability and nonce checks you'd write for WP_Query. The package gives you a data layer, not an authorization layer.
Post content is raw. post_content comes back unfiltered. If you're rendering it in Blade, you must decide explicitly: apply_filters('the_content', $post->post_content) to match front-end behavior (and accept that it's pre-escaped HTML), or wp_kses_post() for a safer subset. Never {!! !!} the raw column.
Serialized meta. Many plugins store arrays/objects in meta_value. Eloquent's $casts won't help on an EAV row; use maybe_unserialize() when reading meta values, and be defensive about objects.
Revisions and nav menu items pollute results. Post::all() includes post_type = 'revision' and nav_menu_item rows. Always scope by post type, even in quick scripts. preventLazyLoading won't save you here.
Multisite requires connection surgery. Eloquent caches the connection and its prefix. switch_to_blog() alone won't change anything. After switching, update the config and purge:
switch_to_blog($blogId);
config(['database.connections.wordpress.prefix' => $GLOBALS['wpdb']->prefix]);
DB::purge('wordpress');
Wrap this in a helper and always restore_current_blog().
The object cache is bypassed. Eloquent queries hit MySQL directly; they don't populate or read wp_cache_*. On a site with a persistent object cache, this means duplicated caching strategies and possible staleness right after a WordPress write. If read volume matters, put an explicit cache layer around your Eloquent queries rather than assuming WP's cache covers you.
Transactions vs. $wpdb. Eloquent transactions work, but anything writing through $wpdb inside the same request shares the connection — verify that $wpdb and Eloquent are pointed at the same host/socket, or your rollback won't cover both.
Package maturity. 36 stars and zero listed dependents means: no Stack Overflow corpus, occasional breaking changes between releases, and Acorn/Laravel version constraints you need to check before upgrading. Pin an exact version in composer.json, read the changelog, and prefer wrapping the package's models in your own App\Models\* classes so a future swap (Corcel, or hand-rolled models) is a one-directory change rather than a site-wide refactor.
Testing. There's no factory or test harness here. Use wp-env or the WordPress PHPUnit suite, wrap each test in a transaction that you roll back (or reset with wp_delete_post(..., true)), and assert against get_post() for writes so you catch the "bypassed save_post" class of bug early.
How can I help you explore Laravel packages today?