Skip to content

Page revisions ​

What it does ​

Keeps a history of each page and lets editors view an old version, compare it with the current one, and restore it. Restoring is undoable.

A revision is a snapshot of the page's fields plus all its blocks, stored as JSON. This is separate from the audit trail: the audit trail says what changed, and revisions let you go back.

How capture works ​

  • Capture is called from the Filament page hooks, not from model events. A model's updated event fires before the blocks Repeater has saved its relationship, so a model-event snapshot would capture the old blocks.
  • The snapshot is hashed (keys sorted, so key order doesn't matter) and compared with the latest revision. A save that changes nothing creates no revision.
  • Each revision stores a one-line summary such as "Changed: title. Blocks: 1 edited, 1 added", the user's name (a snapshot), and an event: created, saved, before_restore or restored.
  • Only the last 50 revisions per page are kept (PageRevision::KEEP_PER_PAGE).

How restore works ​

What a restore puts back: title, the SEO fields (meta_title, canonical_url, meta_description, robots_index, robots_follow, og_title, og_description, og_image) and all blocks. This list is PageRevision::RESTORABLE.

What it deliberately leaves alone: status, published_at, slug, parent_page_id and is_homepage. Otherwise restoring an old published version could publish a draft, or let an editor without page.publish get around the publishing rule, or break URLs.

Restore runs in a transaction. Blocks are replaced wholesale with activity logging paused, so the audit trail gets one "restored" entry instead of a create and delete for every block.

The screen ​

RevisionsRelationManager appears on the Edit Page screen as "Revision history":

  • View & compare opens a wide modal showing what restoring would change: page fields (current vs. restoring) and a per-block status (same, content differs, will be added back, will be removed).
  • Restore asks for confirmation, then reloads the edit form with the restored content.
  • Permissions: page.view to see history, page.update to restore (PageRevisionPolicy and the action's visibility check).

Files ​

  • Migration create_page_revisions_table, with unique(page_id, number)
  • app/Models/PageRevision.php (capture, snapshot, restore, comparison, summaries)
  • Page::revisions() (hasMany)
  • app/Filament/Resources/Pages/RelationManagers/RevisionsRelationManager.php, registered in PageResource::getRelations()
  • app/Policies/PageRevisionPolicy.php
  • Capture hooks: EditPage::afterSave(), CreatePage::afterCreate(), CreatePageWizard::afterCreate()

One-time setup ​

Pages that existed before this feature have no history. Back-fill it once, before clients start editing. Otherwise their first save becomes revision #1 and the pre-edit version can't be recovered:

php
App\Models\Page::each(fn ($page) => App\Models\PageRevision::capture($page));

Why it's built this way ​

  • Snapshots, not a versioning package. A page is a row plus child blocks, and a package that versions one model would miss the blocks.
  • Filament hooks, not model events, for the ordering reason above.
  • A restore that skips publishing fields keeps your publishing rules intact. History can't be used to bypass them.
  • A "before_restore" revision is what makes restore undoable.
  • Hash comparison keeps the history free of no-op saves.