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

Feedable API ​

Introduction ​

The Feedable API defines model snapshots and resolves current links and media. See Feedable Models for setup.

The Feedable Contract ​

vendor/storyfeed/storyfeed/src/Contracts/Feedable.php
php
<?php

namespace Storyfeed\Contracts;

use Storyfeed\FeedContext;
use Storyfeed\FeedEntity;
use Storyfeed\FeedMedia;

interface Feedable
{
    public function toFeed(): FeedEntity;

    public static function feedMedia(FeedContext $context): ?FeedMedia;
}
MethodRuns AtReturns
toFeed()publication and every model savesnapshot labels, data, and bodies
feedMedia()feed retrieval, called statically with snapshot datacurrent links and media

InteractsWithFeed implements both methods. You may use its defaults or override either method on the model.

Implementing the Feedable Contract ​

The Feedable interface defines the toFeed and feedMedia methods. The InteractsWithFeed trait implements feedMedia using your registered closure. You may implement the static method directly:

app/Models/Order.php
php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Storyfeed\Concerns\InteractsWithFeed;
use Storyfeed\Contracts\Feedable;
use Storyfeed\FeedContext;
use Storyfeed\FeedEntity;
use Storyfeed\FeedMedia;

class Order extends Model implements Feedable
{
    use InteractsWithFeed;

    public function toFeed(): FeedEntity
    {
        return FeedEntity::make()
            ->label("Order #{$this->reference}");
    }

    public static function feedMedia(FeedContext $context): ?FeedMedia
    {
        return FeedMedia::make()
            ->url(route('orders.show', $context->routeKey()));
    }
}
php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Storyfeed\Concerns\InteractsWithFeed;
use Storyfeed\Contracts\Feedable;
use Storyfeed\FeedContext;
use Storyfeed\FeedEntity;
use Storyfeed\FeedMedia;

class Order extends Model implements Feedable
{
    use InteractsWithFeed;

    public function toFeed(): FeedEntity
    {
        return FeedEntity::make(
            label: "Order #{$this->reference}",
        );
    }

    public static function feedMedia(FeedContext $context): ?FeedMedia
    {
        return FeedMedia::make(
            url: route('orders.show', $context->routeKey()),
        );
    }
}

A method defined on the model takes precedence over the trait's implementation.

InteractsWithFeed ​

MethodWhereRunsUse
describeFeed(): voidthe modelwhen the snapshot is writtenfill $this->feedEntity()
$this->feedEntity()inside describeFeed()when the snapshot is writtenthe FeedEntity the snapshot is written from
static::feedMediaUsing(fn (FeedContext $context, FeedMedia $media) => …)booted()when the feed is retrievedthe link and media
guessFeedLabel(): stringthe model, to overridewhen no label is setthe default label
updateFeedSnapshot()anywherewhen calledrefresh the snapshot outside a save
deleteFromFeed()anywherewhen calledsoft-delete every activity involving the model
forceDeleteFromFeed()anywherewhen calledpermanently delete every activity involving the model, including soft-deleted ones, with their grouping and participant records
storyfeed(?string $preset = null)anywherewhen calledthe model's own feed

A feedMediaUsing() closure receives a FeedContext and an empty FeedMedia. Return a URL string, the populated $media, or null for no link. Registering another closure replaces the first. Without a resolver, the model has no link.

Describing the Snapshot with describeFeed() ​

Define snapshot values by modifying the entity returned by $this->feedEntity():

app/Models/Order.php
php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Storyfeed\Body\Prose;
use Storyfeed\Concerns\InteractsWithFeed;
use Storyfeed\Contracts\Feedable;

class Order extends Model implements Feedable
{
    use InteractsWithFeed;

    public function describeFeed(): void
    {
        $this->feedEntity()
            ->body( 
                Prose::make($this->instructions),
            );
    }
}
php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Storyfeed\Body\Prose;
use Storyfeed\Concerns\InteractsWithFeed;
use Storyfeed\Contracts\Feedable;

class Order extends Model implements Feedable
{
    use InteractsWithFeed;

    public function describeFeed(): void
    {
        $this->feedEntity()
            ->body( 
                Prose::make(content: $this->instructions),
            );
    }
}

This example adds a body while retaining the default label. You may also set labels and data, or combine values from a parent model and its subclasses. Unset fields remain empty except for the label. If you implement toFeed, the trait does not call describeFeed.

