Moving content between environments
This document explains how to move a site's CMS content from one environment to another, for example from a developer's local site to a staging site, with the cms:export and cms:import commands. It describes what the exported bundle holds, the one-time setup on each environment, the steps for each move, how the import decides whether to create, update, skip or refuse each record, and how references between records survive the move. It is written for the developer who builds pages locally, together with the skin and blocks they depend on, and deploys both to a shared environment.
Neither command reads or writes a table that does not belong to the CMS. They are suitable for a site whose database also holds the host application's own data, which a database dump and restore would overwrite.
What the bundle is
cms:export writes a directory, the bundle, at the path in cms.bundle.path (default cms-bundle, resolved against the application root). Every record is one JSON file, so a change to one page is a change to one file in a git diff. The bundle is committed to the application's repository together with the code it depends on.
| Directory | One file per | Identity key | File name |
|---|---|---|---|
| content/ | Page, snippet and every application content type except layout areas | The content row's UUID | {uuid}.json |
| areas/ | Layout area (the content behind a shared layout region) | The area's slug, and its locale on a multilingual site | {slug}.json or {slug}.{locale}.json |
| media/ | Media library item | The SHA-256 of the original file | {sha256}.json, and {sha256}.{ext} with --with-files |
| forms/ | Form, with its steps and fields | cmsforms.slug | {slug}.json |
| menus/ | Menu, with its item tree | cmsmenus.handle | {handle}.json |
| tags/ | Tag | cmstags.slug | {slug}.json |
| redirects/ | Redirect created by hand in the panel (source = manual) | The locale and the frompath | MD5 of the key |
| site/ | The site settings row | none | site.json |
| manifest.json | The bundle | none | Package version, export time and record count per directory |
Each file holds an envelope with four keys. type is the directory name. key is the identity key. data is the record, with environment-specific ids replaced as described in References between records. source appears only in media files and holds the disk and path of the original file on the exporting environment.
A content file holds two versions. published is the published version, or null when the record has never been published. draft is a version newer than the published one, or null when there are no unpublished changes. Version history is not exported.
The following data is never exported, because it is specific to one environment or encrypted with that environment's APP_KEY:
- Form submissions, form drafts, users, API tokens and MCP tokens.
- The
webhook_urlandwebhook_api_keycolumns of each form. The key is encrypted with the source'sAPP_KEY, and a staging site must not post submissions to the production webhook. - The
site_settingscolumnsanalytics,webhooks,api_origins,notifications,stripe_publishable_key,stripe_secret_keyandstripe_webhook_secret. - Redirects whose
sourceisauto_renameorimport. The import's own path changes writeauto_renameredirects on the target. - The scheduling columns
scheduled_publish_atandscheduled_unpublish_at. The target keeps its own values. - Media conversions (
thumb,medium,large). The target generates them.
One-time setup
- In
config/cms.php, check the three keys of thebundlesection:
bundle.path: the bundle directory. The default,cms-bundle, places it at the application root.bundle.application_keys: keys in block data that hold the application's own integer ids, such ascategory_idin a block that lists one category of a product catalog. See References between records.bundle.draft_environments: environments in whichcms:importwrites drafts only. The default is['production']. See Drafts and production.
- In each environment's
.env, when every environment stores media on one shared disk (for example one S3 bucket), setMEDIA_PREFIXto a different value on each environment, such aslocal-dom,stagingandproduction. Spatie Media Library stores each file under a directory named after itsmediarow id ({prefix}/{id}/{file name}, with conversions in{prefix}/{id}/conversions/). Each environment has its own database, and each database numbers its media rows from 1. Without a prefix per environment, media row 14 on the local site and media row 14 on staging are different files written to the same directory, and the second upload overwrites the first file's conversions. See "Media library items" in Storage. - In a terminal on each environment that receives imports, run
php artisan migrate. The migration creates thecms_import_recordstable, which records what each import wrote.
Moving content to another environment
- In a terminal at the source site's root, run
php artisan cms:export. The command prints a table of record counts per directory, then one warning per integer id it did not recognize, and ends withWrote the bundle to {path}.When the target cannot read the source's media disk (for example a local site on thepublicdisk exporting to a staging site on S3), runphp artisan cms:export --with-filesinstead, which copies each original file intocms-bundle/media/. - In the same terminal, review the changes with
git diff cms-bundle/, then commit the bundle together with the skin, block and view changes it depends on, and push. - On the target, deploy the commit through the usual deploy process.
- In a terminal on the target, run
php artisan cms:import --dry-run. The command prints one row per record that it would create, update, refuse or report, and a final line of counts beginningDry run, nothing written:. A report that contains onlycreateandupdaterows is ready to import. Eachconflict,unresolvedorfailedrow needs a decision before the next step. - In the same terminal, run
php artisan cms:import. The command exits with a non-zero status when any record is a conflict, has an unresolved reference or failed to write, so a deploy script that runs it stops there. - In a browser, open the changed pages on the target.
How the import decides what to write
For each record, the import compares three SHA-256 hashes:
- The hash of the record's
datain the bundle. - The hash of the same record as it currently exists on the target, built the same way the export builds it.
- The hash stored in
cms_import_recordsstraight after the previous import wrote the record.
| State on the target | Action |
|---|---|
| The current record equals the bundle's | skip |
| The record does not exist | create |
| The record exists and was never imported | conflict: the record was created on the target. Layout areas and site settings are updated instead, because cms:layout-areas and the first request create them on every environment |
| The record changed since the previous import wrote it | conflict: the record was edited on the target |
| Otherwise | update |
A conflict is reported and not written. --overwrite writes it.
The import writes records in this order, so a reference always points at a record that already exists: tags, media, forms, content (parents before children), layout areas, menus, site settings, redirects. Each record is written in its own database transaction, through ContentWriter, VersionManager, Spatie's file adder and the Eloquent models the panel uses. Every content write therefore creates a new version, records its search text and media references, invalidates the page cache, and writes a 301 redirect when the page's path changed.
A record that an earlier import wrote and that the bundle no longer holds was deleted on the source. The import lists it as deleted locally and leaves it in place. --prune deletes it, for content, menus, tags and redirects only. Deleted content goes to the trash and can be restored. Forms are never pruned, because deleting a form deletes its submissions. Media is never pruned, because content on the target that the bundle does not cover can still use the file.
References between records
Content rows keep their UUID on every environment. The import creates each page with the UUID it has on the source, so every reference to content is valid on the target without rewriting: parent_id, translation_group, snippet_id in the Snippet block, content_id in the Link list block, parent_id in the Page list block, and content_id and source_content_id on menu items.
Media and form ids are auto-increment numbers that differ between databases. The export replaces each one with a portable reference, and the import replaces each reference with the target's id:
| Value | Rule | Portable form |
|---|---|---|
| A media id | A key named mediaid or ending in mediaid, the keys ogimage and defaultogimageid, at any depth in block data, version attributes, SEO fields, site settings and menu items | {"$media": "<sha256>"} |
| A list of media ids | The keys images and logos | A list of {"$media": "<sha256>"} |
| A form id | The key formid | {"$form": "<slug>"} |
The rule for media keys is the one the media library's Usage tab applies, described in Media. A custom block that stores a media id under a key that does not follow it is not rewritten.
The export leaves every other integer under a key ending in _id unchanged and prints a warning naming its location, for example content.{uuid}.published.blocks.2.data.product_id holds the integer 12. When the integer is the application's own id and the application's data is a copy of the target's, with the same ids, add the key to cms.bundle.application_keys to stop the warning.
A record whose reference resolves neither from the bundle nor from the target is reported as unresolved and not written.
Layout areas are matched by slug instead of UUID. cms:layout-areas (LayoutAreas::sync()) creates the area for every shared region on every environment, each with that environment's own UUID.
Drafts and production
In every environment listed in cms.bundle.draft_environments (default ['production']), cms:import writes every content record as a draft and publishes nothing. The command prints Content is written as drafts; nothing is published. before the report. --publish turns this off for one run, so the import publishes what was published on the source. In every other environment, --draft turns drafts-only on for one run. --draft and --publish cannot be combined.
A record imported as a draft differs from the bundle's copy, which records a published version. Every later import therefore reports the record as update until it is imported with publishing or published in the panel and exported again.
Command reference
cms:export
| Option | Effect |
|---|---|
| --path= | Writes to this directory instead of cms.bundle.path. |
| --only= | Exports only the listed record types, comma separated: tags, media, forms, content, areas, menus, site, redirects. The manifest keeps the counts of the other types. |
| --with-files | Copies each media original into media/{sha256}.{ext} in the bundle. |
The export deletes each exported directory and writes it again, so a record deleted on the source disappears from the bundle. It never deletes the bundle directory itself.
cms:import
| Option | Effect |
|---|---|
| --dry-run | Prints the report and writes nothing, including the media hash cache. |
| --path= | Reads from this directory instead of cms.bundle.path. |
| --only= | Imports only the listed record types, comma separated. |
| --draft | Writes every content record as a draft and publishes nothing. |
| --publish | In an environment listed in cms.bundle.draftenvironments, publishes what was published on the source. |
| --overwrite | Writes records reported as conflicts. |
| --prune | Deletes content, menus, tags and redirects that an earlier import wrote and that the bundle no longer holds. |
The command refuses to run when the bundle has no manifest.json. It exits with a non-zero status when any record is reported as conflict, unresolved or failed.
On first use the import reads every media original on the target once, to compute its SHA-256, and stores the hash in the media row's sha256 custom property. Later runs read the stored hash.