Appearance
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
updatedevent 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_restoreorrestored. - 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.viewto see history,page.updateto restore (PageRevisionPolicyand the action's visibility check).
Files
- Migration
create_page_revisions_table, withunique(page_id, number) app/Models/PageRevision.php(capture, snapshot, restore, comparison, summaries)Page::revisions()(hasMany)app/Filament/Resources/Pages/RelationManagers/RevisionsRelationManager.php, registered inPageResource::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.