Default Labels ​

Unset labels use the first available value:

GuessExample
the app-wide guesser, when it returns a stringwhatever it returns
a name attributeChicken Curry
a title attributeSpring Menu
the registered noun and the keyDish #42
the class name as words and the keyMenu Item #42

Custom Labels ​

To customize default labels across your application, register a callback in a service provider. Return null to use the default rules:

app/Providers/AppServiceProvider.phpboot()
php
use Illuminate\Database\Eloquent\Model;
use Storyfeed\Facades\Storyfeed;

Storyfeed::guessFeedLabelsUsing(
    fn (Model $model) => $model->getAttribute('reference'), 
);

To customize one model's default label, override its guessFeedLabel method. Overriding guessFeedLabel() bypasses the application-wide guesser. To call the trait's implementation from your override, alias it: use InteractsWithFeed { guessFeedLabel as guessedFeedLabel; }.

Snapshot Maintenance ​

InteractsWithFeed handles these model events while recording is enabled:

EventWhat happens
savedthe snapshot is refreshed
deletedthe model's activities are pointed at a tombstone, and its snapshot is deleted
restoredits activities are pointed back at the model, and the tombstone is deleted
forceDeletedthe tombstone becomes permanent

Activities stay unless their verb declares forgetWhenMissing(). deleteFromFeed() and forceDeleteFromFeed() explicitly remove activities. Deleted Models covers tombstones.

Retrieving the Model's Feed ​

$model->storyfeed() is shorthand for Storyfeed::feed()->involving($model). Pass a feed name to use a named feed: $model->storyfeed('customer').

The global storyfeed() helper returns the manager, or a pending activity when passed a verb. Inside a model, use $this->storyfeed() to retrieve that model's feed.

Registering External Models ​

To include a model from another package without modifying its class, register it in a service provider:

app/Providers/AppServiceProvider.phpboot()
php
use Spatie\MediaLibrary\MediaCollections\Models\Media;
use Storyfeed\Facades\Storyfeed;
use Storyfeed\FeedContext;
use Storyfeed\FeedEntity;
use Storyfeed\FeedMedia;

Storyfeed::feedable(Media::class)
    ->toFeedUsing(
        fn (Media $photo, FeedEntity $entity) => $entity
            ->label($photo->name)
            ->data(['mediaType' => $photo->mime_type]), 
    )
    ->feedMediaUsing(
        fn (FeedContext $context, FeedMedia $media) => $media
            ->url(route('photos.show', $context->routeKey())),
    );
MethodReceivesReturns
Storyfeed::feedable($class)a model classa registration to chain the methods below on
->toFeedUsing(fn (Model $model, FeedEntity $entity) => …)the model and an empty FeedEntitythe entity, or nothing; an unset label is guessed
->feedMediaUsing(fn (FeedContext $context, FeedMedia $media) => …)the FeedContext and an empty FeedMediaa URL string, the $media, or null

Both closures are optional. Storyfeed treats the registered class as Feedable: saves refresh snapshots, and deletion and restoration update its activities. Register the exact instantiated class; parent registrations do not apply to subclasses. Classes implementing Feedable cannot also be registered.

FeedEntity ​

FeedEntity::make() starts empty. Each argument has a matching method that updates and returns the entity.

php
FeedEntity::make()
    ->label("Order #{$this->reference}")
    ->data(['total' => $this->total])
    ->body(Prose::make($this->instructions));
php
FeedEntity::make(
    label: "Order #{$this->reference}",
    data: ['total' => $this->total],
    body: Prose::make(content: $this->instructions),
);
MethodTypeOn the Payload
label()?stringentity.label
data()array or Arrayable, merged; or a key and a valueentity.data, available to feedMedia()
body()a body, a string, or a list; each call appendsentity.body
content()?stringauthored text, for comments and posts
mediaType()?stringthe encoding of content
attributedTo()?stringthe author's IRI
tombstone()Closure(PendingTombstone)not on the payload: what the model's tombstone keeps

FeedEntity supports when() and unless() through Conditionable. See Activity Content for body types.

Payload shape: entity object.

Storing Snapshot Data ​

Use the data method in toFeed to store values with the snapshot, such as an image's media type and dimensions:

