diff --git a/versions/4.x/entity-orm.md b/versions/4.x/entity-orm.md index fe26705..a33b260 100644 --- a/versions/4.x/entity-orm.md +++ b/versions/4.x/entity-orm.md @@ -1938,6 +1938,9 @@ User::query() ->delete(); ``` +### Soft Deleting Models +If a model is marked with the `#[SoftDeletes]` attribute, `delete()`, `purge()` and bulk deletes stamp a `deleted_at` column instead of removing the rows, and trashed rows are excluded from every query. See [Soft Deletes](/versions/4.x/soft-deletes) for the full API, including `withTrashed()`, `onlyTrashed()`, `restore()` and `forceDelete()`. + ## Aggregation The Doppar ORM provides a rich set of aggregation functions that allow you to perform statistical and summary operations directly through your model queries. These methods help extract meaningful insights from your data without writing raw SQL, making your code clean, expressive, and efficient. diff --git a/versions/4.x/hooks.md b/versions/4.x/hooks.md index cb45bcb..e62e9fb 100644 --- a/versions/4.x/hooks.md +++ b/versions/4.x/hooks.md @@ -3,7 +3,7 @@ title: Model Hooks description: Lifecycle hooks for Doppar ORM models meta: - name: keywords - content: Model Hooks, Lifecycle, before_created, after_created, before_updated, after_updated, before_deleted, after_deleted, booting, booted + content: Model Hooks, Lifecycle, before_created, after_created, before_updated, after_updated, before_deleted, after_deleted, before_restored, after_restored, booting, booted --- - [Introduction](#introduction) @@ -27,7 +27,7 @@ Doppar allows two independent ways to define hooks on a model, and both can be u ## Supported Events -Every hook is tied to one of the following eight lifecycle events: +Every hook is tied to one of the following ten lifecycle events: | Event | When it fires | |---|---| @@ -38,7 +38,9 @@ Every hook is tied to one of the following eight lifecycle events: | `before_updated` | Just before an existing record is updated. Attribute mutations made here are persisted. | | `after_updated` | Immediately after an existing record has been successfully updated. | | `before_deleted` | Just before a DELETE query runs on the record. | -| `after_deleted` | Immediately after a record has been removed from the database. | +| `after_deleted` | Immediately after a record has been removed from the database, or soft-deleted on a `#[SoftDeletes]` model. | +| `before_restored` | Just before a soft-deleted record is restored. `#[SoftDeletes]` models only. | +| `after_restored` | Immediately after a soft-deleted record has been restored. `#[SoftDeletes]` models only. | ## Array Property Hooks @@ -1034,6 +1036,46 @@ public static function cleanupRelatedAssets(Model $model): void > `before_deleted` and `after_deleted` are only triggered when deleting via the model instance's `delete()` method. They are not triggered by `Post::query()->where('id', $id)->delete()`. +On a model marked `#[SoftDeletes]`, `delete()` soft-deletes the record and these hooks still fire. They also fire for `forceDelete()`. Call `$this->isForceDeleting()` inside the hook to tell a permanent delete from a soft one. + +### before_restored and after_restored + +These events exist only on models marked `#[SoftDeletes]`. `before_restored` fires just before a soft-deleted record is restored with `restore()`, and `after_restored` fires once the soft delete column has been cleared. Throwing an exception in `before_restored` stops the restore. + +```php +status === 'archived') { + throw new \RuntimeException("Post #{$this->id} is archived and cannot be restored."); + } + } + + #[Hook('after_restored')] + public function refreshCache(): void + { + Cache::delete('posts.all'); + Cache::delete('posts.latest'); + } +} +``` + +> `before_restored` and `after_restored` are only triggered by the model instance's `restore()` method. They are not triggered by `Post::query()->where('id', $id)->restore()`. See [Soft Deletes](/versions/4.x/soft-deletes) for the full soft delete API. + ## Accessing Model State Inside Hooks Inside any hook you have full access to the model's current state, its original state before the operation began, and which attributes have changed. @@ -1176,6 +1218,12 @@ User::withoutHook()->updateOrCreate( ); ``` +#### Skip hooks on restore + +```php +Post::withoutHook()->withTrashed()->find($id)->restore(); +``` + #### Skip hooks on delete ```php diff --git a/versions/4.x/migrations.md b/versions/4.x/migrations.md index 74ce209..926f30c 100644 --- a/versions/4.x/migrations.md +++ b/versions/4.x/migrations.md @@ -472,7 +472,7 @@ Same as timestamps() but with timezone awareness $table->timestampsTz(); ``` -Adds a nullable deleted_at column for soft deletion +Adds a nullable deleted_at column for soft deletion. See [Soft Deletes](/versions/4.x/soft-deletes) for how to use it with models. ```php $table->softDeletes(); ``` diff --git a/versions/4.x/soft-deletes.md b/versions/4.x/soft-deletes.md new file mode 100644 index 0000000..7d3f556 --- /dev/null +++ b/versions/4.x/soft-deletes.md @@ -0,0 +1,428 @@ +--- +title: Soft Deletes +description: Doppar ORM soft deletes documentation +meta: +- name: keywords + content: Soft Deletes, SoftDeletes, ORM, withTrashed, onlyTrashed, restore, forceDelete, deleted_at +--- + +- [Introduction](#introduction) +- [Marking a Model as Soft-Deletable](#marking-a-model-as-soft-deletable) +- [Preparing the Table](#preparing-the-table) +- [Soft Deleting Models](#soft-deleting-models) +- [Querying Soft-Deleted Models](#querying-soft-deleted-models) +- [Restoring Soft-Deleted Models](#restoring-soft-deleted-models) +- [Permanently Deleting Models](#permanently-deleting-models) +- [Relationships](#relationships) +- [Hooks](#hooks) +- [Temporal ORM Integration](#temporal-orm-integration) +- [Utility Methods](#utility-methods) +- [Driver Support](#driver-support) +- [Important Notes](#important-notes) + +## Soft Deletes + +### Introduction + +Sometimes a record should disappear from your application without actually being removed from the database. A user deletes a post but may want it back, an admin needs to review removed accounts, or a compliance rule requires that nothing is ever physically erased. Soft deletes solve this: instead of running a `DELETE`, Doppar stamps a timestamp column on the row, and from then on every query on that model quietly skips it. + +Soft deletes are opt-in per model through the `#[SoftDeletes]` attribute. Once a model carries the attribute, `delete()` marks the row as deleted, every query excludes trashed rows by default, and you get a small API for including them again, restoring them, or removing them for good. + +The constraint is applied consistently across the ORM. Regular queries, aggregates, pagination, eager loading, and relationship existence queries such as `present()` and `whereLinked()` all skip trashed rows unless you explicitly ask for them. Soft deletes also integrate with Doppar's [Model Hooks](/versions/4.x/hooks) and [Temporal ORM](/versions/4.x/temporal-orm), so deletions and restorations can be observed and recorded in the model's history. + +Soft deletes are particularly useful when your application needs a recycle bin, an undo action, audit-friendly deletion, or a grace period before data is permanently removed. + +No extra code is required in your controllers or services. Your existing `delete()` calls keep working, they simply stop removing rows. + +## Marking a Model as Soft-Deletable + +Add the `#[SoftDeletes]` attribute to the model class: + +```php + **Note:** The attribute is inherited, so putting it on a base model makes every child model soft-deletable as well. + +## Preparing the Table + +The soft delete column must be a nullable datetime column. The migration `Blueprint` has a helper for it: + +```php +public function up(): void +{ + Schema::create('posts', function (Blueprint $table) { + $table->id(); + $table->string('title'); + $table->text('body'); + $table->timestamps(); + $table->softDeletes(); // nullable deleted_at + // $table->softDeletes('removed_at'); // custom column name + // $table->softDeletesTz(); // timezone aware variant + }); +} +``` + +To add the column to an existing table, and drop it again on rollback: + +```php +public function up(): void +{ + Schema::table('posts', function (Blueprint $table) { + $table->softDeletes(); + }); +} + +public function down(): void +{ + Schema::table('posts', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); +} +``` + +A row whose soft delete column is `NULL` is live. A row with a timestamp in it is trashed. + +## Soft Deleting Models + +Call `delete()` exactly as you would on any other model: + +```php +$post = Post::find(1); + +$post->delete(); +``` + +Instead of removing the row, Doppar sets `deleted_at` to the current time (and touches `updated_at` when the model uses timestamps). The in-memory model is updated with the same values, so there is no need to reload it: + +```php +$post->trashed(); // true +$post->deleted_at; // "2026-10-09 10:15:00" +``` + +Deleting through the query builder soft-deletes every matching row as well: + +```php +// Soft-deletes every draft post +Post::query()->where('status', 'draft')->delete(); + +// Soft-deletes the posts with these primary keys +Post::purge(1, 2, 3); +``` + +> **Note:** Bulk soft deletes only touch rows that are still live, so a row that is already trashed keeps its original `deleted_at` timestamp. Calling `delete()` on a single model that is already trashed stamps it again with the current time. + +## Querying Soft-Deleted Models + +Every query on a soft-deletable model excludes trashed rows automatically. This applies everywhere the model is queried: `get()`, `first()`, `find()`, `count()` and other aggregates, `paginate()`, cursor pagination, chunking, bulk `update()`, `increment()` and so on. + +```php +Post::find(1); // null when post 1 is trashed +Post::all(); // live posts only +Post::where('user_id', 5)->count(); // live posts only +``` + +### Including Trashed Models + +Use `withTrashed()` to include trashed rows in the results: + +```php +$posts = Post::withTrashed()->where('user_id', 5)->get(); + +$post = Post::withTrashed()->find(1); +``` + +### Retrieving Only Trashed Models + +Use `onlyTrashed()` to retrieve trashed rows and nothing else. This is handy for a "recycle bin" screen: + +```php +$trash = Post::onlyTrashed() + ->where('user_id', auth()->id()) + ->orderBy('deleted_at', 'desc') + ->get(); +``` + +### Going Back to the Default + +`withoutTrashed()` switches a query back to the default behaviour of excluding trashed rows. It is useful when a query object is built up in steps: + +```php +$query = Post::onlyTrashed(); + +if ($request->boolean('live')) { + $query->withoutTrashed(); +} + +$posts = $query->get(); +``` + +### OR Conditions Are Safe + +The soft delete constraint is added after your own conditions are wrapped in parentheses, so an `orWhere()` can never bring trashed rows back by accident: + +```php +Post::where('status', 'draft')->orWhere('status', 'review')->toSql(); + +// SELECT * FROM posts WHERE (status = ? OR status = ?) AND posts.deleted_at IS NULL +``` + +Nested `where` groups do not repeat the constraint. It is applied once, on the outer query. Table aliases are respected too, so `Post::query()->from('posts as p')` qualifies the column as `p.deleted_at`. + +## Restoring Soft-Deleted Models + +Call `restore()` on a trashed model to bring it back. Doppar sets the soft delete column back to `NULL` and touches `updated_at`: + +```php +$post = Post::withTrashed()->find(1); + +$post->restore(); + +$post->trashed(); // false +``` + +To restore many rows at once, call `restore()` on a query. A restore query targets trashed rows only, so you don't need to add `onlyTrashed()` yourself: + +```php +// Restore every trashed post of a user +Post::where('user_id', 5)->restore(); +``` + +Live rows that happen to match the query are left untouched, so their `updated_at` does not change. + +## Permanently Deleting Models + +When a row really has to go, use `forceDelete()`. It runs a real `DELETE`, whether the model is live or already trashed: + +```php +$post = Post::withTrashed()->find(1); + +$post->forceDelete(); +``` + +`forceDelete()` is available on queries too: + +```php +// Empty the recycle bin +Post::onlyTrashed()->forceDelete(); + +// Permanently remove old trashed posts +Post::onlyTrashed() + ->where('deleted_at', '<', now()->subDays(30)) + ->forceDelete(); +``` + +## Relationships + +Trashed rows are hidden from relationships in the same way they are hidden from regular queries. + +### Loading Relations + +Lazy loading and eager loading with `embed()` skip trashed related models, including nested relations and many-to-many relations through a pivot table: + +```php +$user = User::find(1); + +$user->posts; // live posts only + +$users = User::query()->embed('posts.comments')->get(); // live posts, live comments +``` + +### Relationship Existence Queries + +`present()`, `absent()`, `ifExists()` and `whereLinked()` ignore trashed related rows, so a user whose only post is trashed counts as having no posts: + +```php +// Users with at least one live post +User::query()->present('posts')->get(); + +// Users with no live posts +User::query()->absent('posts')->get(); + +// Users with a live post titled "Hello" +User::query()->whereLinked('posts', 'title', 'Hello')->get(); +``` + +This also works through nested relations (`present('posts.comments')`) and many-to-many relations, where a trashed row anywhere in the chain is ignored. + +To change this, call `withTrashed()` or `onlyTrashed()` inside the callback: + +```php +// Users with any post, live or trashed +User::query()->present('posts', fn($query) => $query->withTrashed())->get(); + +// Users who have at least one trashed post +User::query()->present('posts', fn($query) => $query->onlyTrashed())->get(); +``` + +## Hooks + +A soft delete fires the usual `before_deleted` and `after_deleted` hooks. A permanent delete through `forceDelete()` fires them as well. Inside the hook, `isForceDeleting()` tells you which one is happening: + +```php +use Phaseolies\Database\Entity\Attributes\Hook; + +#[SoftDeletes] +class Post extends Model +{ + #[Hook('after_deleted')] + public function cleanUp(): void + { + if ($this->isForceDeleting()) { + // The row is gone for good: remove uploaded files + Storage::delete($this->cover_path); + } + } +} +``` + +Restoring a model fires two new events, `before_restored` and `after_restored`. Throwing an exception from a `before_restored` hook stops the restore: + +```php +#[Hook('before_restored')] +public function preventRestoreIfArchived(): void +{ + if ($this->status === 'archived') { + throw new \RuntimeException("Post #{$this->id} is archived and cannot be restored."); + } +} + +#[Hook('after_restored')] +public function clearCache(): void +{ + Cache::delete('posts.all'); +} +``` + +> `before_deleted`, `after_deleted`, `before_restored` and `after_restored` are only triggered by the model instance's `delete()`, `forceDelete()` and `restore()` methods. They are not triggered by bulk queries such as `Post::query()->where(...)->delete()` or `Post::query()->where(...)->restore()`. + +`withoutHook()` still soft-deletes, it only skips the hooks: + +```php +Post::withoutHook()->find($id)->delete(); // soft-deleted, no hooks +Post::withoutHook()->withTrashed()->find($id)->restore(); // restored, no hooks +``` + +See [Model Hooks](/versions/4.x/hooks) for more about hooks. + +## Temporal ORM Integration + +Soft deletes work together with the [Temporal ORM](/versions/4.x/temporal-orm). When a model carries both `#[SoftDeletes]` and `#[Temporal]`: + +- A soft delete is recorded as a `deleted` snapshot, and a restore is recorded as a new `restored` snapshot. +- Time-travel queries such as `::at()` treat the record as absent while it was trashed and present again after it was restored. +- `restoreTo()` carries the soft delete state of the snapshot. Restoring a trashed record to a moment before it was deleted brings it back as a live record. + +```php +#[Temporal] +#[SoftDeletes] +class Contract extends Model +{ + // +} + +$contract = Contract::withTrashed()->find(42); + +// Roll back to the state before it was deleted; the contract is live again +$contract->restoreTo('2026-01-01 12:00:00'); +``` + +## Utility Methods + +### `trashed()` + +Returns `true` if the model has been soft-deleted. + +```php +$post = Post::withTrashed()->find(1); + +if ($post->trashed()) { + echo 'This post is in the recycle bin.'; +} +``` + +### `isForceDeleting()` + +Returns `true` while `forceDelete()` is running. Use it inside `before_deleted` and `after_deleted` hooks to tell a permanent delete from a soft one. + +```php +#[Hook('after_deleted')] +public function cleanUp(): void +{ + if ($this->isForceDeleting()) { + Storage::delete($this->cover_path); + } +} +``` + +### `usesSoftDeletes()` + +Returns `true` if the model class is marked with `#[SoftDeletes]`. + +```php +$post = new Post(); + +if ($post->usesSoftDeletes()) { + echo 'This model uses soft deletes.'; +} +``` + +### `getSoftDeleteColumn()` + +Returns the name of the soft delete column, or `null` if the model is not marked with `#[SoftDeletes]`. + +```php +echo (new Post())->getSoftDeleteColumn(); // 'deleted_at' +echo (new Note())->getSoftDeleteColumn(); // 'removed_at' +``` + +## Driver Support + +Soft deletes work with all three database drivers supported by Doppar: MySQL, PostgreSQL, and SQLite. The constraint is plain `IS NULL` / `IS NOT NULL` SQL, so no driver-specific configuration is needed. + +## Important Notes + +### Soft delete methods require the attribute + +Calling `withTrashed()`, `onlyTrashed()`, `withoutTrashed()`, `restore()` or `forceDelete()` on a model without `#[SoftDeletes]` throws a `LogicException`. Models without the attribute are not affected by soft deletes at all, and their `delete()` removes the row as before. + +### A trashed model can still be saved + +Writes that target a single model by its primary key (`save()`, `update()`, `increment()`, `decrement()`, `delete()`) ignore the soft delete constraint. You can load a trashed model with `withTrashed()`, change it and save it. It stays trashed: + +```php +$post = Post::withTrashed()->find(1); +$post->title = 'Archived: ' . $post->title; +$post->save(); // saved, still trashed +``` + +### Raw SQL is not scoped + +The constraint is added by the model's query builder. Raw queries run through `DB::sql()` do not know about soft deletes, so add `deleted_at IS NULL` yourself when you need it. diff --git a/versions/4.x/temporal-orm.md b/versions/4.x/temporal-orm.md index 9ff1f33..f9df0d4 100644 --- a/versions/4.x/temporal-orm.md +++ b/versions/4.x/temporal-orm.md @@ -153,7 +153,7 @@ For each temporal model, Doppar creates a history table with the following colum |--------|------|-------------| | `history_id` | auto-increment integer | Unique identifier for each history row | | `record_id` | integer | The primary key of the original record | -| `action` | varchar(10) | One of: `created`, `updated`, `deleted` | +| `action` | varchar(10) | One of: `created`, `updated`, `deleted`, `restored` | | `valid_from` | high-precision datetime | When this state became current (microsecond precision) | | `snapshot` | JSON / JSONB / TEXT | The full row state at the time of the write | | `changed_cols` | JSON / JSONB / TEXT | (updates only) Array of column names that changed | @@ -243,7 +243,7 @@ Call `->history()` on any loaded model instance to retrieve its full chronologic $contract = Contract::find(42); foreach ($contract->history() as $entry) { - echo $entry->__action; // 'created' | 'updated' | 'deleted' + echo $entry->__action; // 'created' | 'updated' | 'deleted' | 'restored' echo $entry->__valid_from; // '2024-01-15 09:23:11.482910+00' echo $entry->__history_id; // 3 echo $entry->status; // value at that point in time @@ -264,7 +264,7 @@ Each history entry carries these read-only virtual properties: | Property | Type | Description | |----------|------|-------------| | `__history_id` | int | The row's ID in the history table | -| `__action` | string | `created`, `updated`, or `deleted` | +| `__action` | string | `created`, `updated`, `deleted`, or `restored` (soft-deletable models only) | | `__valid_from` | string | High-precision timestamp of when this state was recorded | | `__changed_cols` | array\|null | Columns that changed (updates only, `null` otherwise) | | `__actor` | mixed\|null | PK of the user who triggered the change (requires `trackActor: true`) | @@ -279,6 +279,7 @@ foreach ($contract->history() as $entry) { 'created' => 'Contract was created', 'updated' => 'Contract was updated — changed: ' . implode(', ', $entry->__changed_cols ?? []), 'deleted' => 'Contract was deleted', + 'restored' => 'Contract was restored', }; echo "[{$entry->__valid_from}] {$label}" . PHP_EOL; @@ -536,6 +537,37 @@ User::withoutHook()->create([ The `->history()` method is the only API that includes `deleted` entries, since it returns the full raw audit trail without filtering. +### Soft-deletable models + +When a model carries both `#[Temporal]` and `#[SoftDeletes]`, a soft delete is recorded as a `deleted` snapshot and a `restore()` is recorded as a `restored` snapshot. Time-travel queries therefore treat the record as absent while it was trashed and present again once it was restored: + +```php +#[Temporal] +#[SoftDeletes] +class Contract extends Model +{ + // +} + +// 2026-01-01: created +// 2026-01-02: $contract->delete(); // recorded as 'deleted' +// 2026-01-03: $contract->restore(); // recorded as 'restored' + +Contract::at('2026-01-01 12:00:00')->find(42); // the contract +Contract::at('2026-01-02 12:00:00')->find(42); // null, it was trashed +Contract::at('2026-01-03 12:00:00')->find(42); // the contract again +``` + +`->restoreTo()` also carries over the soft delete state of the snapshot. Restoring a trashed record to a moment before it was deleted brings it back as a live record, and the restoration is recorded as a `restored` entry: + +```php +$contract = Contract::withTrashed()->find(42); + +$contract->restoreTo('2026-01-01 12:00:00'); // live again +``` + +See [Soft Deletes](/versions/4.x/soft-deletes) for the full soft delete API. + ### The `created` snapshot is recorded before the primary key is written back Doppar fires `after_created` before the auto-increment primary key is written back to the model's attributes. The Temporal system handles this transparently by falling back to `PDO::lastInsertId()` so that the `record_id` in the history table is always correct.