Skip to content

Staged Changes

This guide covers the staged changes system, which allows you to queue model changes for review and approval before they’re persisted.

Staged changes are ideal for:

  • Content moderation: Review user-submitted content before publishing
  • Maker-checker workflows: Require approval for sensitive data changes
  • Compliance: Audit trail of proposed vs. approved changes
  • Collaborative editing: Multiple reviewers for important updates

Add the HasStagedChanges trait and implement the Stageable interface:

use Cline\Tracer\Concerns\HasStagedChanges;
use Cline\Tracer\Contracts\Stageable;
use Illuminate\Database\Eloquent\Model;
class Article extends Model implements Stageable
{
use HasStagedChanges;
protected $fillable = ['title', 'content', 'status'];
}

For both revision tracking and staged changes:

use Cline\Tracer\Concerns\HasRevisions;
use Cline\Tracer\Concerns\HasStagedChanges;
use Cline\Tracer\Contracts\Stageable;
use Cline\Tracer\Contracts\Traceable;
class Article extends Model implements Traceable, Stageable
{
use HasRevisions;
use HasStagedChanges;
}

All staging operations use the Tracer facade:

use Cline\Tracer\Tracer;
$article = Article::find(1);
$stagedChange = Tracer::staging($article)->stage([
'title' => 'Updated Title',
'content' => 'Updated content here...',
]);
$stagedChange = Tracer::staging($article)->stage(
['title' => 'Fixed Typo in Title'],
'Correcting spelling mistake reported by user'
);
use Cline\Tracer\Tracer;
$stagedChange = Tracer::stage($article, [
'title' => 'New Title',
], 'Marketing requested title change');
┌─────────────────────────────────────────────────────────────────┐
│ STAGED CHANGE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌──────────┐ ┌─────────┐ │
│ │ Pending │────▶│ Approved │────▶│ Applied │ │
│ └────┬────┘ └──────────┘ └─────────┘ │
│ │ │
│ │ ┌──────────┐ │
│ ├─────────▶│ Rejected │ │
│ │ └──────────┘ │
│ │ │
│ │ ┌───────────┐ │
│ └─────────▶│ Cancelled │ │
│ └───────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
StatusDescription
pendingAwaiting approval (mutable)
approvedReady to apply (can be applied)
rejectedDenied (terminal)
appliedChanges persisted to model (terminal)
cancelledWithdrawn (terminal)
use Cline\Tracer\Tracer;
// Approve
Tracer::approve($stagedChange, auth()->user(), 'Looks good!');
// Reject
Tracer::reject($stagedChange, auth()->user(), 'Content violates guidelines');
$staging = Tracer::staging($article);
// Get pending changes
$pending = $staging->pending();
// Approve specific change
$staging->approve($stagedChange, $approver, 'Approved for publication');
// Reject with reason
$staging->reject($stagedChange, $rejector, 'Needs more detail');

Once approved, changes must be explicitly applied:

$stagedChange->apply(auth()->user());
use Cline\Tracer\Tracer;
$appliedCount = Tracer::staging($article)->applyApproved(auth()->user());
// Returns number of staged changes applied
Tracer::apply($stagedChange, auth()->user());
// All staged changes via relationship
$all = $article->stagedChanges;
// Pending only via relationship
$pending = $article->pendingStagedChanges()->get();
// Approved only via relationship
$approved = $article->approvedStagedChanges()->get();
use Cline\Tracer\Tracer;
$staging = Tracer::staging($article);
// Check for pending
if ($staging->hasPending()) {
// Show "pending review" indicator
}
// Check for approved
if ($staging->hasApproved()) {
// Show "ready to apply" indicator
}
use Cline\Tracer\Tracer;
// All pending changes across all models
$allPending = Tracer::allPendingStagedChanges();
// All approved changes ready to apply
$allApproved = Tracer::allApprovedStagedChanges();
$staging = Tracer::staging($article);
$pending = $staging->pending();
$approved = $staging->approved();
$all = $staging->all();
$stagedChange->proposed_values;
// ['title' => 'New Title', 'content' => 'New content']
$stagedChange->getProposedValue('title');
// 'New Title'
$stagedChange->getProposedValue('missing_key', 'default');
// 'default'
$stagedChange->original_values;
// ['title' => 'Old Title', 'content' => 'Old content']
$stagedChange->getOriginalValue('title');
// 'Old Title'
$stagedChange->getChangedAttributeKeys();
// ['title', 'content']
$stagedChange->wouldChange('title'); // true
$stagedChange->wouldChange('status'); // false
use Cline\Tracer\Tracer;
$diffStrategy = Tracer::resolveDiffStrategy($stagedChange->diff_strategy);
$descriptions = $diffStrategy->describe($stagedChange->proposed_values);
// [
// 'title' => 'Changed from "Old Title" to "New Title"',
// 'content' => 'Changed from "..." to "..."',
// ]