app/Models/MenuItem.php
php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Storyfeed\Concerns\InteractsWithFeed;
use Storyfeed\Contracts\Feedable;
use Storyfeed\FeedEntity;

class MenuItem extends Model implements Feedable
{
    use InteractsWithFeed;

    public function toFeed(): FeedEntity
    {
        return FeedEntity::make()
            ->label($this->name)
            ->data([ 
                'mediaType' => $this->photo_mime,
                'width' => $this->photo_width,
                'height' => $this->photo_height,
            ]);
    }
}
app/Models/MenuItem.phptoFeed()
php
use Storyfeed\FeedEntity;

return FeedEntity::make(
    label: $this->name,
    data: [ 
        'mediaType' => $this->photo_mime,
        'width' => $this->photo_width,
        'height' => $this->photo_height,
    ],
);

These values are stored when the snapshot is written. A media resolver can retrieve them through $context->data('mediaType'); a missing key returns null.

PendingTombstone ​

app/Models/Order.phpdescribeFeed()
php
use Storyfeed\PendingTombstone;

$this->feedEntity()
    ->label("Order #{$this->reference}")
    ->tombstone(fn (PendingTombstone $tombstone) => $tombstone->keepLabel());
MethodEffect
keepLabel(bool $keep = true)the tombstone keeps the model's label, for display in its activities

keepLabel() applies only to model-event deletions. Tombstones created by storyfeed:trickle or Storyfeed::tombstone() omit labels. Use the verb's forgetWhenMissing() setting to delete affected activities. See Deleted Models.

FeedContext ​

feedMedia() receives a FeedContext for each entity with a snapshot, including sampled group entities that may not display as links. Resolvers should not write data or query the database except through model().

AccessorReturns
$context->type()the morph alias, as stored on the activity
$context->key()the entity's key, as getKey() returns it
$context->routeKey()the entity's route key, as getRouteKey() returned it when the snapshot was written; key() when no route key is stored
$context->label()the cached label
$context->data()the data array the snapshot holds
$context->data('mediaType')one value from it, by dot path ('photo.width'); a missing key returns null, or as the second argument
$context->feed()the registered name of the feed being retrieved, or null on an ad-hoc feed and in the Activity Streams serializer
$context->model()the current model, or null

If the resolver throws, Storyfeed reports the exception and returns url: null and media: null for that entity. The rest of the feed still renders.

Loading the Model ​

php
$document = $context->model(with: ['project'], withTrashed: true);

model() loads all entities of a class with one query per page. It returns null for missing or soft-deleted models, or when storyfeed.hydration.enabled is false. Handle null in your resolver.

ArgumentEffect
with: ['project']eager loads the relation for all loaded models; nested access without it is an N+1
withCount: ['comments']loads relationship counts for all loaded models
withTrashed: trueincludes soft-deleted records, on models that soft-delete

ActivityContext ​

The closures passed to headline(), anonymousHeadline() and missingHeadline() receive a Storyfeed\ActivityContext. It provides the activity's data, verb, publication time and roles. It does not expose the Activity model. The context is immutable.

Activity and Roles ​

MethodReturns
verb()the recorded verb as a string
publishedAt()the publication time as a Carbon\CarbonImmutable, or null
actor()the actor's FeedContext, or null
object()the object's FeedContext, or null
target()the target's FeedContext, or null
context()the context role's FeedContext, or null
origin()the origin's FeedContext, or null
result()the result's FeedContext, or null
instrument()the instrument's FeedContext, or null

For example, $activity->actor()?->label() returns the actor's cached label. An empty role returns null. A role whose snapshot is missing still provides its recorded type and key, with a null label and empty data. Role contexts use the same feed name and model hydration as feedMedia() contexts.

Activity Data ​

ActivityContext uses Laravel's InteractsWithData trait. It offers the same typed helpers as Laravel's request, applied to the activity's data. Keys support dot notation.

