---
title: "Undo — Imports for Laravel | Roundly"
description: "Undo within a window — unchanged created records are deleted or handed to your rollback hook, changed ones kept and listed, updates never reverted."
url: https://roundly-consulting.com/open-source/docs/imports-for-laravel/undo
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Imports for Laravel](https://roundly-consulting.com/open-source/docs/imports-for-laravel.md)

# Undo

```php
$batch->canRollback();            // completed / failed / cancelled, before undo_until, no rollback yet (or a failed one)
$batch->rollback();
$batch->rollbackReport();         // RollbackReport (null while a queued rollback runs)
```

Within imports.undo.window (P7D) and after authorize(Rollback), ledger rows marked created and not yet rolled back are processed — inline up to preview.sync\_threshold, otherwise as a job batch — each record in its own transaction:

- The record is loaded through the owner scope, soft-deleted included, with a row lock — an edit between the check and the delete waits and is then seen. Gone or trashed → missing.
- No checksum stored → kept\_changed with reason no\_checksum: nothing proves it unchanged. That happens when the commit could not read the created record back through the owner query (ownership() and create() disagree) — the commit reports it.
- Changed since the import → kept\_changed: by checksum (default — a fresh read of the raw attributes minus key and timestamps, so a bare touch() is not a change but a quiet mass update is) or by updated\_at (ChangeDetection::updatedAt()).
- Otherwise your rollback() hook: Deleted (the default), Handled or Kept.
- A delete that throws (a foreign key in your schema) → failed with the reason; the rest carry on.

Updates are never reverted and linked records are never touched. The ledger records each outcome in the same transaction, so a retried rollback does nothing twice and a second rollback is a no-op. A rollback that ended rollback\_failed fires ImportRollbackFailed and can be started again while the window is open — rows already undone are skipped.

Each undo chunk counts as progress, so imports:sweep never gives up on a long undo that is still moving. The undo’s job batch allows failures: one failed chunk does not end the undo while its other chunks still run — it ends rollback\_failed (chunk\_failed) when the batch finishes.

## Outcomes

| RollbackOutcome | Meaning |
| --- | --- |
| `deleted` | Unchanged since the import and deleted — the default rollback() hook. |
| `handled` | Your rollback() hook undid it another way (an invitation revoked). |
| `kept` | Your rollback() hook kept it. |
| `kept_changed` | Changed since the import — by checksum or updated\_at — or no checksum stored (reason no\_checksum): kept and listed. |
| `missing` | Already gone or trashed. |
| `failed` | The delete threw (a host foreign key) — the reason is stored, the rest carry on. |

## The rollback report

```php
$report = $batch->rollbackReport();

$report->deleted; $report->handled; $report->keptChanged; $report->keptByHook;
$report->linked; $report->missing; $report->failed;
$report->keptTotal();
foreach ($report->kept as $view) {   // ImportedRecordView
    $view->rowNumber; $view->record; $view->action; $view->rollbackOutcome; $view->rollbackReason;
}
```

## Your own rollback

Override rollback() on the importer to undo a record another way — revoke instead of delete:

```php
use RoundlyConsulting\Imports\Enums\RollbackOutcome;

public function rollback(Model $record, ImportContext $context): RollbackOutcome
{
    if ($record->accepted_at !== null) {
        return RollbackOutcome::Kept;      // leave it alone
    }

    $record->revoke();                     // undone another way

    return RollbackOutcome::Handled;       // the default deletes it: RollbackOutcome::Deleted
}
```

## Change detection

A model observer that edits a record right after the import makes it look changed. Exclude such attributes in checksumAttributes(), list the compared attributes with ChangeDetection::checksum(\[...\]), or switch to ChangeDetection::updatedAt():

```php
public function checksumAttributes(Model $record): array
{
    // an observer stamps last_synced_at right after the import — ignore it
    return Arr::except($record->getAttributes(), ['id', 'created_at', 'updated_at', 'last_synced_at']);
}
```

Past the window, rollback() throws UndoWindowClosedException (409).

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/imports-for-laravel.md)