Only pending changes can be modified:

$stagedChange->updateProposedValues([
'title' => 'Even Newer Title',
]);

This merges with existing proposed values.

$stagedChange->cancel();
use Cline\Tracer\Tracer;
$cancelled = Tracer::staging($article)->cancelPending();
// Returns number cancelled

Configuration is managed via config/tracer.php or runtime registration, not on the model itself.

config/tracer.php
'models' => [
App\Models\Article::class => [
'stageable_attributes' => ['title', 'content'],
'unstageable_attributes' => ['internal_notes', 'admin_only'],
],
],
use Cline\Tracer\Tracer;
// Only allow staging specific attributes
Tracer::configure(Article::class)
->stageableAttributes(['title', 'content']);
// Exclude specific attributes from staging
Tracer::configure(Article::class)
->unstageableAttributes(['internal_notes', 'admin_only']);

Configure in config/tracer.php:

'unstageable_attributes' => [
'id',
'created_at',
'updated_at',
'deleted_at',
],

By default, the authenticated user is recorded as the author via the configured CauserResolver:

use Cline\Tracer\Tracer;
$stagedChange = Tracer::staging($article)->stage(['title' => 'New']);
$stagedChange->author; // The authenticated user

Create a custom CauserResolver to customize how authors are resolved:

use Cline\Tracer\Contracts\CauserResolver;
use Illuminate\Database\Eloquent\Model;
class CustomCauserResolver implements CauserResolver
{
public function resolve(): ?Model
{
// Use API client for API requests
if (request()->hasHeader('X-API-Key')) {
return ApiClient::findByKey(request()->header('X-API-Key'));
}
return auth()->user();
}
}

Register in config/tracer.php:

'causer_resolver' => CustomCauserResolver::class,

Each staged change tracks approval workflow data:

$stagedChange->approval_metadata;
// For simple approval:
// [
// 'approved_by_type' => 'App\\Models\\User',
// 'approved_by_id' => 5,
// 'approved_at' => '2024-01-15T10:30:00+00:00',
// ]
// For quorum approval:
// [
// 'quorum_reached' => true,
// 'approvals_required' => 2,
// 'approvals_received' => 2,
// 'approved_at' => '2024-01-15T10:30:00+00:00',
// ]

Each approval/rejection is recorded individually:

$stagedChange->approvals;
// Collection of StagedChangeApproval models
foreach ($stagedChange->approvals as $approval) {
$approval->approver; // User model
$approval->approved; // true/false
$approval->comment; // Approval/rejection comment
$approval->sequence; // Order of approval
$approval->created_at; // When they voted
}

Tracer dispatches events throughout the staged change lifecycle:

use Cline\Tracer\Events\StagedChangeCreated;
use Cline\Tracer\Events\StagedChangeApproved;
use Cline\Tracer\Events\StagedChangeRejected;
use Cline\Tracer\Events\StagedChangeApplied;
// In EventServiceProvider
protected $listen = [
StagedChangeCreated::class => [
NotifyReviewersListener::class,
],
StagedChangeApproved::class => [
NotifyAuthorApprovedListener::class,
],
StagedChangeRejected::class => [
NotifyAuthorRejectedListener::class,
],
StagedChangeApplied::class => [
LogChangeAppliedListener::class,
],
];
use Cline\Tracer\Exceptions\CannotModifyStagedChangeException;
try {
$approvedChange->updateProposedValues(['title' => 'New']);
} catch (CannotModifyStagedChangeException $e) {
// Change is already approved/rejected/applied
}
use Cline\Tracer\Exceptions\CannotApplyStagedChangeException;
try {
$pendingChange->apply();
} catch (CannotApplyStagedChangeException $e) {
// Change must be approved first
}
try {
$stagedChange->apply();
} catch (CannotApplyStagedChangeException $e) {
// The target model was deleted
}

When using both traits, applying a staged change creates a revision:

class Article extends Model implements Traceable, Stageable
{
use HasRevisions;
use HasStagedChanges;
}
// Stage a change
$staged = Tracer::staging($article)->stage(['title' => 'New Title']);
// Approve and apply
Tracer::approve($staged, $admin);
$staged->apply($admin);
// A revision is automatically created
$revision = $article->latestRevision();
$revision->action; // RevisionAction::Updated
$revision->new_values; // ['title' => 'New Title']