MethodReturns or behaviour
get($key, $default = null)one value, or the default, as on Laravel's Fluent
all($keys = null)all data, or selected keys; missing selected keys have null values
boolean($key = null, $default = false)a boolean
string($key, $default = null)an Illuminate\Support\Stringable
str($key, $default = null)an alias for string()
integer($key, $default = 0)an integer
float($key, $default = 0.0)a float
date($key, $format = null, $tz = null)a Carbon date, or null for an empty value; invalid formats may throw
enum($key, $enumClass, $default = null)a backed enum case, or the default
enums($key, $enumClass)an array of valid backed enum cases
array($key = null)data as an array, or selected keys when given an array of keys
collect($key = null)data as a collection, or selected keys when given an array of keys
exists($key)an alias for has()
has($key)whether all given keys exist, including values of null
hasAny($keys)whether any given key exists
filled($key)whether all given values are non-empty
isNotFilled($key)whether all given values are empty
anyFilled($keys)whether any given value is non-empty
missing($key)whether any given key is absent
whenHas($key, $callback, $default = null)calls the callback when the key exists
whenFilled($key, $callback, $default = null)calls the callback when the value is non-empty
whenMissing($key, $callback, $default = null)calls the callback when the key is absent
only($keys)selected data, omitting absent keys
except($keys)all data except the given keys

Additional helpers follow the installed Laravel version. Laravel 13 also provides clamp($key, $min, $max, $default = 0) for a bounded number, interval($key, $unit = null) for a Carbon interval, and whenEnum($key, $enumClass, $callback, $default = null) for a valid enum case.

The conditional helpers return the callback's result or the context, using Laravel's behaviour. Unknown methods throw an error; the context does not support macros or dynamic property access.

A FeedLink contains a label and an href. Bodies accept it wherever a piece of text may link to a page.

php
use Storyfeed\FeedLink;

FeedLink::make($label, $url);
FeedLink::make()->label($label)->href($url);
MethodEffect
make($label, $href)create a link with its label and destination
label(string $label)set the text to display
href($href)set the destination URL
BodyFields That Accept FeedLink
ItemListeach entry in items (also accepts strings), and more
MediaObjectsubject and footnote (both also accept strings)

The href is stored as written and may become stale if its destination changes or a signed URL expires. A plain string remains unlinked.

The label names the thing being linked to; it is not an instruction such as “Open the conversation”. See Links in Bodies for examples.

FeedMedia ​

Every argument FeedMedia::make() takes has a method of the same name.

php
FeedMedia::make()->url($url)->attributes(['target' => '_blank']);
// replaces the snapshot label on the item
FeedMedia::make()->url($url)->label($label);
// hint the renderer to open as a modal
FeedMedia::make()->url($url)->modal();
FeedMedia::make()->url($url)->preview($thumb)->icon($avatar);
php
FeedMedia::make(url: $url, attributes: ['target' => '_blank']);
// replaces the snapshot label on the item
FeedMedia::make(url: $url, label: $label);
// hint the renderer to open as a modal
FeedMedia::make(url: $url, modal: true);
FeedMedia::make(url: $url, preview: $thumb, icon: $avatar);
MethodTypeOn the Payload
url()string, or a FeedImage when the resource is an imageentity.url
label()stringreplaces entity.label
attributes()array, merged; or a key and a valueentity.attributes
modal()bool, default trueentity.modal
icon(), preview(), image()FeedImage, or a bare src stringentity.media
files()FeedResources, for a PDF or other non-image resource; each call appendsentity.media.files
body()a body, a list, or a closure called when the body is resolved; each call appendsentity.body, after the stored bodies

See Linking to the Model for URL, modal, and attribute examples.

Showing Image Previews ​

See Showing Pictures. A picture appears only when a body names its slot; the entity URL is only a link destination.

Image Slots ​

See Feed Media for the three slots and their Activity Streams meanings.

Rich Content ​

php
use Storyfeed\Body\Component;

FeedEntity::make()
    ->label($this->name)
    ->body(Component::make()->name('Resource')->props(['status' => $this->status]));
php
use Storyfeed\Body\Component;

FeedEntity::make(
    label: $this->name,
    body: Component::make(name: 'Resource', props: ['status' => $this->status]),
);

A Component body appears in entity.body with its name and props. Your frontend maps the name to a component. See Custom Body Types.

Morph Aliases ​

Aliases come from the application's morph map. The morph_map configuration in config/storyfeed.php is merged into it. Storyfeed's own aliases resolve without an application mapping.

Activities with unresolved role aliases remain in the payload without a label or link for that role. storyfeed:trickle counts them as unresolved and soft-deletes them only with storyfeed.trickle.prune or --prune.

Installation covers enforcing the map.

Released under the MIT License.