Pre-1.0. Not intended for public use yet. The API is subject to undocumented change, and these pages may already be wrong about it.

Skip to content

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 ​

app/Http/Controllers/OrderController.phpdestroy()
php
$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:

ES
Erica Sinclair placed a removed order with Scoops Ahoy
[
  {
    "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:

KeyValue
object.typestoryfeed.tombstone, with url: null and label: null
object.tombstone.formerTypethe deleted model's morph alias: order
object.tombstone.deletedwhen it was deleted
tombstonedthe 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:

app/Http/Controllers/OrderController.phprestore()
php
$order->restore();

Force Deleting Models ​

Force-deleting a model makes its tombstone permanent, whether or not the model was soft-deleted first:

app/Http/Controllers/OrderController.phpdestroy()
php
// the activities stay; the tombstone is now their object for good
$order->forceDelete();
ES
Erica Sinclair placed a removed order with Scoops Ahoy

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():

app/Models/MenuItem.php
php
<?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());
    }
}
SH
Steve Harrington put USS Butterscotch on the menu
[
  {
    "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:

Someone placed 3 orders with something
[
  {
    "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:

routes/feed.php
php
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:

ES
Erica Sinclair placed Order #1035 with a removed venue
[
  {
    "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:

routes/feed.php
php
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);
SH
Steve Harrington voided a removed order
[
  {
    "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:

routes/feed.php
php
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');
ES
Erica Sinclair 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:

KeyHolds
headline_template:actor placed :object with :target, as before the delete
missing_headline_templatethe verb's missingHeadline(), while redundant is true; otherwise null
missing_headlinethe 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:

routes/feed.php
php
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 IsIts view Activities
soft-deletedstay, so a restore brings them back
force-deleted, or deleted without SoftDeletesare 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:

ES
Erica Sinclair placed a removed order with Scoops Ahoy
[
  {
    "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:

Where the rows are deleted: a controller, an action, a job
php
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:

app/Http/Controllers/AccountController.phpdestroy()
php
$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:

php
(new \Storyfeed\Actions\DeleteFromFeed)($model);
(new \Storyfeed\Actions\ForceDeleteFromFeed)($model);

Recording Deletions helps choose between keeping a label, forgetting activities and removing them.

Released under the MIT License.