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: theusersprovider iseloquentwithApp\Models\User.App\Models\Useruses theHasUuidstrait —users.idis achar(36)uuid column (Statamic's importer requires uuid user ids). The scaffold migrations were edited accordingly (0001_…create_users_tableusesuuid('id')->primary(), sessions/role_user/group_user useforeignUuid).- 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'sConfirmTwoFactorAuthenticationsavesnow()->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 onPOST cp/two-factor/confirm(observed on production 2026-06-12). Statamic only auto-adds this cast via an update script when upgrading through6.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'sDisableTwoFactorAuthenticationdoes) 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\UseroverridessendPasswordResetNotification/sendActivateAccountNotificationwith Statamic's notifications. Laravel's password broker notifies the raw model, and the framework default links to the undefinedpassword.resetroute — without the overrides, CP "Forgot password" 500s withRouteNotFoundException. 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
datacolumn onentries - 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-collectionsruns 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, afterimport-entries, lands the full trees and recomputes theordercolumn.- The tinker
Entry::all()->each->save()step recomputes the cachedentries.uricolumn (root page/,{mount}-prefixed URIs) once the trees exist, between twostache:clearcalls. - 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:
eloquent:import-entrieshas NO--forceflag (most other import/export commands have it). Piping output (e.g. throughtail) masks the non-zero exit code, so a bad flag fails silently mid-chain. Check each command with--helpfirst.- Entry URIs are cached in the
entries.uricolumn 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) andphp please stache:clear. php please stache:cleardoes NOT flush the file-Stache the import commands read. With the eloquent driver bound,stache:cleartrims the eloquent-backed stores; theeloquent: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. Runphp artisan cache:clearafter adding/renaming/deleting flat files, before importing. (Observed 2026-06-11: with onlystache:clear,eloquent:import-entriesskipped 13 new testimonial files and resurrected 3 placeholder entries that had just been deleted from both disk and DB;cache:clearfixed it.)- Exports write the canonical normalized YAML (e.g. blueprints come back in
tabs:format, entries gainparent/updated_at, nav trees gain branchids). Hand-authored files get rewritten on the first export — that's expected; the exported form is the authoritative seed format. eloquent:export-collectionsdrops theinjectcascade (upstream gap in the export command — it maps every settings key exceptinject). All four collection yamls carryinjectblocks:testimonials.yaml(inject.seo: false),pages.yaml(inject.seo.image: '@seo:og_image'),services.yaml(inject.seo.image: '@seo:og_image'), andarticles.yaml(inject.seo.image: '@seo:cover_image'). After runningcontent:export, rungrep -l "inject:" content/collections/*.yaml— the output must list all four files. Restore any missinginjectblocks manually before committing.Data::find()with an id that matches no entry 500s under this driver: the lookup falls through to the eloquentTermRepository, which doesexplode('::', $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.- MySQL
jsoncolumns normalise object key order (length-then-bytes sort). Thefieldsets.datacolumn was originallyjson; 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 migratingfieldsets.datatolongtext(migration2026_06_11_192236_change_fieldsets_data_column_to_longtext), which preserves insertion order. Other eloquent-driver tables still usejsoncolumns — do not rely on object-key order in those (e.g.blueprints.data); arrays within json values are safe. - 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, andphp artisan cache:clearbefore the next import — otherwise the stale file-Stache resurrects it (see gotcha 3). After an entries-only change, the targeted sequence is:cache:clear→eloquent:import-entries→stache:clear. - PHPUnit fixtures can leak into the dev MySQL DB (observed once, 2026-06-12). Root cause: shell-exported
DB_CONNECTION/DB_DATABASEland in$_SERVER, which Laravel'senv()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 intests/Unit/TestDatabaseGuardTest.php) unless the connection is in-memory sqlite, firing after app boot but beforeRefreshDatabasemigrates — verified end-to-end withDB_CONNECTION=mysql vendor/bin/phpunitrefusing loudly and leaving MySQL untouched. If the guard ever trips: unset theDB_*shell exports andphp artisan config:clear. Symptoms of a historical leak: straybuilder-page/duplicatehomeentries surface on export (ashome.1.md), nav trees come back empty (live nav collapses to a lone "Home"), nav settings losecollections/max_depth, and the export stamps a bogusparent:ontohome.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-runcomposer content:exportand confirm the diff is normalization-only. Hand-rolled targeted import sequences should includeeloquent:import-navs --forcewhenever navs may be dirty — the composer script covers navs, ad-hoc sequences often forget them. eloquent:import-assets --forceclobbers DB asset data from stale.metayamls (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-diskpublic/assets/.meta/<file>.yamlkeepsdata: {}(the yaml was generated empty when the importer first scanned the file); a laterimport-assets --forcethen seeds the DB from that empty yaml. After setting asset data via facades, runphp please eloquent:export-assets --forceandgrep alt: public/assets/.meta/*.yamlto 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 save → find 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.phptests/Feature/PageBuilderImageSetsTest.php
When this skill is wrong
Update this file whenever:
config/statamic/eloquent-driver.phpchanges a driver assignment (e.g. switches one type back to flat-file)map_data_to_columnsis 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:exportscript changes what it exports
References
- Package repo: https://github.com/statamic/eloquent-driver
- Statamic docs on Eloquent driver: https://statamic.dev/installing-the-eloquent-driver
- Boost
search-docswithpackages=['statamic/cms']for related queries