statamic-eloquent-driver

star 0

CRITICAL context whenever reading, writing, or reasoning about Statamic content in this project. The `statamic/eloquent-driver` package is installed and configured, so content lives in MySQL — NOT in flat files under `content/` as Statamic defaults to. Use whenever inspecting entries/collections/blueprints/globals/taxonomies/assets/forms/users, or wondering why CP edits don't appear in flat files.

ryanmortier By ryanmortier schedule Updated 6/12/2026

name: statamic-eloquent-driver description: CRITICAL context whenever reading, writing, or reasoning about Statamic content in this project. The statamic/eloquent-driver package is installed and configured, so content lives in MySQL — NOT in flat files under content/ as Statamic defaults to. Use whenever inspecting entries/collections/blueprints/globals/taxonomies/assets/forms/users, or wondering why CP edits don't appear in flat files. metadata: version: "1.1.0" domain: cms-storage triggers: statamic eloquent, eloquent driver, statamic content, entries table, collections table, blueprints table, stache, please eloquent, content export, users database

Statamic Eloquent Driver (this project)

This is the single most important non-obvious thing about this codebase. Default Statamic stores content as flat YAML/Markdown files. This project does NOT — it uses statamic/eloquent-driver (^5.9), and every content type is routed to a database model. Users are in the database too (see below).

The official Boost guidelines and Statamic docs both say "Statamic stores content in Markdown files." That is not true for this project. Trust the DB over the content/ directory.

How to verify

config/statamic/eloquent-driver.php — every section has 'driver' => 'eloquent':

  • addon_settings, asset_containers, assets, blueprints, collections, collection_trees, entries, fieldsets, forms, form_submissions, global_sets, global_set_variables, navigations, navigation_trees, revisions, sites, taxonomies, terms, tokens

All Eloquent-driver migrations have run (php artisan migrate:status). DB: MySQL alignedlife (see .env).

What lives where

Content type DB table Eloquent model Statamic class
Entries entries Statamic\Eloquent\Entries\UuidEntryModel Statamic\Eloquent\Entries\Entry
Collections collections Statamic\Eloquent\Collections\CollectionModel core
Collection trees trees (shared with nav trees) Statamic\Eloquent\Structures\TreeModel Statamic\Eloquent\Structures\CollectionTree
Blueprints blueprints Statamic\Eloquent\Fields\BlueprintModel core
Fieldsets fieldsets (data col is longtext, not json — see gotcha 7) Statamic\Eloquent\Fields\FieldsetModel core
Globals global_sets Statamic\Eloquent\Globals\GlobalSetModel core
Global variables global_set_variables Statamic\Eloquent\Globals\VariablesModel core
Taxonomies taxonomies (in package) core
Terms taxonomy_terms (in package) core
Asset containers asset_containers Statamic\Eloquent\Assets\AssetContainerModel core
Assets (metadata) assets_meta Statamic\Eloquent\Assets\AssetModel Statamic\Eloquent\Assets\Asset
Forms forms Statamic\Eloquent\Forms\FormModel core
Form submissions form_submissions Statamic\Eloquent\Forms\SubmissionModel core
Navigations navigations Statamic\Eloquent\Structures\NavModel core
Nav trees trees (shared with collection trees) core core
Revisions revisions Statamic\Eloquent\Revisions\RevisionModel core
Sites sites core core
Addon settings (incl. SEO Pro site defaults) addon_settings Statamic\Eloquent\AddonSettings\AddonSettingsModel core
Users users (uuid PK) App\Models\User Statamic\Auth\Eloquent\User
Roles / groups roles, groups (+ role_user, group_user pivots) Statamic\Auth\Eloquent\RoleModel / UserGroupModel core

