Deleted Models
Introduction
Activities remain in the feed when a model is deleted. Storyfeed replaces the model with a tombstone, which identifies its former type and deletion time without keeping its details. The activity still has a headline.
Use InteractsWithFeed on an Eloquent Feedable model to attach the deletion and restoration hooks. Feedable Models covers setup.
Deleting Feedable Models
Deleting Models
$order->delete();With Laravel's SoftDeletes trait, delete() soft-deletes the model and restoring it removes the tombstone. Without the trait, both the deletion and tombstone are permanent.
Before deletion:
After deletion:
[
{
"kind": "activity",
"id": "j84",
"verb": "place",
"published_at": "1985-07-02T12:00:00.000000Z",
"headline_template": ":actor placed :object with :target",
"headline": null,
"glyph": "shopping-bag",
"glyph_intent": "pending",
"actor": {
"type": "user",
"id": "113",
"label": "Erica Sinclair",
"url": "/users/113",
"attributes": {},
"modal": false,
"data": {},
"media": null,
"body": null,
"tombstone": null
},
"object": {
"type": "storyfeed.tombstone",
"id": "1035",
"label": null,
"url": null,
"attributes": {},
"modal": false,
"data": {},
"media": null,
"body": null,
"tombstone": {
"formerType": "order",
"deleted": "1985-07-04T18:00:00.000Z",
"approximate": false,
"removedBy": null
}
},
"target": {
"type": "venue",
"id": "101",
"label": "Scoops Ahoy",
"url": "/venues/scoops",
"attributes": {},
"modal": false,
"data": {},
"media": {
"icon": null,
"image": null,
"files": [],
"preview": {
"src": "/media/worlds/stranger-things/parlour.jpg",
"mediaType": "image/jpeg",
"width": 960,
"height": 720,
"alt": "An ice cream counter"
},
"url": null
},
"body": [
{
"$body": "Storyfeed/Body/Image",
"$v": 1,
"caption": "Scoops Ahoy",
"alt": "Scoops Ahoy",
"width": null,
"height": null,
"image": "preview"
}
],
"tombstone": null
},
"context": null,
"origin": null,
"result": null,
"instrument": null,
"data": {},
"tombstoned": [
"object"
],
"redundant": true,
"missing_headline_template": null,
"missing_headline": null
}
]Every activity involving the order now refers to its tombstone. By default, the tombstone contains only the model's former type and deletion time:
| Key | Value |
|---|---|
object.type | storyfeed.tombstone, with url: null and label: null |
object.tombstone.formerType | the deleted model's morph alias: order |
object.tombstone.deleted | when it was deleted |
tombstoned | the roles holding a tombstone: ["object"] |
The full shape is in The Payload Contract. Tombstones are stored in feed_tombstones.
The activity keeps the headline, icon, and intent defined for order.place.
Restoring Models
Restore a model that uses SoftDeletes to remove its tombstone and reconnect its activities:
$order->restore();Force Deleting Models
Force-deleting a model makes its tombstone permanent, whether or not the model was soft-deleted first:
// the activities stay; the tombstone is now their object for good
$order->forceDelete();Tombstones
Keeping Labels
By default, tombstones omit the model's label. If the label is safe to keep, such as a public menu dish's name, call keepLabel() in describeFeed():
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Storyfeed\Concerns\InteractsWithFeed;
use Storyfeed\Contracts\Feedable;
use Storyfeed\PendingTombstone;
class MenuItem extends Model implements Feedable
{
use InteractsWithFeed;
public function describeFeed(): void
{
$this->feedEntity()
->label("{$this->code} {$this->name}")
->tombstone(fn (PendingTombstone $tombstone) => $tombstone->keepLabel());
}
}[
{
"kind": "activity",
"id": "j85",
"verb": "publish",
"published_at": "1985-07-02T12:05:00.000000Z",
"headline_template": ":actor put :object on the menu",
"headline": null,
"glyph": "chef-hat",
"glyph_intent": null,
"actor": {
"type": "user",
"id": "101",
"label": "Steve Harrington",
"url": "/users/101",
"attributes": {},
"modal": false,
"data": {},
"media": null,
"body": null,
"tombstone": null
},
"object": {
"type": "storyfeed.tombstone",
"id": "101",
"label": "USS Butterscotch",
"url": null,
"attributes": {},
"modal": false,
"data": {},
"media": null,
"body": null,
"tombstone": {
"formerType": "menu_item",
"deleted": "1985-07-04T18:00:00.000Z",
"approximate": false,
"removedBy": null
}
},
"target": null,
"context": null,
"origin": null,
"result": null,
"instrument": null,
"data": {},
"tombstoned": [
"object"
],
"redundant": true,
"missing_headline_template": null,
"missing_headline": null
}
]The tombstone keeps the label and removes the link.
Grouped Entities
Groups count tombstones per role in distinct_tombstoned. Here, one of three orders has been deleted:
[
{
"kind": "group",
"id": "repeat-deeper-order-2",
"axis": "repeat",
"count": 3,
"verb": "place",
"published_at": "1985-07-02T12:20:00.000000Z",
"headline_template": ":actor placed :count orders with :target",
"headline": null,
"glyph": "shopping-bag",
"glyph_intent": "pending",
"actor": null,
"object": null,
"target": null,
"context": null,
"origin": null,
"result": null,
"instrument": null,
"sample": {
"actors": [],
"objects": [
{
"type": "storyfeed.tombstone",
"id": "2031",
"label": null,
"url": null,
"attributes": {},
"modal": false,
"data": {},
"media": null,
"body": null,
"tombstone": {
"formerType": "order",
"deleted": "1985-07-04T18:00:00.000Z",
"approximate": false,
"removedBy": null
}
},
{
"type": "order",
"id": "2032",
"label": "Order #2032",
"url": "/orders/2032",
"attributes": {},
"modal": false,
"data": {},
"media": null,
"body": null,
"tombstone": null
},
{
"type": "order",
"id": "2033",
"label": "Order #2033",
"url": "/orders/2033",
"attributes": {},
"modal": false,
"data": {},
"media": null,
"body": null,
"tombstone": null
}
],
"targets": [],
"contexts": [],
"origins": [],
"results": [],
"instruments": []
},
"distinct": {
"actors": 1,
"objects": 3,
"targets": 1,
"contexts": 0,
"origins": 0,
"results": 0,
"instruments": 0
},
"children": [
{
"kind": "activity",
"id": "deeper-order-0",
"verb": "place",
"published_at": "1985-07-02T12:10:00.000000Z",
"headline_template": ":actor placed :object with :target",
"headline": null,
"glyph": "shopping-bag",
"glyph_intent": "pending",
"actor": {
"type": "user",
"id": "113",
"label": "Erica Sinclair",
"url": "/users/113",
"attributes": {},
"modal": false,
"data": {},
"media": null,
"body": null,
"tombstone": null
},
"object": {
"type": "storyfeed.tombstone",
"id": "2031",
"label": null,
"url": null,
"attributes": {},
"modal": false,
"data": {},
"media": null,
"body": null,
"tombstone": {
"formerType": "order",
"deleted": "1985-07-04T18:00:00.000Z",
"approximate": false,
"removedBy": null
}
},
"target": {
"type": "venue",
"id": "101",
"label": "Scoops Ahoy",
"url": "/venues/scoops",
"attributes": {},
"modal": false,
"data": {},
"media": {
"icon": null,
"image": null,
"files": [],
"preview": {
"src": "/media/worlds/stranger-things/parlour.jpg",
"mediaType": "image/jpeg",
"width": 960,
"height": 720,
"alt": "An ice cream counter"
},
"url": null
},
"body": [
{
"$body": "Storyfeed/Body/Image",
"$v": 1,
"caption": "Scoops Ahoy",
"alt": "Scoops Ahoy",
"width": null,
"height": null,
"image": "preview"
}
],
"tombstone": null
},
"context": null,
"origin": null,
"result": null,
"instrument": null,
"data": {},
"tombstoned": [
"object"
],
"redundant": true,
"missing_headline_template": null,
"missing_headline": null
},
{
"kind": "activity",
"id": "deeper-order-1",
"verb": "place",
"published_at": "1985-07-02T12:15:00.000000Z",
"headline_template": ":actor placed :object with :target",
"headline": null,
"glyph": "shopping-bag",
"glyph_intent": "pending",
"actor": {
"type": "user",
"id": "113",
"label": "Erica Sinclair",
"url": "/users/113",
"attributes": {},
"modal": false,
"data": {},
"media": null,
"body": null,
"tombstone": null
},
"object": {
"type": "order",
"id": "2032",
"label": "Order #2032",
"url": "/orders/2032",
"attributes": {},
"modal": false,
"data": {},
"media": null,
"body": null,
"tombstone": null
},
"target": {
"type": "venue",
"id": "101",
"label": "Scoops Ahoy",
"url": "/venues/scoops",
"attributes": {},
"modal": false,
"data": {},
"media": {
"icon": null,
"image": null,
"files": [],
"preview": {
"src": "/media/worlds/stranger-things/parlour.jpg",
"mediaType": "image/jpeg",
"width": 960,
"height": 720,
"alt": "An ice cream counter"
},
"url": null
},
"body": [
{
"$body": "Storyfeed/Body/Image",
"$v": 1,
"caption": "Scoops Ahoy",
"alt": "Scoops Ahoy",
"width": null,
"height": null,
"image": "preview"
}
],
"tombstone": null
},
"context": null,
"origin": null,
"result": null,
"instrument": null,
"data": {},
"tombstoned": [],
"redundant": false,
"missing_headline_template": null,
"missing_headline": null
},
{
"kind": "activity",
"id": "deeper-order-2",
"verb": "place",
"published_at": "1985-07-02T12:20:00.000000Z",
"headline_template": ":actor placed :object with :target",
"headline": null,
"glyph": "shopping-bag",
"glyph_intent": "pending",
"actor": {
"type": "user",
"id": "113",
"label": "Erica Sinclair",
"url": "/users/113",
"attributes": {},
"modal": false,
"data": {},
"media": null,
"body": null,
"tombstone": null
},
"object": {
"type": "order",
"id": "2033",
"label": "Order #2033",
"url": "/orders/2033",
"attributes": {},
"modal": false,
"data": {},
"media": null,
"body": null,
"tombstone": null
},
"target": {
"type": "venue",
"id": "101",
"label": "Scoops Ahoy",
"url": "/venues/scoops",
"attributes": {},
"modal": false,
"data": {},
"media": {
"icon": null,
"image": null,
"files": [],
"preview": {
"src": "/media/worlds/stranger-things/parlour.jpg",
"mediaType": "image/jpeg",
"width": 960,
"height": 720,
"alt": "An ice cream counter"
},
"url": null
},
"body": [
{
"$body": "Storyfeed/Body/Image",
"$v": 1,
"caption": "Scoops Ahoy",
"alt": "Scoops Ahoy",
"width": null,
"height": null,
"image": "preview"
}
],
"tombstone": null
},
"context": null,
"origin": null,
"result": null,
"instrument": null,
"data": {},
"tombstoned": [],
"redundant": false,
"missing_headline_template": null,
"missing_headline": null
}
],
"children_truncated": false,
"tombstoned": [],
"redundant": false,
"distinct_tombstoned": {
"actors": 0,
"objects": 0,
"targets": 0,
"contexts": 0,
"origins": 0,
"results": 0,
"instruments": 0
}
}
]The group's tombstone keys are in The Payload Contract.
Configuring Verbs for Deleted Models
Redundant Roles
An activity is redundant when a role selected by its verb contains a tombstone. By default, only the object determines redundancy. Call missing() to choose the roles:
use App\Models\Order;
use Storyfeed\Facades\Story;
Story::for(Order::class)
->verb('place')
->headline(':actor placed :object with :target')
->missing('object', 'target');With this declaration, deleting the shop also makes the activity redundant, so the payload includes redundant: true:
[
{
"kind": "activity",
"id": "j84",
"verb": "place",
"published_at": "1985-07-02T12:00:00.000000Z",
"headline_template": ":actor placed :object with :target",
"headline": null,
"glyph": "shopping-bag",
"glyph_intent": "pending",
"actor": {
"type": "user",
"id": "113",
"label": "Erica Sinclair",
"url": "/users/113",
"attributes": {},
"modal": false,
"data": {},
"media": null,
"body": null,
"tombstone": null
},
"object": {
"type": "order",
"id": "1035",
"label": "Order #1035",
"url": "/orders/1035",
"attributes": {},
"modal": false,
"data": {},
"media": null,
"body": null,
"tombstone": null
},
"target": {
"type": "storyfeed.tombstone",
"id": "101",
"label": null,
"url": null,
"attributes": {},
"modal": false,
"data": {},
"media": null,
"body": null,
"tombstone": {
"formerType": "venue",
"deleted": "1985-07-04T18:00:00.000Z",
"approximate": false,
"removedBy": null
}
},
"context": null,
"origin": null,
"result": null,
"instrument": null,
"data": {},
"tombstoned": [
"target"
],
"redundant": true,
"missing_headline_template": null,
"missing_headline": null
}
]missing() replaces the default list; calling it without roles disables this check. Set Story::for(Order::class)->missing(...) to apply the list to all order verbs. A verb's own declaration takes precedence. On a Story class, return the list from its missing() method.
Removal Verbs
Removal verbs check no roles for redundancy by default, since their objects are expected to be deleted:
use App\Models\Order;
use Storyfeed\ActivityStreams\ActivityType;
use Storyfeed\Facades\Story;
Story::for(Order::class)
->verb('void')
->headline(':actor voided :object')
->type(ActivityType::Remove);[
{
"kind": "activity",
"id": "j84",
"verb": "void",
"published_at": "1985-07-04T18:00:00.000Z",
"headline_template": ":actor voided :object",
"headline": null,
"glyph": "x-circle",
"glyph_intent": "pending",
"actor": {
"type": "user",
"id": "101",
"label": "Steve Harrington",
"url": "/users/101",
"attributes": {},
"modal": false,
"data": {},
"media": null,
"body": null,
"tombstone": null
},
"object": {
"type": "storyfeed.tombstone",
"id": "1035",
"label": null,
"url": null,
"attributes": {},
"modal": false,
"data": {},
"media": null,
"body": null,
"tombstone": {
"formerType": "order",
"deleted": "1985-07-04T18:00:00.000Z",
"approximate": false,
"removedBy": null
}
},
"target": {
"type": "venue",
"id": "101",
"label": "Scoops Ahoy",
"url": "/venues/scoops",
"attributes": {},
"modal": false,
"data": {},
"media": {
"icon": null,
"image": null,
"files": [],
"preview": {
"src": "/media/worlds/stranger-things/parlour.jpg",
"mediaType": "image/jpeg",
"width": 960,
"height": 720,
"alt": "An ice cream counter"
},
"url": null
},
"body": [
{
"$body": "Storyfeed/Body/Image",
"$v": 1,
"caption": "Scoops Ahoy",
"alt": "Scoops Ahoy",
"width": null,
"height": null,
"image": "preview"
}
],
"tombstone": null
},
"context": null,
"origin": null,
"result": null,
"instrument": null,
"data": {},
"tombstoned": [
"object"
],
"redundant": false,
"missing_headline_template": null,
"missing_headline": null
}
]A removal verb is one whose Activity Streams type is Delete, Remove, Undo or Reject. A verb from Storyfeed's own vocabulary with one of those types counts even when it is recorded as a plain string, such as delete, discard or undo. Story::resource() declares its delete and restore verbs as removals.
Missing Headlines
Call missingHeadline() to define a headline for redundant activities:
use App\Models\Order;
use Storyfeed\Facades\Story;
Story::for(Order::class)
->verb('place')
->headline(':actor placed :object with :target')
->missingHeadline(':actor placed an order, since deleted');[
{
"kind": "activity",
"id": "j84",
"verb": "place",
"published_at": "1985-07-02T12:00:00.000000Z",
"headline_template": ":actor placed :object with :target",
"headline": null,
"glyph": "shopping-bag",
"glyph_intent": "pending",
"actor": {
"type": "user",
"id": "113",
"label": "Erica Sinclair",
"url": "/users/113",
"attributes": {},
"modal": false,
"data": {},
"media": null,
"body": null,
"tombstone": null
},
"object": {
"type": "storyfeed.tombstone",
"id": "1035",
"label": null,
"url": null,
"attributes": {},
"modal": false,
"data": {},
"media": null,
"body": null,
"tombstone": {
"formerType": "order",
"deleted": "1985-07-04T18:00:00.000Z",
"approximate": false,
"removedBy": null
}
},
"target": {
"type": "venue",
"id": "101",
"label": "Scoops Ahoy",
"url": "/venues/scoops",
"attributes": {},
"modal": false,
"data": {},
"media": {
"icon": null,
"image": null,
"files": [],
"preview": {
"src": "/media/worlds/stranger-things/parlour.jpg",
"mediaType": "image/jpeg",
"width": 960,
"height": 720,
"alt": "An ice cream counter"
},
"url": null
},
"body": [
{
"$body": "Storyfeed/Body/Image",
"$v": 1,
"caption": "Scoops Ahoy",
"alt": "Scoops Ahoy",
"width": null,
"height": null,
"image": "preview"
}
],
"tombstone": null
},
"context": null,
"origin": null,
"result": null,
"instrument": null,
"data": {},
"tombstoned": [
"object"
],
"redundant": true,
"missing_headline_template": ":actor placed an order, since deleted",
"missing_headline": null
}
]The payload includes the missing headline alongside the unchanged original:
| Key | Holds |
|---|---|
headline_template | :actor placed :object with :target, as before the delete |
missing_headline_template | the verb's missingHeadline(), while redundant is true; otherwise null |
missing_headline | the same, pre-rendered, when it came from a closure; otherwise null |
Without missingHeadline(), both missing-headline fields are null.
Forgetting Redundant Activities
Call forgetWhenMissing() to delete redundant activities after permanent model deletion. For example, an order view may no longer be useful after the order is deleted:
use App\Models\Order;
use Storyfeed\Facades\Story;
Story::for(Order::class)
->verb('view')
->headline(':actor viewed :object')
->forgetWhenMissing();Other activities keep the order's tombstone. Set forgetWhenMissing() on Story::for(Order::class)->fallback() to apply it to all order verbs.
| The Order Is | Its view Activities |
|---|---|
| soft-deleted | stay, so a restore brings them back |
force-deleted, or deleted without SoftDeletes | are permanently deleted |
Handling Bulk Deletions
Bulk deletion, such as Order::whereKey($ids)->delete(), dispatches no model events. Schedule storyfeed:trickle every minute to find deleted models and create tombstones. These tombstones are marked approximate because their deletion time is when Storyfeed found them missing:
[
{
"kind": "activity",
"id": "j84",
"verb": "place",
"published_at": "1985-07-02T12:00:00.000000Z",
"headline_template": ":actor placed :object with :target",
"headline": null,
"glyph": "shopping-bag",
"glyph_intent": "pending",
"actor": {
"type": "user",
"id": "113",
"label": "Erica Sinclair",
"url": "/users/113",
"attributes": {},
"modal": false,
"data": {},
"media": null,
"body": null,
"tombstone": null
},
"object": {
"type": "storyfeed.tombstone",
"id": "1035",
"label": null,
"url": null,
"attributes": {},
"modal": false,
"data": {},
"media": null,
"body": null,
"tombstone": {
"formerType": "order",
"deleted": "1985-07-04T18:00:00.000Z",
"approximate": true,
"removedBy": null
}
},
"target": {
"type": "venue",
"id": "101",
"label": "Scoops Ahoy",
"url": "/venues/scoops",
"attributes": {},
"modal": false,
"data": {},
"media": {
"icon": null,
"image": null,
"files": [],
"preview": {
"src": "/media/worlds/stranger-things/parlour.jpg",
"mediaType": "image/jpeg",
"width": 960,
"height": 720,
"alt": "An ice cream counter"
},
"url": null
},
"body": [
{
"$body": "Storyfeed/Body/Image",
"$v": 1,
"caption": "Scoops Ahoy",
"alt": "Scoops Ahoy",
"width": null,
"height": null,
"image": "preview"
}
],
"tombstone": null
},
"context": null,
"origin": null,
"result": null,
"instrument": null,
"data": {},
"tombstoned": [
"object"
],
"redundant": true,
"missing_headline_template": null,
"missing_headline": null
}
]The command also reconnects activities after a bulk restore(). To create tombstones immediately after bulk deletion:
use App\Models\Order;
use Storyfeed\Facades\Storyfeed;
Order::whereKey($ids)->delete();
Storyfeed::tombstone(Order::class, $ids);Neither path has a model instance, so neither applies keepLabel(). forgetWhenMissing() still applies to permanently deleted models. For a non-Eloquent Feedable, pass its morph alias instead of a class.
Removing Activities Explicitly
To remove a model's activities entirely, delete them before the model:
$user->forceDeleteFromFeed(); // every activity involving the user, permanently
$user->forceDelete();Use deleteFromFeed() to soft-delete the activities. Models registered with Storyfeed::feedable() have neither method; call the actions directly:
(new \Storyfeed\Actions\DeleteFromFeed)($model);
(new \Storyfeed\Actions\ForceDeleteFromFeed)($model);Recording Deletions helps choose between keeping a label, forgetting activities and removing them.