Eloquent ORM in WordPress via Acorn
When to Use Eloquent vs WordPress Functions
| Use case |
Use Eloquent |
Use WordPress functions |
| Custom tables (event logs, testimonials, form entries) |
Yes |
No |
| Complex joins across custom tables |
Yes |
No |
| Relationships between custom models |
Yes |
No |
| Post types, taxonomies, menus |
No |
Yes (WP_Query, get_posts) |
| User management, roles, capabilities |
No |
Yes (wp_insert_user, get_userdata) |
| Options, transients |
No |
Yes (get_option, get_transient) |
Complex read queries on wp_posts |
Read-only, carefully |
Prefer WP_Query first |
Rule: WordPress-managed content goes through WordPress functions so hooks fire. Custom application data goes through Eloquent.
Quick Start — Scripts
# Create a model (PascalCase name required)
bash skills/acorn-eloquent/scripts/create-model.sh Testimonial
# Create a model with a migration
bash skills/acorn-eloquent/scripts/create-model.sh EventLog --migration
# Run pending migrations
bash skills/acorn-eloquent/scripts/run-migration.sh
# Run with additional flags (passed through to lando acorn migrate)
bash skills/acorn-eloquent/scripts/run-migration.sh --seed
bash skills/acorn-eloquent/scripts/run-migration.sh --step=2
Scripts: scripts/create-model.sh · scripts/run-migration.sh
Models
Models live in app/Models/. Always declare $table explicitly with the WordPress prefix.
class Testimonial extends Model
{
use HasFactory;
protected $table = 'wp_testimonials';
protected $fillable = ['author_name', 'company', 'body', 'rating', 'is_featured', 'published_at'];
protected $casts = [
'rating' => 'integer',
'is_featured' => 'boolean',
'published_at' => 'datetime',
'metadata' => 'array',
];
}
See references/models.md for:
- Full property reference (
$primaryKey, $keyType, $incrementing)
- Custom table example
- WP core table mirror example with read-only guard
- Accessors/mutators with
Attribute::make()
Assets
Boilerplate templates with {{PLACEHOLDER}} tokens:
- model-custom-table.php.tpl — Custom table model with
$table, $primaryKey, $timestamps, $fillable, $casts. Replace {{CLASS_NAME}}, {{TABLE_NAME}}.
- model-wp-mirror.php.tpl — Read-only WP core table mirror with
$primaryKey = 'ID', $timestamps = false, write guards. Replace {{CLASS_NAME}}, {{WP_TABLE}}.
Migrations
lando acorn make:migration create_testimonials_table
lando acorn migrate
lando acorn migrate:status
lando acorn migrate:rollback
return new class extends Migration
{
public function up(): void
{
Schema::create('wp_testimonials', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id')->nullable();
$table->string('author_name');
$table->text('body');
$table->unsignedTinyInteger('rating')->default(5);
$table->boolean('is_featured')->default(false);
$table->timestamp('published_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('wp_testimonials');
}
};
See references/migrations.md for:
- Column types reference table
- Adding columns to existing tables
- Full migration commands reference
- Naming conventions
Relationships
class Testimonial extends Model
{
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class, 'wp_testimonial_tag', 'testimonial_id', 'tag_id');
}
}
Eager load to avoid N+1:
Testimonial::with('author', 'tags')->get();
See references/relationships.md for:
wp_posts → wp_postmeta (hasMany with ID key)
wp_terms → wp_termmeta
- Wrapping
wp_users for read-only relationships
- Polymorphic relationships (event logs)
Query Scopes
public function scopePublished(Builder $query): void
{
$query->whereNotNull('published_at')
->where('published_at', '<=', now());
}
public function scopeMinRating(Builder $query, int $rating): void
{
$query->where('rating', '>=', $rating);
}
Chain fluently:
Testimonial::published()->featured()->minRating(4)->with('author')->get();
See references/query-scopes.md for:
- Global scopes with
booted() registration
withoutGlobalScope() usage
- Conditional scopes with
->when()
- Raw queries with safe binding
Factories
lando acorn make:factory TestimonialFactory
class TestimonialFactory extends Factory
{
protected $model = Testimonial::class;
public function definition(): array
{
return [
'author_name' => fake()->name(),
'body' => fake()->paragraphs(2, asText: true),
'rating' => fake()->numberBetween(1, 5),
'is_featured' => fake()->boolean(20),
'published_at' => fake()->optional(0.8)->dateTimeBetween('-1 year'),
];
}
}
Testimonial::factory()->count(10)->create();
Testimonial::factory()->featured()->count(3)->create();
Seed:
lando acorn db:seed --class=TestimonialSeeder
See references/factories.md for:
- Factory states (
featured(), unpublished())
fake() helper reference
- Seeder structure and
DatabaseSeeder registration
- WordPress context in seeders
Critical Rules
- Set
$timestamps = false for WP core tables (wp_posts, wp_users) — they have no created_at/updated_at.
- Use
$table explicitly for every model — avoid Eloquent's table name guessing with WP prefix.
- Never use Eloquent to create/update WP core table rows in production — use
wp_insert_post(), wp_update_post(), update_post_meta() so hooks fire.
- Run migrations via
lando acorn migrate — never write raw SQL directly.
- Use
$fillable explicitly — never use $guarded = []; list only known, safe attributes.
- Eager load relationships — use
::with() or ->load() to prevent N+1 queries in loops.
Verification
- Run
lando acorn migrate:status and confirm all migrations show as "Ran".
- Use
lando acorn tinker to verify: App\Models\Testimonial::first() returns data from the correct table.
- Verify relationships:
App\Models\Testimonial::with('author')->first() returns related data without errors.
Failure modes
See references/troubleshooting.md for all failure modes and escalation paths.
References
Deep content loaded on demand — zero tokens until needed.
- models.md — Model class,
$table, $fillable, $casts, custom primary key, $timestamps = false for WP tables, accessors.
- migrations.md —
Schema::create, column types reference, up()/down(), running via lando acorn migrate.
- relationships.md —
hasMany, belongsTo, belongsToMany, WP-specific: Post → PostMeta, Term → TermMeta, polymorphic.
- factories.md —
HasFactory, definition(), fake() reference, states, seeding with lando acorn db:seed.
- query-scopes.md — Local scopes, global scopes,
withoutGlobalScope(), conditional ->when(), raw queries.
- wp-tables.md — Integrating Eloquent models with WordPress core tables (
wp_posts, wp_postmeta, etc.), read-only reporting, canonical patterns.
- troubleshooting.md — Common errors, debugging commands, escalation paths for persistent issues.
Escalation
- N+1 performance issues: see
superpowers-sage:wp-performance for eager loading strategies.
- Writing to WP core tables: use WP API functions —
wp_insert_post(), update_post_meta(), wp_insert_user().
1---2name: superpowers-sage-acorn-eloquent3description: Eloquent, model, migration, relationship, hasMany, belongsTo, belongsToMany, Acorn Eloquent, custom table, wp_posts, wp_users, wp_postmeta, factory, query scope, scopePublished, global scope, make:model, make:migration, lando acorn migrate, $fillable, $casts, $timestamps, $primaryKey, HasFactory, db:seed, seeder — Eloquent ORM for custom and WordPress tables via Acorn4---56# Eloquent ORM in WordPress via Acorn78## When to Use Eloquent vs WordPress Functions910| Use case | Use Eloquent | Use WordPress functions |11|---|---|---|12| Custom tables (event logs, testimonials, form entries) | Yes | No |13| Complex joins across custom tables | Yes | No |14| Relationships between custom models | Yes | No |15| Post types, taxonomies, menus | No | Yes (`WP_Query`, `get_posts`) |16| User management, roles, capabilities | No | Yes (`wp_insert_user`, `get_userdata`) |17| Options, transients | No | Yes (`get_option`, `get_transient`) |18| Complex read queries on `wp_posts` | Read-only, carefully | Prefer `WP_Query` first |1920**Rule:** WordPress-managed content goes through WordPress functions so hooks fire. Custom application data goes through Eloquent.2122## Quick Start — Scripts2324```bash25# Create a model (PascalCase name required)26bash skills/acorn-eloquent/scripts/create-model.sh Testimonial2728# Create a model with a migration29bash skills/acorn-eloquent/scripts/create-model.sh EventLog --migration3031# Run pending migrations32bash skills/acorn-eloquent/scripts/run-migration.sh3334# Run with additional flags (passed through to lando acorn migrate)35bash skills/acorn-eloquent/scripts/run-migration.sh --seed36bash skills/acorn-eloquent/scripts/run-migration.sh --step=237```3839Scripts: [`scripts/create-model.sh`](scripts/create-model.sh) · [`scripts/run-migration.sh`](scripts/run-migration.sh)4041## Models4243Models live in `app/Models/`. Always declare `$table` explicitly with the WordPress prefix.4445```php46class Testimonial extends Model47{48 use HasFactory;4950 protected $table = 'wp_testimonials';5152 protected $fillable = ['author_name', 'company', 'body', 'rating', 'is_featured', 'published_at'];5354 protected $casts = [55 'rating' => 'integer',56 'is_featured' => 'boolean',57 'published_at' => 'datetime',58 'metadata' => 'array',59 ];60}61```6263See [`references/models.md`](references/models.md) for:64- Full property reference (`$primaryKey`, `$keyType`, `$incrementing`)65- Custom table example66- WP core table mirror example with read-only guard67- Accessors/mutators with `Attribute::make()`6869## Assets7071Boilerplate templates with `{{PLACEHOLDER}}` tokens:7273- **[model-custom-table.php.tpl](assets/model-custom-table.php.tpl)** — Custom table model with `$table`, `$primaryKey`, `$timestamps`, `$fillable`, `$casts`. Replace `{{CLASS_NAME}}`, `{{TABLE_NAME}}`.74- **[model-wp-mirror.php.tpl](assets/model-wp-mirror.php.tpl)** — Read-only WP core table mirror with `$primaryKey = 'ID'`, `$timestamps = false`, write guards. Replace `{{CLASS_NAME}}`, `{{WP_TABLE}}`.7576## Migrations7778```bash79lando acorn make:migration create_testimonials_table80lando acorn migrate81lando acorn migrate:status82lando acorn migrate:rollback83```8485```php86return new class extends Migration87{88 public function up(): void89 {90 Schema::create('wp_testimonials', function (Blueprint $table) {91 $table->id();92 $table->unsignedBigInteger('user_id')->nullable();93 $table->string('author_name');94 $table->text('body');95 $table->unsignedTinyInteger('rating')->default(5);96 $table->boolean('is_featured')->default(false);97 $table->timestamp('published_at')->nullable();98 $table->timestamps();99 });100 }101102 public function down(): void103 {104 Schema::dropIfExists('wp_testimonials');105 }106};107```108109See [`references/migrations.md`](references/migrations.md) for:110- Column types reference table111- Adding columns to existing tables112- Full migration commands reference113- Naming conventions114115## Relationships116117```php118class Testimonial extends Model119{120 public function author(): BelongsTo121 {122 return $this->belongsTo(User::class, 'user_id');123 }124125 public function tags(): BelongsToMany126 {127 return $this->belongsToMany(Tag::class, 'wp_testimonial_tag', 'testimonial_id', 'tag_id');128 }129}130```131132Eager load to avoid N+1:133134```php135Testimonial::with('author', 'tags')->get();136```137138See [`references/relationships.md`](references/relationships.md) for:139- `wp_posts` → `wp_postmeta` (`hasMany` with `ID` key)140- `wp_terms` → `wp_termmeta`141- Wrapping `wp_users` for read-only relationships142- Polymorphic relationships (event logs)143144## Query Scopes145146```php147public function scopePublished(Builder $query): void148{149 $query->whereNotNull('published_at')150 ->where('published_at', '<=', now());151}152153public function scopeMinRating(Builder $query, int $rating): void154{155 $query->where('rating', '>=', $rating);156}157```158159Chain fluently:160161```php162Testimonial::published()->featured()->minRating(4)->with('author')->get();163```164165See [`references/query-scopes.md`](references/query-scopes.md) for:166- Global scopes with `booted()` registration167- `withoutGlobalScope()` usage168- Conditional scopes with `->when()`169- Raw queries with safe binding170171## Factories172173```bash174lando acorn make:factory TestimonialFactory175```176177```php178class TestimonialFactory extends Factory179{180 protected $model = Testimonial::class;181182 public function definition(): array183 {184 return [185 'author_name' => fake()->name(),186 'body' => fake()->paragraphs(2, asText: true),187 'rating' => fake()->numberBetween(1, 5),188 'is_featured' => fake()->boolean(20),189 'published_at' => fake()->optional(0.8)->dateTimeBetween('-1 year'),190 ];191 }192}193```194195```php196Testimonial::factory()->count(10)->create();197Testimonial::factory()->featured()->count(3)->create();198```199200Seed:201202```bash203lando acorn db:seed --class=TestimonialSeeder204```205206See [`references/factories.md`](references/factories.md) for:207- Factory states (`featured()`, `unpublished()`)208- `fake()` helper reference209- Seeder structure and `DatabaseSeeder` registration210- WordPress context in seeders211212## Critical Rules2132141. **Set `$timestamps = false`** for WP core tables (`wp_posts`, `wp_users`) — they have no `created_at`/`updated_at`.2152. **Use `$table` explicitly** for every model — avoid Eloquent's table name guessing with WP prefix.2163. **Never use Eloquent to create/update WP core table rows in production** — use `wp_insert_post()`, `wp_update_post()`, `update_post_meta()` so hooks fire.2174. **Run migrations via `lando acorn migrate`** — never write raw SQL directly.2185. **Use `$fillable` explicitly** — never use `$guarded = []`; list only known, safe attributes.2196. **Eager load relationships** — use `::with()` or `->load()` to prevent N+1 queries in loops.220221## Verification222223- Run `lando acorn migrate:status` and confirm all migrations show as "Ran".224- Use `lando acorn tinker` to verify: `App\Models\Testimonial::first()` returns data from the correct table.225- Verify relationships: `App\Models\Testimonial::with('author')->first()` returns related data without errors.226227## Failure modes228229See [`references/troubleshooting.md`](references/troubleshooting.md) for all failure modes and escalation paths.230231## References232233Deep content loaded on demand — zero tokens until needed.234235- **[models.md](references/models.md)** — Model class, `$table`, `$fillable`, `$casts`, custom primary key, `$timestamps = false` for WP tables, accessors.236- **[migrations.md](references/migrations.md)** — `Schema::create`, column types reference, `up()`/`down()`, running via `lando acorn migrate`.237- **[relationships.md](references/relationships.md)** — `hasMany`, `belongsTo`, `belongsToMany`, WP-specific: Post → PostMeta, Term → TermMeta, polymorphic.238- **[factories.md](references/factories.md)** — `HasFactory`, `definition()`, `fake()` reference, states, seeding with `lando acorn db:seed`.239- **[query-scopes.md](references/query-scopes.md)** — Local scopes, global scopes, `withoutGlobalScope()`, conditional `->when()`, raw queries.240- **[wp-tables.md](references/wp-tables.md)** — Integrating Eloquent models with WordPress core tables (`wp_posts`, `wp_postmeta`, etc.), read-only reporting, canonical patterns.241- **[troubleshooting.md](references/troubleshooting.md)** — Common errors, debugging commands, escalation paths for persistent issues.242243## Escalation244245- N+1 performance issues: see `superpowers-sage:wp-performance` for eager loading strategies.246- Writing to WP core tables: use WP API functions — `wp_insert_post()`, `update_post_meta()`, `wp_insert_user()`.