Users are eloquent (since 2026-06)

  • config/statamic/users.php: 'repository' => 'eloquent', tables.roles => 'roles', tables.groups => 'groups'.
  • config/auth.php: the users provider is eloquent with App\Models\User.
  • App\Models\User uses the HasUuids trait — users.id is a char(36) uuid column (Statamic's importer requires uuid user ids). The scaffold migrations were edited accordingly (0001_…create_users_table uses uuid('id')->primary(), sessions/role_user/group_user use foreignUuid).
  • The model casts super => boolean, preferences => array, last_login => datetime, two_factor_confirmed_at => datetime — required, or saves fail. The 2FA cast is load-bearing: Statamic's ConfirmTwoFactorAuthentication saves now()->timestamp (raw int) onto the user, and without the cast MySQL strict mode rejects it (Incorrect datetime value … for column 'two_factor_confirmed_at') — the CP 2FA setup screen 500s on POST cp/two-factor/confirm (observed on production 2026-06-12). Statamic only auto-adds this cast via an update script when upgrading through 6.0.0-beta.4; fresh 6.x installs must add it by hand. Regression test: tests/Feature/TwoFactorEnrollmentTest.php.
  • Statamic\Auth\Eloquent\User::set($key, null) unsets the model attribute instead of writing null, so the column keeps its old value on save. To null a column go through ->remove($key) (what Statamic's DisableTwoFactorAuthentication does) or assign null on the underlying model directly.
  • The legacy flat-file user yaml was removed from the repo on 2026-06-11 (it committed a bcrypt password hash; the hash remains in git history but the password was rotated the same day, so it's inert). Users are provisioned with php please make:user --super — they are deliberately NOT part of the content snapshot/import workflow.
  • App\Models\User overrides sendPasswordResetNotification / sendActivateAccountNotification with Statamic's notifications. Laravel's password broker notifies the raw model, and the framework default links to the undefined password.reset route — without the overrides, CP "Forgot password" 500s with RouteNotFoundException. Regression test: tests/Feature/CpPasswordResetTest.php.

Key config detail: map_data_to_columns: false

For the entries config in eloquent-driver.php, map_data_to_columns is false. That means:

  • Blueprint fields are NOT promoted to dedicated columns
  • All custom field data lives in the JSON data column on entries
  • Querying a specific field requires JSON_EXTRACT / -> syntax, e.g.:
    SELECT id, data->'$.title' FROM entries WHERE collection = 'pages';
    
  • Same goes for global_set_variables.data, taxonomy_terms.data, etc.

If map_data_to_columns is ever flipped to true, this skill must be updated and existing entries re-migrated.

How to inspect content

Prefer the Boost database-query MCP tool over reading flat files. Examples:

-- List all entries in a collection (no `status` column; the boolean is `published`)
SELECT id, slug, published, data->'$.title' AS title
FROM entries WHERE collection = 'pages';

-- See blueprint definitions
SELECT handle, namespace, data FROM blueprints;

-- Inspect global variables
SELECT g.handle, v.locale, v.data
FROM global_sets g JOIN global_set_variables v ON v.handle = g.handle;

Use database-schema first to confirm the exact column names — they evolve with the driver package.

Importing/exporting between flat files and DB

The please eloquent:* commands round-trip content. Run php please list and look under the eloquent group.

The blessed snapshot workflow is composer content:export — it runs all the export commands (sites, blueprints+fieldsets, collections+trees, entries, navs, globals, taxonomies, assets, forms-without-submissions, addon settings) so DB content can be committed to git. Run it before committing whenever content/blueprints changed in the CP. Submissions are deliberately excluded (PII).

The restore workflow is composer content:import — it rebuilds the DB from the snapshots (fresh machine, or after hand-editing snapshot files). composer setup runs it automatically. The script's quirks are deliberate; don't reorder it casually:

  • eloquent:import-collections runs twice: collection trees are validated on save against the entry repository, so on the first pass (no entries yet) every node is stripped and the trees import empty. The second pass, after import-entries, lands the full trees and recomputes the order column.
  • The tinker Entry::all()->each->save() step recomputes the cached entries.uri column (root page /, {mount}-prefixed URIs) once the trees exist, between two stache:clear calls.
  • Verified end-to-end on 2026-06-11 against a scratch DB: identical URIs to the live DB for all entries.

Gotchas learned the hard way:

  1. eloquent:import-entries has NO --force flag (most other import/export commands have it). Piping output (e.g. through tail) masks the non-zero exit code, so a bad flag fails silently mid-chain. Check each command with --help first.
  2. Entry URIs are cached in the entries.uri column at save time. If an entry's URI depends on something imported later (a {mount} page, a structure tree), the column goes stale and routing breaks even though $entry->uri() computes correctly. Fix: re-save the entries (Statamic\Facades\Entry::all()->each->save() in tinker) and php please stache:clear.
  3. php please stache:clear does NOT flush the file-Stache the import commands read. With the eloquent driver bound, stache:clear trims the eloquent-backed stores; the eloquent:import-* commands then swap in the file repositories, whose Stache index persists separately in the Laravel cache. A stale index makes imports silently skip new files AND re-import entries whose files were deleted. Run php artisan cache:clear after adding/renaming/deleting flat files, before importing. (Observed 2026-06-11: with only stache:clear, eloquent:import-entries skipped 13 new testimonial files and resurrected 3 placeholder entries that had just been deleted from both disk and DB; cache:clear fixed it.)
  4. Exports write the canonical normalized YAML (e.g. blueprints come back in tabs: format, entries gain parent/updated_at, nav trees gain branch ids). Hand-authored files get rewritten on the first export — that's expected; the exported form is the authoritative seed format.
  5. eloquent:export-collections drops the inject cascade (upstream gap in the export command — it maps every settings key except inject). All four collection yamls carry inject blocks: testimonials.yaml (inject.seo: false), pages.yaml (inject.seo.image: '@seo:og_image'), services.yaml (inject.seo.image: '@seo:og_image'), and articles.yaml (inject.seo.image: '@seo:cover_image'). After running content:export, run grep -l "inject:" content/collections/*.yaml — the output must list all four files. Restore any missing inject blocks manually before committing.
  6. Data::find() with an id that matches no entry 500s under this driver: the lookup falls through to the eloquent TermRepository, which does explode('::', $id)[1] and crashes on UUIDs. Practical consequence: don't use {{ link id="…" }} in templates for entries that could ever be missing — templates here use stable hardcoded paths (/services, /resources, /contact) as a deliberate frozen-slug choice.
  7. MySQL json columns normalise object key order (length-then-bytes sort). The fieldsets.data column was originally json; MySQL re-sorted the keys in page-builder set definitions, which scrambled set order in the CP picker relative to the yaml. This was fixed by migrating fieldsets.data to longtext (migration 2026_06_11_192236_change_fieldsets_data_column_to_longtext), which preserves insertion order. Other eloquent-driver tables still use json columns — do not rely on object-key order in those (e.g. blueprints.data); arrays within json values are safe.
  8. Imports upsert, never prune. Deleting a snapshot file does not delete the DB row. To retire an entry: delete the row via the facade (Entry::find($id)?->delete() in tinker), remove the file, and php artisan cache:clear before the next import — otherwise the stale file-Stache resurrects it (see gotcha 3). After an entries-only change, the targeted sequence is: cache:cleareloquent:import-entriesstache:clear.
  9. PHPUnit fixtures can leak into the dev MySQL DB (observed once, 2026-06-12). Root cause: shell-exported DB_CONNECTION/DB_DATABASE land in $_SERVER, which Laravel's env() reads BEFORE anything phpunit.xml sets — even <env force="true"/> cannot override them, so a process with those vars exported runs tests against MySQL. Guarded since 2026-06-12: tests/TestCase.php::setUpTraits() throws (ensureSafeTestDatabase, unit-tested in tests/Unit/TestDatabaseGuardTest.php) unless the connection is in-memory sqlite, firing after app boot but before RefreshDatabase migrates — verified end-to-end with DB_CONNECTION=mysql vendor/bin/phpunit refusing loudly and leaving MySQL untouched. If the guard ever trips: unset the DB_* shell exports and php artisan config:clear. Symptoms of a historical leak: stray builder-page/duplicate home entries surface on export (as home.1.md), nav trees come back empty (live nav collapses to a lone "Home"), nav settings lose collections/max_depth, and the export stamps a bogus parent: onto home.md. Repair: delete stray entries via the facade, eloquent:import-navs --force, eloquent:import-collections --force (tree re-import prunes nodes whose entries are gone), clear home's parent ($h = Entry::find('home'); $h->remove('parent'); $h->save();), re-save all entries, stache:clear, then re-run composer content:export and confirm the diff is normalization-only. Hand-rolled targeted import sequences should include eloquent:import-navs --force whenever navs may be dirty — the composer script covers navs, ad-hoc sequences often forget them.
  10. eloquent:import-assets --force clobbers DB asset data from stale .meta yamls (observed 2026-06-12: five freshly set CTA alt texts wiped to null). Mechanism: Asset::set('alt', …)->save() via tinker can update the DB row while the on-disk public/assets/.meta/<file>.yaml keeps data: {} (the yaml was generated empty when the importer first scanned the file); a later import-assets --force then seeds the DB from that empty yaml. After setting asset data via facades, run php please eloquent:export-assets --force and grep alt: public/assets/.meta/*.yaml to confirm the yamls carry the new keys (the export only backfills what the DB still holds — run it BEFORE any import has a chance to clobber), then commit the populated yamls. Disk meta is the import seed; DB-only asset data does not survive a re-import.

Do not edit flat files and expect the CP to pick up changes — you must run the corresponding import command (then stache:clear).

The content/ directory in this project

The flat files under content/, resources/blueprints/, resources/fieldsets/, resources/forms/, and resources/addons/seo-pro.yaml are git snapshots produced by composer content:export. The control panel edits DB rows, not these files. To change seeds: edit file → php please eloquent:import-* → verify rows; or edit in CP → composer content:export.

The Stache

Statamic's "Stache" is its in-memory content index. With the Eloquent driver, the Stache caches DB reads. If queries appear to return stale data after a direct DB edit, clear it:

php please stache:clear

STATAMIC_STACHE_WATCHER=auto is set in .env — Statamic watches file changes, but DB-only changes won't trigger the watcher. The watcher does not pick up direct SQL writes.

Writing tests that touch content

Use RefreshDatabase (tests run on SQLite in-memory; all eloquent-driver migrations run fine there). Build content through Statamic facades — Collection::make(), Entry::make(), Nav::make() — never raw model writes, so events and the driver fire. See tests/Feature/HomePageTest.php for the minimal homepage fixture: collection + root entry + tree, then re-save the entry so the cached URI computes, and create the main/footer navs the layout references (the {{ nav:x }} tag throws NavigationNotFoundException when the nav doesn't exist).

Blink cache in fieldset tests (gotcha A)

FieldsetRepository caches the full fieldset collection under Blink key 'eloquent-fieldsets' during save() — specifically, the savefind call populates Blink before the DB row is committed, and updateModel only forgets the per-item key. If a test calls Fieldset::make(...)->save() more than once (e.g. in setUp()), subsequent Fieldset::find() calls return nothing because the stale Blink entry shadows the new DB rows.

Fix: call \Statamic\Facades\Blink::forget('eloquent-fieldsets') immediately after each Fieldset::save() call in tests. See the setUp() methods in:

  • tests/Feature/ArticleCoverImageTest.php
  • tests/Feature/PageBuilderImageSetsTest.php

When this skill is wrong

Update this file whenever:

  • config/statamic/eloquent-driver.php changes a driver assignment (e.g. switches one type back to flat-file)
  • map_data_to_columns is toggled
  • The eloquent-driver package version changes (currently ^5.9) — check release notes for schema changes
  • New columns appear on the content tables
  • The composer content:export script changes what it exports

References

Install via CLI
npx skills add https://github.com/ryanmortier/alignedlife --skill statamic-eloquent-driver
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator