tiny-pixel/acorn-db
Acorn DB brings Laravel-style database tooling to WordPress. Use Eloquent models, migrations, and a fluent query builder to manage custom tables and query data with a familiar, expressive API inside Sage themes and Acorn-powered plugins.
tiny-pixel/acorn-db gives an Acorn (Roots/Sage) site an Eloquent query layer over the standard WordPress tables. It does not replace WordPress — it reads (and to a degree writes) wp_posts, wp_users, wp_terms, wp_comments, etc. through illuminate/database.
Install and boot
composer require tiny-pixel/acorn-db
Acorn's package discovery registers the service provider automatically; if you are on an older Acorn, add TinyPixel\AcornDB\Providers\AcornDBServiceProvider::class to config/app.php → providers. The package builds an Eloquent capsule from WordPress' existing database credentials (DB_* constants / $wpdb), so there is usually no separate config/database.php to fill in. If a publishable config is present:
wp acorn vendor:publish --tag=acorn-db
Where to look first
src/Model/ — the shipped models (Post, Page, Attachment, User, Term, Comment). This is the real documentation; read the booted()/scope methods before trusting any README.Capsule, setAsGlobal, bootEloquent) and whether it reuses $wpdb->dbh.config/ — for table prefix handling and any connection overrides.First use case
Swap a WP_Query loop in a controller or Blade view for a model query:
use TinyPixel\AcornDB\Model\Post;
$posts = Post::with('thumbnail')
->where('post_date', '>', now()->subMonth())
->orderByDesc('post_date')
->take(6)
->get();
Then in Blade: @foreach ($posts as $post) <h2>{{ $post->post_title }}</h2> @endforeach. That single refactor — typed collections, eager loading, no setup_postdata()/wp_reset_postdata() bookkeeping — is the day-one payoff.
Treat models as read models first. The most reliable use of this package is querying content for templates, REST endpoints, and block render callbacks. Use it where you would otherwise write get_posts() with meta_query/tax_query arrays that are painful to read.
Scopes over raw where clauses. The shipped Post model constrains by post_type and (typically) post_status. Lean on that:
// Published posts of the default type
Post::where('post_title', 'like', "%{$q}%")->get();
// Escape the built-in status/type constraints deliberately
Post::withoutGlobalScopes()
->where('post_type', 'event')
->where('post_status', 'publish')
->get();
Custom post types and taxonomies. Subclass the base model rather than re-configuring one:
namespace App\Models;
use TinyPixel\AcornDB\Model\Post;
class Event extends Post
{
protected $postType = 'event'; // confirm the property name in the parent
protected $with = ['meta'];
}
Register these in a small service provider or a Models directory so controllers never reference the vendor namespace directly — it keeps the package swappable.
Eager load relationships aggressively. author, meta, terms, thumbnail all resolve to extra queries. Post::with(['author', 'terms'])->get() turns an N+1 into three queries. For a 50-post archive this is the difference between 5ms and 300ms.
Meta via relations. Rather than get_post_meta() scattered through templates, expose accessors on your subclass:
public function getSubtitleAttribute(): ?string
{
return $this->meta->firstWhere('meta_key', 'subtitle')?->meta_value;
}
Terms and filtering. belongsToMany through term_relationships/term_taxonomy is where Eloquent pays for itself:
Post::whereHas('terms', fn ($q) => $q->where('slug', 'featured'))->get();
Note this is not the same caching behaviour as WP_Query's taxonomy cache — see gotchas.
Read/write split. Use Eloquent for reads; keep wp_insert_post(), wp_update_post(), wp_set_object_terms() for writes. Where you must write through the model, wrap it in a repository method and follow up with clean_post_cache($id) so WordPress' object cache and WP_Query see the change.
Raw SQL escape hatch. The capsule registers a global connection, so Illuminate\Support\Facades\DB works for tables the package has no model for (wp_options, wp_usermeta, custom plugin tables):
DB::table('options')->where('option_name', 'blogname')->value('option_value');
Blade/controller integration. Return collections directly from Sage controllers; they are Arrayable and loop cleanly. For anything needing the_title()/the_content() filters, hydrate the WP post object (get_post($model->ID)) or call apply_filters('the_content', $model->post_content) explicitly — do not assume the model applies content filters for you.
ID, not id; post_date, not created_at; $timestamps = false. $model->id will be null and $model->created_at will throw or return nothing. Route-model binding by ID needs getRouteKeyName() overridden.->withoutGlobalScopes() is the first thing to try, and ->toSql() with bindings the second.php acorn migrate against them, and don't assume column types — wp_posts has been MyISAM historically, so transactions may silently not roll back. Verify the storage engine before writing transaction-dependent code.$model->save() skips save_post, revisions, clean_post_cache, term counting, and search index plugins. This is the single biggest source of "the post exists but nothing renders" bugs. Prefer the WP API for mutation.switch_to_blog() the Eloquent connection still points at the old prefix unless you reconnect. Explicitly purge/reconfigure the capsule on blog switch, or scope queries manually. If multisite is central to the project, budget time to prove this out before committing to the package.$wpdb->dbh, a DB::disconnect() or a long-running worker (queue, WP-CLI loop) can leave you with a dead handle. Reconnect in long-lived processes.meta_value comes back as a raw string. Run maybe_unserialize() yourself; Eloquent casts will not do it for you.wp_update_term_count() lag until WP runs. Fine for display, dangerous for anything transactional.DB::connection()->enableQueryLog()) or DB::listen() in a service provider during development; that is how you catch both N+1s and scope surprises. ->dd()/->dump() on the builder is available through the Illuminate macros.illuminate/database. Pin the version and read the changelog on every update; the blast radius of an Acorn or Illuminate major bump is entirely yours. If you only need meta queries, weigh WP_Query + a small repository over adding a dependency that few others exercise.SiteScope for multisite). Keep vendor-class references confined to that one layer so a future package swap is a refactor of five files, not fifty.How can I help you explore Laravel packages today?