Skip to content
MCG Filament CMS Demo

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:

  1. Form submissions, form drafts, users, API tokens and MCP tokens.
  2. The webhook_url and webhook_api_key columns of each form. The key is encrypted with the source's APP_KEY, and a staging site must not post submissions to the production webhook.
  3. The site_settings columns analytics, webhooks, api_origins, notifications, stripe_publishable_key, stripe_secret_key and stripe_webhook_secret.
  4. Redirects whose source is auto_rename or import. The import's own path changes write auto_rename redirects on the target.
  5. The scheduling columns scheduled_publish_at and scheduled_unpublish_at. The target keeps its own values.
  6. Media conversions (thumb, medium, large). The target generates them.

One-time setup

  1. In config/cms.php, check the three keys of the bundle section:
  • 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 as category_id in a block that lists one category of a product catalog. See References between records.
  • bundle.draft_environments: environments in which cms:import writes drafts only. The default is ['production']. See Drafts and production.
  1. In each environment's .env, when every environment stores media on one shared disk (for example one S3 bucket), set MEDIA_PREFIX to a different value on each environment, such as local-dom, staging and production. Spatie Media Library stores each file under a directory named after its media row 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.
  2. In a terminal on each environment that receives imports, run php artisan migrate. The migration creates the cms_import_records table, which records what each import wrote.

Moving content to another environment

  1. 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 with Wrote the bundle to {path}. When the target cannot read the source's media disk (for example a local site on the public disk exporting to a staging site on S3), run php artisan cms:export --with-files instead, which copies each original file into cms-bundle/media/.
  2. 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.
  3. On the target, deploy the commit through the usual deploy process.
  4. 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 beginning Dry run, nothing written:. A report that contains only create and update rows is ready to import. Each conflict, unresolved or failed row needs a decision before the next step.
  5. 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.
  6. 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:

  1. The hash of the record's data in the bundle.
  2. The hash of the same record as it currently exists on the target, built the same way the export builds it.
  3. The hash stored in cms_import_records straight 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.